fix(web/config): preserve and update builtin web provider API keys

This commit is contained in:
Alix-007 2026-03-27 01:32:34 +08:00
parent 935d5f8660
commit 9db32d996b
7 changed files with 181 additions and 14 deletions

View file

@ -191,6 +191,9 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
} }
type webSearchAPIKeyPatch struct { type webSearchAPIKeyPatch struct {
braveAPIKey *string
tavilyAPIKey *string
perplexityAPIKey *string
glmSearchAPIKey *string glmSearchAPIKey *string
baiduSearchAPIKey *string baiduSearchAPIKey *string
} }
@ -199,6 +202,15 @@ func parseWebSearchAPIKeyPatch(body []byte) webSearchAPIKeyPatch {
var raw struct { var raw struct {
Tools *struct { Tools *struct {
Web *struct { Web *struct {
Brave *struct {
APIKey *string `json:"api_key"`
} `json:"brave"`
Tavily *struct {
APIKey *string `json:"api_key"`
} `json:"tavily"`
Perplexity *struct {
APIKey *string `json:"api_key"`
} `json:"perplexity"`
GLMSearch *struct { GLMSearch *struct {
APIKey *string `json:"api_key"` APIKey *string `json:"api_key"`
} `json:"glm_search"` } `json:"glm_search"`
@ -214,6 +226,15 @@ func parseWebSearchAPIKeyPatch(body []byte) webSearchAPIKeyPatch {
patch := webSearchAPIKeyPatch{} patch := webSearchAPIKeyPatch{}
if raw.Tools != nil && raw.Tools.Web != nil { if raw.Tools != nil && raw.Tools.Web != nil {
if raw.Tools.Web.Brave != nil && raw.Tools.Web.Brave.APIKey != nil {
patch.braveAPIKey = raw.Tools.Web.Brave.APIKey
}
if raw.Tools.Web.Tavily != nil && raw.Tools.Web.Tavily.APIKey != nil {
patch.tavilyAPIKey = raw.Tools.Web.Tavily.APIKey
}
if raw.Tools.Web.Perplexity != nil && raw.Tools.Web.Perplexity.APIKey != nil {
patch.perplexityAPIKey = raw.Tools.Web.Perplexity.APIKey
}
if raw.Tools.Web.GLMSearch != nil && raw.Tools.Web.GLMSearch.APIKey != nil { if raw.Tools.Web.GLMSearch != nil && raw.Tools.Web.GLMSearch.APIKey != nil {
patch.glmSearchAPIKey = raw.Tools.Web.GLMSearch.APIKey patch.glmSearchAPIKey = raw.Tools.Web.GLMSearch.APIKey
} }
@ -225,6 +246,15 @@ func parseWebSearchAPIKeyPatch(body []byte) webSearchAPIKeyPatch {
} }
func applyWebSearchAPIKeyPatch(cfg *config.Config, patch webSearchAPIKeyPatch) { func applyWebSearchAPIKeyPatch(cfg *config.Config, patch webSearchAPIKeyPatch) {
if patch.braveAPIKey != nil {
cfg.Tools.Web.Brave.SetAPIKey(strings.TrimSpace(*patch.braveAPIKey))
}
if patch.tavilyAPIKey != nil {
cfg.Tools.Web.Tavily.SetAPIKey(strings.TrimSpace(*patch.tavilyAPIKey))
}
if patch.perplexityAPIKey != nil {
cfg.Tools.Web.Perplexity.SetAPIKey(strings.TrimSpace(*patch.perplexityAPIKey))
}
if patch.glmSearchAPIKey != nil { if patch.glmSearchAPIKey != nil {
cfg.Tools.Web.GLMSearch.SetAPIKey(strings.TrimSpace(*patch.glmSearchAPIKey)) cfg.Tools.Web.GLMSearch.SetAPIKey(strings.TrimSpace(*patch.glmSearchAPIKey))
} }

View file

@ -160,32 +160,61 @@ func runConfigRequest(t *testing.T, configPath, method, body string) {
} }
} }
func assertWebSearchAPIKeys(t *testing.T, configPath, wantGLMAPIKey, wantBaiduAPIKey string) { type webSearchAPIKeys struct {
brave string
tavily string
perplexity string
glm string
baidu string
}
func assertWebSearchAPIKeys(t *testing.T, configPath string, want webSearchAPIKeys) {
t.Helper() t.Helper()
cfg, err := config.LoadConfig(configPath) cfg, err := config.LoadConfig(configPath)
if err != nil { if err != nil {
t.Fatalf("LoadConfig() error = %v", err) t.Fatalf("LoadConfig() error = %v", err)
} }
if got := cfg.Tools.Web.GLMSearch.APIKey(); got != wantGLMAPIKey { if got := cfg.Tools.Web.Brave.APIKey(); got != want.brave {
t.Fatalf("tools.web.glm_search.api_key = %q, want %q", got, wantGLMAPIKey) t.Fatalf("tools.web.brave.api_key = %q, want %q", got, want.brave)
} }
if got := cfg.Tools.Web.BaiduSearch.APIKey(); got != wantBaiduAPIKey { if got := cfg.Tools.Web.Tavily.APIKey(); got != want.tavily {
t.Fatalf("tools.web.baidu_search.api_key = %q, want %q", got, wantBaiduAPIKey) t.Fatalf("tools.web.tavily.api_key = %q, want %q", got, want.tavily)
}
if got := cfg.Tools.Web.Perplexity.APIKey(); got != want.perplexity {
t.Fatalf("tools.web.perplexity.api_key = %q, want %q", got, want.perplexity)
}
if got := cfg.Tools.Web.GLMSearch.APIKey(); got != want.glm {
t.Fatalf("tools.web.glm_search.api_key = %q, want %q", got, want.glm)
}
if got := cfg.Tools.Web.BaiduSearch.APIKey(); got != want.baidu {
t.Fatalf("tools.web.baidu_search.api_key = %q, want %q", got, want.baidu)
} }
} }
func TestHandlePatchConfig_PreservesWebSearchAPIKeysWhenOmitted(t *testing.T) { func TestHandlePatchConfig_PreservesWebSearchAPIKeysWhenOmitted(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
seedWebSearchAPIKeys(t, configPath, "glm-existing-key", "baidu-existing-key") seedWebSearchAPIKeys(t, configPath, webSearchAPIKeys{
brave: "brave-existing-key",
tavily: "tavily-existing-key",
perplexity: "perplexity-existing-key",
glm: "glm-existing-key",
baidu: "baidu-existing-key",
})
runConfigRequest(t, configPath, http.MethodPatch, `{ runConfigRequest(t, configPath, http.MethodPatch, `{
"gateway": { "gateway": {
"log_level": "info" "log_level": "info"
} }
}`) }`)
assertWebSearchAPIKeys(t, configPath, "glm-existing-key", "baidu-existing-key") assertWebSearchAPIKeys(t, configPath, webSearchAPIKeys{
brave: "brave-existing-key",
tavily: "tavily-existing-key",
perplexity: "perplexity-existing-key",
glm: "glm-existing-key",
baidu: "baidu-existing-key",
})
} }
func TestHandlePatchConfig_UpdatesWebSearchAPIKeys(t *testing.T) { func TestHandlePatchConfig_UpdatesWebSearchAPIKeys(t *testing.T) {
@ -195,18 +224,33 @@ func TestHandlePatchConfig_UpdatesWebSearchAPIKeys(t *testing.T) {
runConfigRequest(t, configPath, http.MethodPatch, `{ runConfigRequest(t, configPath, http.MethodPatch, `{
"tools": { "tools": {
"web": { "web": {
"brave": { "api_key": "brave-updated-key" },
"tavily": { "api_key": "tavily-updated-key" },
"perplexity": { "api_key": "perplexity-updated-key" },
"glm_search": { "api_key": "glm-updated-key" }, "glm_search": { "api_key": "glm-updated-key" },
"baidu_search": { "api_key": "baidu-updated-key" } "baidu_search": { "api_key": "baidu-updated-key" }
} }
} }
}`) }`)
assertWebSearchAPIKeys(t, configPath, "glm-updated-key", "baidu-updated-key") assertWebSearchAPIKeys(t, configPath, webSearchAPIKeys{
brave: "brave-updated-key",
tavily: "tavily-updated-key",
perplexity: "perplexity-updated-key",
glm: "glm-updated-key",
baidu: "baidu-updated-key",
})
} }
func TestHandleUpdateConfig_PreservesWebSearchAPIKeysWhenOmitted(t *testing.T) { func TestHandleUpdateConfig_PreservesWebSearchAPIKeysWhenOmitted(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
seedWebSearchAPIKeys(t, configPath, "glm-existing-key-via-put", "baidu-existing-key-via-put") seedWebSearchAPIKeys(t, configPath, webSearchAPIKeys{
brave: "brave-existing-key-via-put",
tavily: "tavily-existing-key-via-put",
perplexity: "perplexity-existing-key-via-put",
glm: "glm-existing-key-via-put",
baidu: "baidu-existing-key-via-put",
})
runConfigRequest(t, configPath, http.MethodPut, `{ runConfigRequest(t, configPath, http.MethodPut, `{
"version": 1, "version": 1,
@ -224,7 +268,13 @@ func TestHandleUpdateConfig_PreservesWebSearchAPIKeysWhenOmitted(t *testing.T) {
} }
] ]
}`) }`)
assertWebSearchAPIKeys(t, configPath, "glm-existing-key-via-put", "baidu-existing-key-via-put") assertWebSearchAPIKeys(t, configPath, webSearchAPIKeys{
brave: "brave-existing-key-via-put",
tavily: "tavily-existing-key-via-put",
perplexity: "perplexity-existing-key-via-put",
glm: "glm-existing-key-via-put",
baidu: "baidu-existing-key-via-put",
})
} }
func TestHandleUpdateConfig_UpdatesWebSearchAPIKeys(t *testing.T) { func TestHandleUpdateConfig_UpdatesWebSearchAPIKeys(t *testing.T) {
@ -248,15 +298,24 @@ func TestHandleUpdateConfig_UpdatesWebSearchAPIKeys(t *testing.T) {
], ],
"tools": { "tools": {
"web": { "web": {
"brave": { "api_key": "brave-updated-key-via-put" },
"tavily": { "api_key": "tavily-updated-key-via-put" },
"perplexity": { "api_key": "perplexity-updated-key-via-put" },
"glm_search": { "api_key": "glm-updated-key-via-put" }, "glm_search": { "api_key": "glm-updated-key-via-put" },
"baidu_search": { "api_key": "baidu-updated-key-via-put" } "baidu_search": { "api_key": "baidu-updated-key-via-put" }
} }
} }
}`) }`)
assertWebSearchAPIKeys(t, configPath, "glm-updated-key-via-put", "baidu-updated-key-via-put") assertWebSearchAPIKeys(t, configPath, webSearchAPIKeys{
brave: "brave-updated-key-via-put",
tavily: "tavily-updated-key-via-put",
perplexity: "perplexity-updated-key-via-put",
glm: "glm-updated-key-via-put",
baidu: "baidu-updated-key-via-put",
})
} }
func seedWebSearchAPIKeys(t *testing.T, configPath, glmAPIKey, baiduAPIKey string) { func seedWebSearchAPIKeys(t *testing.T, configPath string, keys webSearchAPIKeys) {
t.Helper() t.Helper()
cfg, err := config.LoadConfig(configPath) cfg, err := config.LoadConfig(configPath)
@ -264,8 +323,11 @@ func seedWebSearchAPIKeys(t *testing.T, configPath, glmAPIKey, baiduAPIKey strin
t.Fatalf("LoadConfig() error = %v", err) t.Fatalf("LoadConfig() error = %v", err)
} }
cfg.Tools.Web.GLMSearch.SetAPIKey(glmAPIKey) cfg.Tools.Web.Brave.SetAPIKey(keys.brave)
cfg.Tools.Web.BaiduSearch.SetAPIKey(baiduAPIKey) cfg.Tools.Web.Tavily.SetAPIKey(keys.tavily)
cfg.Tools.Web.Perplexity.SetAPIKey(keys.perplexity)
cfg.Tools.Web.GLMSearch.SetAPIKey(keys.glm)
cfg.Tools.Web.BaiduSearch.SetAPIKey(keys.baidu)
if err := config.SaveConfig(configPath, cfg); err != nil { if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err) t.Fatalf("SaveConfig() error = %v", err)

View file

@ -183,6 +183,9 @@ export function ConfigPage() {
"Cron exec timeout", "Cron exec timeout",
{ min: 0 }, { min: 0 },
) )
const braveAPIKey = form.braveAPIKey.trim()
const tavilyAPIKey = form.tavilyAPIKey.trim()
const perplexityAPIKey = form.perplexityAPIKey.trim()
const glmSearchAPIKey = form.glmSearchAPIKey.trim() const glmSearchAPIKey = form.glmSearchAPIKey.trim()
const baiduSearchAPIKey = form.baiduSearchAPIKey.trim() const baiduSearchAPIKey = form.baiduSearchAPIKey.trim()
const execConfigPatch: Record<string, unknown> = { const execConfigPatch: Record<string, unknown> = {
@ -209,6 +212,15 @@ export function ConfigPage() {
} }
} }
if (braveAPIKey !== "") {
webConfigPatch.brave = { api_key: braveAPIKey }
}
if (tavilyAPIKey !== "") {
webConfigPatch.tavily = { api_key: tavilyAPIKey }
}
if (perplexityAPIKey !== "") {
webConfigPatch.perplexity = { api_key: perplexityAPIKey }
}
if (glmSearchAPIKey !== "") { if (glmSearchAPIKey !== "") {
webConfigPatch.glm_search = { api_key: glmSearchAPIKey } webConfigPatch.glm_search = { api_key: glmSearchAPIKey }
} }

View file

@ -521,6 +521,45 @@ export function WebSearchSection({
title={t("pages.config.sections.web_search")} title={t("pages.config.sections.web_search")}
description={t("pages.config.web_search_section_hint")} description={t("pages.config.web_search_section_hint")}
> >
<Field
label={t("pages.config.brave_api_key")}
hint={t("pages.config.brave_api_key_hint")}
layout="setting-row"
>
<Input
type="password"
value={form.braveAPIKey}
placeholder={t("pages.config.secret_placeholder")}
onChange={(e) => onFieldChange("braveAPIKey", e.target.value)}
/>
</Field>
<Field
label={t("pages.config.tavily_api_key")}
hint={t("pages.config.tavily_api_key_hint")}
layout="setting-row"
>
<Input
type="password"
value={form.tavilyAPIKey}
placeholder={t("pages.config.secret_placeholder")}
onChange={(e) => onFieldChange("tavilyAPIKey", e.target.value)}
/>
</Field>
<Field
label={t("pages.config.perplexity_api_key")}
hint={t("pages.config.perplexity_api_key_hint")}
layout="setting-row"
>
<Input
type="password"
value={form.perplexityAPIKey}
placeholder={t("pages.config.secret_placeholder")}
onChange={(e) => onFieldChange("perplexityAPIKey", e.target.value)}
/>
</Field>
<Field <Field
label={t("pages.config.glm_search_api_key")} label={t("pages.config.glm_search_api_key")}
hint={t("pages.config.glm_search_api_key_hint")} hint={t("pages.config.glm_search_api_key_hint")}

View file

@ -14,6 +14,9 @@ export interface CoreConfigForm {
execTimeoutSeconds: string execTimeoutSeconds: string
allowCommand: boolean allowCommand: boolean
cronExecTimeoutMinutes: string cronExecTimeoutMinutes: string
braveAPIKey: string
tavilyAPIKey: string
perplexityAPIKey: string
glmSearchAPIKey: string glmSearchAPIKey: string
baiduSearchAPIKey: string baiduSearchAPIKey: string
maxTokens: string maxTokens: string
@ -79,6 +82,9 @@ export const EMPTY_FORM: CoreConfigForm = {
execTimeoutSeconds: "0", execTimeoutSeconds: "0",
allowCommand: true, allowCommand: true,
cronExecTimeoutMinutes: "5", cronExecTimeoutMinutes: "5",
braveAPIKey: "",
tavilyAPIKey: "",
perplexityAPIKey: "",
glmSearchAPIKey: "", glmSearchAPIKey: "",
baiduSearchAPIKey: "", baiduSearchAPIKey: "",
maxTokens: "32768", maxTokens: "32768",
@ -133,6 +139,9 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
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 web = asRecord(tools.web)
const brave = asRecord(web.brave)
const tavily = asRecord(web.tavily)
const perplexity = asRecord(web.perplexity)
const glmSearch = asRecord(web.glm_search) const glmSearch = asRecord(web.glm_search)
const baiduSearch = asRecord(web.baidu_search) const baiduSearch = asRecord(web.baidu_search)
const cron = asRecord(tools.cron) const cron = asRecord(tools.cron)
@ -191,6 +200,9 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
cron.exec_timeout_minutes, cron.exec_timeout_minutes,
EMPTY_FORM.cronExecTimeoutMinutes, EMPTY_FORM.cronExecTimeoutMinutes,
), ),
braveAPIKey: asString(brave.api_key),
tavilyAPIKey: asString(tavily.api_key),
perplexityAPIKey: asString(perplexity.api_key),
glmSearchAPIKey: asString(glmSearch.api_key), glmSearchAPIKey: asString(glmSearch.api_key),
baiduSearchAPIKey: asString(baiduSearch.api_key), baiduSearchAPIKey: asString(baiduSearch.api_key),
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens), maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),

View file

@ -479,6 +479,12 @@
"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.", "web_search_section_hint": "Manage API keys for built-in web search providers. Leave a field blank to keep the current key unchanged.",
"brave_api_key": "Brave Search API Key",
"brave_api_key_hint": "Used by the built-in Brave web search provider.",
"tavily_api_key": "Tavily Search API Key",
"tavily_api_key_hint": "Used by the built-in Tavily web search provider.",
"perplexity_api_key": "Perplexity Search API Key",
"perplexity_api_key_hint": "Used by the built-in Perplexity web search provider.",
"glm_search_api_key": "GLM Search API Key", "glm_search_api_key": "GLM Search API Key",
"glm_search_api_key_hint": "Used by the built-in GLM web search provider.", "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": "Baidu Search API Key",

View file

@ -479,6 +479,12 @@
"cron_exec_timeout": "定时命令超时(分钟)", "cron_exec_timeout": "定时命令超时(分钟)",
"cron_exec_timeout_hint": "定时任务中命令的最长运行时间。设置为 0 表示不限制超时。", "cron_exec_timeout_hint": "定时任务中命令的最长运行时间。设置为 0 表示不限制超时。",
"web_search_section_hint": "管理内置网页搜索服务的 API Key。留空表示保持当前 Key 不变。", "web_search_section_hint": "管理内置网页搜索服务的 API Key。留空表示保持当前 Key 不变。",
"brave_api_key": "Brave 搜索 API Key",
"brave_api_key_hint": "用于内置的 Brave 网页搜索服务。",
"tavily_api_key": "Tavily 搜索 API Key",
"tavily_api_key_hint": "用于内置的 Tavily 网页搜索服务。",
"perplexity_api_key": "Perplexity 搜索 API Key",
"perplexity_api_key_hint": "用于内置的 Perplexity 网页搜索服务。",
"glm_search_api_key": "GLM 搜索 API Key", "glm_search_api_key": "GLM 搜索 API Key",
"glm_search_api_key_hint": "用于内置的 GLM 网页搜索服务。", "glm_search_api_key_hint": "用于内置的 GLM 网页搜索服务。",
"baidu_search_api_key": "百度搜索 API Key", "baidu_search_api_key": "百度搜索 API Key",