From 92531999e980aa7cb10b96bd4f239b61e2fcd8fe Mon Sep 17 00:00:00 2001 From: Haoqian Li Date: Wed, 24 Jun 2026 22:40:20 +0800 Subject: [PATCH] fix(ui): allow web search tests to use env keys --- ui/server/routes/config.js | 80 +++++- ui/server/routes/config.test.js | 250 ++++++++++++++++++ .../settings/view/tabs/PilotDeckConfigTab.tsx | 45 ++-- .../tabs/PilotDeckConfigTab.webSearch.test.ts | 29 ++ .../settings/view/tabs/pilotDeckConfigForm.ts | 26 ++ ui/src/i18n/locales/en/settings.json | 6 +- ui/src/i18n/locales/zh-CN/settings.json | 6 +- 7 files changed, 402 insertions(+), 40 deletions(-) create mode 100644 ui/server/routes/config.test.js create mode 100644 ui/src/components/settings/view/tabs/PilotDeckConfigTab.webSearch.test.ts diff --git a/ui/server/routes/config.js b/ui/server/routes/config.js index d03aa969..ad69b046 100644 --- a/ui/server/routes/config.js +++ b/ui/server/routes/config.js @@ -6,6 +6,7 @@ import { prepareBackgroundSpawnOptions } from '../utils/processSpawn.js'; import { parse as parseYaml } from 'yaml'; import { buildDefaultPilotDeckConfig, + buildRuntimeEnv, configToYaml, getPilotDeckConfigPath, maskSecrets, @@ -29,6 +30,60 @@ async function notifyGatewayConfigReload() { } const router = express.Router(); +const MASK = '********'; +const WEB_SEARCH_ENV_KEYS = { + glm: ['GLM_WEB_SEARCH_API_KEY', 'ZAI_API_KEY'], + tavily: ['TAVILY_API_KEY'], + custom: ['CUSTOM_WEB_SEARCH_API_KEY'], +}; + +function isRecord(value) { + return value && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeCredential(value) { + if (typeof value !== 'string') return ''; + const trimmed = value.trim(); + if (!trimmed || trimmed === MASK || trimmed === 'PLACEHOLDER_RUN_ONBOARDING_TO_REPLACE' || trimmed.startsWith('PLACEHOLDER_')) { + return ''; + } + return trimmed; +} + +function buildWebSearchCredentialEnv(requestCustomEnv) { + let diskConfig = buildDefaultPilotDeckConfig(); + try { + diskConfig = readPilotDeckConfigFile().config; + } catch { + // Keep the test route usable even when the main config is temporarily invalid. + } + + const diskCustomEnv = isRecord(diskConfig.customEnv) ? diskConfig.customEnv : {}; + const restoredRequestCustomEnv = isRecord(requestCustomEnv) + ? preserveMaskedSecrets(requestCustomEnv, diskCustomEnv) + : {}; + const customEnv = isRecord(requestCustomEnv) && isRecord(restoredRequestCustomEnv) + ? restoredRequestCustomEnv + : diskCustomEnv; + + return { + ...process.env, + ...buildRuntimeEnv({ + ...diskConfig, + customEnv, + }), + }; +} + +function resolveWebSearchTestApiKey(apiKey, provider, env) { + const inlineKey = normalizeCredential(apiKey); + if (inlineKey) return inlineKey; + for (const key of WEB_SEARCH_ENV_KEYS[provider] ?? WEB_SEARCH_ENV_KEYS.glm) { + const envKey = normalizeCredential(env[key]); + if (envKey) return envKey; + } + return ''; +} function serializeConfigResponse(record, reloadResult = null) { const validation = validatePilotDeckConfig(record.config); @@ -312,7 +367,7 @@ router.post('/test-connection', async (req, res) => { * established by `/test-connection`. */ router.post('/test-web-search', async (req, res) => { - const { provider, apiKey, endpoint, customProvider } = req.body || {}; + const { provider, apiKey, endpoint, customProvider, customEnv } = req.body || {}; const selectedProvider = provider === 'tavily' || provider === 'custom' ? provider : 'glm'; const custom = customProvider && typeof customProvider === 'object' ? customProvider : {}; const customAuth = typeof custom.auth === 'string' ? custom.auth : 'bearer'; @@ -320,8 +375,9 @@ router.post('/test-web-search', async (req, res) => { const queryParam = typeof custom.queryParam === 'string' && custom.queryParam.trim() ? custom.queryParam.trim() : 'query'; const apiKeyParam = typeof custom.apiKeyParam === 'string' && custom.apiKeyParam.trim() ? custom.apiKeyParam.trim() : 'api_key'; const resultsPath = typeof custom.resultsPath === 'string' ? custom.resultsPath.trim() : ''; - const trimmedKey = typeof apiKey === 'string' ? apiKey.trim() : ''; - if (!trimmedKey && !(selectedProvider === 'custom' && customAuth === 'none')) { + const credentialEnv = buildWebSearchCredentialEnv(customEnv); + const effectiveKey = resolveWebSearchTestApiKey(apiKey, selectedProvider, credentialEnv); + if (!effectiveKey && !(selectedProvider === 'custom' && customAuth === 'none')) { return res.status(400).json({ ok: false, error: 'API key is required.' }); } const trimmedEndpoint = typeof endpoint === 'string' ? endpoint.trim() : ''; @@ -347,7 +403,7 @@ router.post('/test-web-search', async (req, res) => { Accept: 'application/json', }, body: JSON.stringify({ - api_key: trimmedKey, + api_key: effectiveKey, query: 'hello', max_results: 3, include_answer: true, @@ -363,13 +419,13 @@ router.post('/test-web-search', async (req, res) => { headers['Content-Type'] = 'application/json'; body[queryParam] = 'hello'; } - if (customAuth === 'bearer' && trimmedKey) { - headers.Authorization = `Bearer ${trimmedKey}`; - } else if (customAuth === 'queryApiKey' && trimmedKey) { - url.searchParams.set(apiKeyParam, trimmedKey); - } else if (customAuth === 'bodyApiKey' && trimmedKey) { - if (customMethod === 'GET') url.searchParams.set(apiKeyParam, trimmedKey); - else body[apiKeyParam] = trimmedKey; + if (customAuth === 'bearer' && effectiveKey) { + headers.Authorization = `Bearer ${effectiveKey}`; + } else if (customAuth === 'queryApiKey' && effectiveKey) { + url.searchParams.set(apiKeyParam, effectiveKey); + } else if (customAuth === 'bodyApiKey' && effectiveKey) { + if (customMethod === 'GET') url.searchParams.set(apiKeyParam, effectiveKey); + else body[apiKeyParam] = effectiveKey; } requestUrl = url.toString(); requestInit = { @@ -382,7 +438,7 @@ router.post('/test-web-search', async (req, res) => { requestInit = { method: 'POST', headers: { - Authorization: `Bearer ${trimmedKey}`, + Authorization: `Bearer ${effectiveKey}`, 'Content-Type': 'application/json', Accept: 'application/json', }, diff --git a/ui/server/routes/config.test.js b/ui/server/routes/config.test.js new file mode 100644 index 00000000..4fc90885 --- /dev/null +++ b/ui/server/routes/config.test.js @@ -0,0 +1,250 @@ +import http from 'node:http'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import express from 'express'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const TEST_ENV_KEYS = [ + 'PILOT_HOME', + 'PILOTDECK_CONFIG_PATH', + 'TAVILY_API_KEY', + 'CUSTOM_WEB_SEARCH_API_KEY', + 'GLM_WEB_SEARCH_API_KEY', + 'ZAI_API_KEY', +]; + +let previousEnv; +let tempRoot; + +async function createConfigApp() { + vi.resetModules(); + vi.doMock('../pilotdeck-bridge.js', () => ({ + getPilotDeckGateway: vi.fn(async () => null), + })); + vi.doMock('../services/pilotdeckConfigReloader.js', () => ({ + reloadPilotDeckConfig: vi.fn(async () => null), + })); + vi.doMock('../services/pilotdeckConfigWatcher.js', () => ({ + suppressNextWatchEvent: vi.fn(), + })); + const { default: configRouter } = await import('./config.js'); + const app = express(); + app.use(express.json()); + app.use('/api/config', configRouter); + return app; +} + +async function postJson(app, body) { + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + + try { + return await new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/api/config/test-web-search', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + (res) => { + let raw = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { raw += chunk; }); + res.on('end', () => { + try { + resolve({ status: res.statusCode, body: JSON.parse(raw) }); + } catch (error) { + reject(error); + } + }); + }, + ); + req.on('error', reject); + req.end(JSON.stringify(body)); + }); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} + +function mockProviderFetch(responseBody = { results: [{ title: 'ok' }] }) { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(responseBody), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('config web search test route', () => { + beforeEach(() => { + previousEnv = Object.fromEntries(TEST_ENV_KEYS.map((key) => [key, process.env[key]])); + for (const key of TEST_ENV_KEYS) delete process.env[key]; + tempRoot = mkdtempSync(join(tmpdir(), 'pilotdeck-web-search-test-')); + process.env.PILOT_HOME = join(tempRoot, 'pilot-home'); + }); + + afterEach(() => { + for (const key of TEST_ENV_KEYS) { + if (previousEnv[key] === undefined) delete process.env[key]; + else process.env[key] = previousEnv[key]; + } + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + if (tempRoot) rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('uses TAVILY_API_KEY when no inline Tavily key is provided', async () => { + process.env.TAVILY_API_KEY = 'env-tavily'; + const fetchMock = mockProviderFetch(); + const app = await createConfigApp(); + + const result = await postJson(app, { provider: 'tavily', apiKey: '' }); + + expect(result.status).toBe(200); + expect(result.body.ok).toBe(true); + const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(requestBody.api_key).toBe('env-tavily'); + }); + + it('uses GLM_WEB_SEARCH_API_KEY before ZAI_API_KEY for GLM tests', async () => { + process.env.GLM_WEB_SEARCH_API_KEY = 'env-glm'; + process.env.ZAI_API_KEY = 'env-zai'; + const fetchMock = mockProviderFetch({ search_result: [{ title: 'ok' }] }); + const app = await createConfigApp(); + + const result = await postJson(app, { provider: 'glm', apiKey: '' }); + + expect(result.status).toBe(200); + expect(result.body.ok).toBe(true); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer env-glm'); + }); + + it('falls back to ZAI_API_KEY when GLM_WEB_SEARCH_API_KEY is missing', async () => { + process.env.ZAI_API_KEY = 'env-zai'; + const fetchMock = mockProviderFetch({ search_result: [{ title: 'ok' }] }); + const app = await createConfigApp(); + + const result = await postJson(app, { provider: 'glm', apiKey: '' }); + + expect(result.status).toBe(200); + expect(result.body.ok).toBe(true); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer env-zai'); + }); + + it('uses request customEnv for custom bearer tests', async () => { + const fetchMock = mockProviderFetch(); + const app = await createConfigApp(); + + const result = await postJson(app, { + provider: 'custom', + apiKey: '', + endpoint: 'https://example.com/search', + customProvider: { auth: 'bearer' }, + customEnv: { CUSTOM_WEB_SEARCH_API_KEY: 'custom-env' }, + }); + + expect(result.status).toBe(200); + expect(result.body.ok).toBe(true); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer custom-env'); + }); + + it('preserves saved customEnv secrets when the request contains a masked value', async () => { + mkdirSync(process.env.PILOT_HOME, { recursive: true }); + writeFileSync( + join(process.env.PILOT_HOME, 'pilotdeck.yaml'), + [ + 'schemaVersion: 1', + 'customEnv:', + ' TAVILY_API_KEY: saved-tavily', + '', + ].join('\n'), + 'utf8', + ); + const fetchMock = mockProviderFetch(); + const app = await createConfigApp(); + + const result = await postJson(app, { + provider: 'tavily', + apiKey: '', + customEnv: { TAVILY_API_KEY: '********' }, + }); + + expect(result.status).toBe(200); + expect(result.body.ok).toBe(true); + const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(requestBody.api_key).toBe('saved-tavily'); + }); + + it('does not revive saved customEnv secrets that were removed from the current request', async () => { + mkdirSync(process.env.PILOT_HOME, { recursive: true }); + writeFileSync( + join(process.env.PILOT_HOME, 'pilotdeck.yaml'), + [ + 'schemaVersion: 1', + 'customEnv:', + ' TAVILY_API_KEY: saved-tavily', + '', + ].join('\n'), + 'utf8', + ); + const fetchMock = mockProviderFetch(); + const app = await createConfigApp(); + + const result = await postJson(app, { + provider: 'tavily', + apiKey: '', + customEnv: {}, + }); + + expect(result.status).toBe(400); + expect(result.body).toEqual({ ok: false, error: 'API key is required.' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('prefers inline API keys over environment keys', async () => { + process.env.TAVILY_API_KEY = 'env-tavily'; + const fetchMock = mockProviderFetch(); + const app = await createConfigApp(); + + const result = await postJson(app, { provider: 'tavily', apiKey: ' inline-tavily ' }); + + expect(result.status).toBe(200); + expect(result.body.ok).toBe(true); + const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(requestBody.api_key).toBe('inline-tavily'); + }); + + it('rejects missing credentials when no inline or environment key is available', async () => { + const fetchMock = mockProviderFetch(); + const app = await createConfigApp(); + + const result = await postJson(app, { provider: 'glm', apiKey: '' }); + + expect(result.status).toBe(400); + expect(result.body).toEqual({ ok: false, error: 'API key is required.' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('allows custom providers with auth none and sends no API key', async () => { + const fetchMock = mockProviderFetch(); + const app = await createConfigApp(); + + const result = await postJson(app, { + provider: 'custom', + apiKey: '', + endpoint: 'https://example.com/search', + customProvider: { auth: 'none', method: 'GET' }, + }); + + expect(result.status).toBe(200); + expect(result.body.ok).toBe(true); + expect(fetchMock.mock.calls[0][0]).toBe('https://example.com/search?query=hello'); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBeUndefined(); + }); +}); diff --git a/ui/src/components/settings/view/tabs/PilotDeckConfigTab.tsx b/ui/src/components/settings/view/tabs/PilotDeckConfigTab.tsx index aeba7d33..8c2c3ca5 100644 --- a/ui/src/components/settings/view/tabs/PilotDeckConfigTab.tsx +++ b/ui/src/components/settings/view/tabs/PilotDeckConfigTab.tsx @@ -51,7 +51,15 @@ import { type CatalogModel, } from '../../../../shared/catalogProviders'; import type { SettingsProject } from '../../types/types'; -import { isCronConfigEnabled, patch } from './pilotDeckConfigForm'; +import { + getWebSearchTestApiKey, + isCronConfigEnabled, + isMissingWebSearchCredentialError, + isMaskedSecret, + isWebSearchTestDisabled, + patch, + type WebSearchTestStatus, +} from './pilotDeckConfigForm'; // ── V2 schema types ──────────────────────────────────────────────────── // Schema mirrors ~/.pilotdeck/pilotdeck.yaml exactly. No more @@ -439,12 +447,6 @@ function rewriteProviderRefs(config: PilotDeckConfig, oldProviderId: string, new return next; } -const MASK = '********'; - -function isMaskedSecret(value: string | undefined): boolean { - return value === MASK; -} - /** Password fields must not bind MASK/placeholders as value — browsers render them as bullets. */ function secretDisplayValue(value: string | undefined): string { if (!value) return ''; @@ -454,11 +456,6 @@ function secretDisplayValue(value: string | undefined): string { return value; } -function hasUsableSecret(value: string | undefined): boolean { - const trimmed = (value ?? '').trim(); - return Boolean(trimmed) && !isMaskedSecret(trimmed) && trimmed !== 'PLACEHOLDER_RUN_ONBOARDING_TO_REPLACE' && !trimmed.startsWith('PLACEHOLDER_'); -} - function providerDisplayName(providerId: string, catalogEntry?: CatalogProvider, emptyFallback = 'Custom Provider'): string { if (catalogEntry?.displayName) return catalogEntry.displayName; const normalized = providerId.trim(); @@ -1927,7 +1924,7 @@ function ToolsSection({ config, onChange }: { config: PilotDeckConfig; onChange: // Test-connection state — modeled after onboarding's LlmConfigurationStep // so behaviour and accessibility match across the app. Reset whenever the // user edits the key or endpoint so a stale ✓ never lies about new input. - const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); + const [testStatus, setTestStatus] = useState('idle'); const [testMessage, setTestMessage] = useState(''); const resetTest = () => { @@ -1986,18 +1983,19 @@ function ToolsSection({ config, onChange }: { config: PilotDeckConfig; onChange: }; const handleTest = async () => { - const trimmedKey = hasUsableSecret(apiKey) ? apiKey.trim() : ''; - if (!trimmedKey) { - setTestStatus('error'); - setTestMessage(t('pilotDeckConfig.panels.tools.test.needsKey')); - return; - } + const trimmedKey = getWebSearchTestApiKey(apiKey); setTestStatus('testing'); setTestMessage(''); try { const res = await authenticatedFetch('/api/config/test-web-search', { method: 'POST', - body: JSON.stringify({ provider, apiKey: trimmedKey, endpoint: endpointValue.trim(), customProvider: custom }), + body: JSON.stringify({ + provider, + apiKey: trimmedKey, + endpoint: endpointValue.trim(), + customProvider: custom, + customEnv: config.customEnv ?? {}, + }), }); const data = await res.json(); if (data.ok) { @@ -2009,9 +2007,12 @@ function ToolsSection({ config, onChange }: { config: PilotDeckConfig; onChange: }), ); } else { + const errorMessage = isMissingWebSearchCredentialError(data.error) + ? t('pilotDeckConfig.panels.tools.test.needsKey') + : (data.error || 'unknown'); setTestStatus('error'); setTestMessage( - t('pilotDeckConfig.panels.tools.test.failedPrefix', { error: data.error || 'unknown' }), + t('pilotDeckConfig.panels.tools.test.failedPrefix', { error: errorMessage }), ); } } catch (err) { @@ -2153,7 +2154,7 @@ function ToolsSection({ config, onChange }: { config: PilotDeckConfig; onChange: variant="outline" size="sm" onClick={handleTest} - disabled={testStatus === 'testing' || !hasUsableSecret(apiKey)} + disabled={isWebSearchTestDisabled(testStatus)} > {testStatus === 'testing' ? ( diff --git a/ui/src/components/settings/view/tabs/PilotDeckConfigTab.webSearch.test.ts b/ui/src/components/settings/view/tabs/PilotDeckConfigTab.webSearch.test.ts new file mode 100644 index 00000000..9ee813a6 --- /dev/null +++ b/ui/src/components/settings/view/tabs/PilotDeckConfigTab.webSearch.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { + getWebSearchTestApiKey, + isMissingWebSearchCredentialError, + isWebSearchTestDisabled, +} from './pilotDeckConfigForm'; + +describe('PilotDeckConfigTab web search test helpers', () => { + it('sends only usable inline API keys to the backend', () => { + expect(getWebSearchTestApiKey(' inline-key ')).toBe('inline-key'); + expect(getWebSearchTestApiKey('')).toBe(''); + expect(getWebSearchTestApiKey('********')).toBe(''); + expect(getWebSearchTestApiKey('PLACEHOLDER_RUN_ONBOARDING_TO_REPLACE')).toBe(''); + expect(getWebSearchTestApiKey('PLACEHOLDER_WEB_SEARCH')).toBe(''); + }); + + it('does not disable testing just because the inline API key is empty', () => { + expect(isWebSearchTestDisabled('idle')).toBe(false); + expect(isWebSearchTestDisabled('error')).toBe(false); + expect(isWebSearchTestDisabled('success')).toBe(false); + expect(isWebSearchTestDisabled('testing')).toBe(true); + }); + + it('maps the backend missing-key response to localizable UI copy', () => { + expect(isMissingWebSearchCredentialError('API key is required.')).toBe(true); + expect(isMissingWebSearchCredentialError(' API key is required. ')).toBe(true); + expect(isMissingWebSearchCredentialError('Custom provider endpoint is required.')).toBe(false); + }); +}); diff --git a/ui/src/components/settings/view/tabs/pilotDeckConfigForm.ts b/ui/src/components/settings/view/tabs/pilotDeckConfigForm.ts index 5f70ec48..89fae430 100644 --- a/ui/src/components/settings/view/tabs/pilotDeckConfigForm.ts +++ b/ui/src/components/settings/view/tabs/pilotDeckConfigForm.ts @@ -6,6 +6,10 @@ type CronConfigShape = { }; }; +export type WebSearchTestStatus = 'idle' | 'testing' | 'success' | 'error'; + +const MASK = '********'; + export function patch(config: T, path: Path, value: unknown): T { // Immutable deep set. Each key cloned along the way so React picks up the // change. Numeric segments materialise arrays; everything else materialises @@ -28,3 +32,25 @@ export function patch(config: T, path: Path, value: unknown): T { export function isCronConfigEnabled(config: CronConfigShape): boolean { return config.cron !== undefined && config.cron.enabled !== false; } + +export function isMaskedSecret(value: string | undefined): boolean { + return value === MASK; +} + +export function hasUsableSecret(value: string | undefined): boolean { + const trimmed = (value ?? '').trim(); + return Boolean(trimmed) && !isMaskedSecret(trimmed) && trimmed !== 'PLACEHOLDER_RUN_ONBOARDING_TO_REPLACE' && !trimmed.startsWith('PLACEHOLDER_'); +} + +export function getWebSearchTestApiKey(value: string | undefined): string { + const trimmed = (value ?? '').trim(); + return hasUsableSecret(value) ? trimmed : ''; +} + +export function isWebSearchTestDisabled(status: WebSearchTestStatus): boolean { + return status === 'testing'; +} + +export function isMissingWebSearchCredentialError(error: unknown): boolean { + return typeof error === 'string' && error.trim() === 'API key is required.'; +} diff --git a/ui/src/i18n/locales/en/settings.json b/ui/src/i18n/locales/en/settings.json index ab4a9739..3a78d9f9 100644 --- a/ui/src/i18n/locales/en/settings.json +++ b/ui/src/i18n/locales/en/settings.json @@ -839,8 +839,8 @@ }, "apiKey": { "label": "API key", - "description": "API key for the selected provider.", - "placeholder": "provider API key", + "description": "API key for the selected provider. Leave empty to use the matching runtime environment variable.", + "placeholder": "provider API key or runtime env", "maskedPlaceholder": "Existing key kept — type to replace", "keyHidden": "Key hidden; leave as-is to keep, retype to replace." }, @@ -878,7 +878,7 @@ "test": { "button": "Test connection", "testing": "Testing…", - "needsKey": "Enter an API key first.", + "needsKey": "No API key is available from the form or runtime environment.", "success": "Connected: {{count}} result(s), {{latency}}ms.", "failedPrefix": "Connection failed: {{error}}" } diff --git a/ui/src/i18n/locales/zh-CN/settings.json b/ui/src/i18n/locales/zh-CN/settings.json index 83b69c48..43b453ed 100644 --- a/ui/src/i18n/locales/zh-CN/settings.json +++ b/ui/src/i18n/locales/zh-CN/settings.json @@ -839,8 +839,8 @@ }, "apiKey": { "label": "API 密钥", - "description": "当前提供商的 API 密钥。", - "placeholder": "提供商 API Key", + "description": "当前提供商的 API 密钥。留空时会使用匹配的运行时环境变量。", + "placeholder": "提供商 API Key 或运行时环境变量", "maskedPlaceholder": "已保留现有密钥,如需替换请重新输入", "keyHidden": "密钥已隐藏;留空会继续保留,重新输入可替换。" }, @@ -878,7 +878,7 @@ "test": { "button": "测试连接", "testing": "测试中…", - "needsKey": "请先填写 API Key。", + "needsKey": "表单和运行时环境中都没有可用的 API Key。", "success": "连接正常:{{count}} 条结果,{{latency}}ms。", "failedPrefix": "连接失败:{{error}}" }