release: bump version to 0.51.0

This commit is contained in:
rayd1o
2026-05-11 09:49:08 +08:00
parent 455b8360d0
commit 1cb51b1172
52 changed files with 4440 additions and 279 deletions

View File

@@ -0,0 +1,824 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import {
ApiOutlined,
EyeInvisibleOutlined,
EyeOutlined,
PlayCircleOutlined,
SyncOutlined,
ToolOutlined,
} from '@ant-design/icons'
import {
Alert,
Button,
Card,
Checkbox,
Form,
Input,
InputNumber,
message,
Select,
Space,
Switch,
Tabs,
Tag,
Typography,
} from 'antd'
import axios from 'axios'
import { useSearchParams } from 'react-router-dom'
import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import { PlaygroundWorkspace } from '../Playground/Playground'
const { Title, Text } = Typography
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
const DEFAULT_PROVIDER_MAX_TOKENS = 4096
interface SecretStatus {
configured: boolean
preview: string
source?: string
}
interface AIProviderConfig {
provider: string
provider_api: string
base_url: string
model: string
api_key: SecretStatus
max_tokens: number
anthropic_version: string
source?: string
}
interface WebSearchProviderConfig {
provider: string
base_url: string
api_key: SecretStatus
max_results: number
timeout_seconds: number
endpoint_path?: string
search_depth?: string
engine?: string
include_answer?: boolean
include_raw_content?: boolean
include_text?: boolean
categories?: string
engines?: string[]
search_path?: string
scrape_path?: string
scrape_formats?: string[]
source?: string
}
interface ExternalIntegrations {
ai_provider: {
service_url: string
service_token: SecretStatus
default_provider: string
provider: string
provider_api: string
base_url: string
model: string
api_key: SecretStatus
providers: Record<string, AIProviderConfig>
max_tokens: number
anthropic_version: string
timeout_seconds: number
retry_attempts: number
source: string
}
barentswatch: {
endpoint: string
client_id: string
client_secret: SecretStatus
source: string
}
web_search: {
enabled: boolean
default_provider: string
provider: string
base_url: string
api_key: SecretStatus
providers: Record<string, WebSearchProviderConfig>
max_results: number
timeout_seconds: number
endpoint_path: string
search_depth: string
engine: string
include_answer: boolean
include_raw_content: boolean
include_text: boolean
categories: string
engines: string[]
search_path: string
scrape_path: string
scrape_formats: string[]
source: string
}
}
interface AIProviderPreset {
provider: string
label: string
provider_api: string
base_url: string
model: string
models: string[]
api_key_env: string
source: string
refresh_error?: string
}
interface WebSearchPreset {
provider: string
label: string
api_key_env: string
base_url: string
endpoint_path?: string
search_path?: string
scrape_path?: string
max_results: number
timeout_seconds: number
search_depth?: string
engine?: string
include_answer?: boolean
include_raw_content?: boolean
include_text?: boolean
categories?: string
engines?: string[]
scrape_formats?: string[]
}
function AISettingsPanel({ loading, children }: { loading: boolean; children: ReactNode }) {
return (
<div className="settings-pane">
<Card className="settings-panel-card" loading={loading}>
<Scrollbar className="settings-panel-scroll">{children}</Scrollbar>
</Card>
</div>
)
}
export default function AISettings() {
const [searchParams, setSearchParams] = useSearchParams()
const [form] = Form.useForm()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [integrations, setIntegrations] = useState<ExternalIntegrations | null>(null)
const [aiProviderPresets, setAiProviderPresets] = useState<AIProviderPreset[]>([])
const [webSearchPresets, setWebSearchPresets] = useState<WebSearchPreset[]>([])
const [refreshingAiPreset, setRefreshingAiPreset] = useState(false)
const [testingAiProviderConnection, setTestingAiProviderConnection] = useState(false)
const [testingWebSearchConnection, setTestingWebSearchConnection] = useState(false)
const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
const [revealedAiProviderSecrets, setRevealedAiProviderSecrets] = useState<Record<string, { api_key: string; service_token: string }>>({})
const [revealedWebSearchSecrets, setRevealedWebSearchSecrets] = useState<Record<string, { api_key: string }>>({})
const [aiProviderApiKeyRevealed, setAiProviderApiKeyRevealed] = useState(false)
const [serviceTokenRevealed, setServiceTokenRevealed] = useState(false)
const [webSearchApiKeyRevealed, setWebSearchApiKeyRevealed] = useState(false)
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], form)
const selectedWebSearchProvider = Form.useWatch(['web_search', 'provider'], form)
const selectedAiProviderSecret = selectedAiProvider
? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key
: integrations?.ai_provider.api_key
const selectedWebSearchSecret = selectedWebSearchProvider
? integrations?.web_search.providers?.[selectedWebSearchProvider]?.api_key || integrations?.web_search.api_key
: integrations?.web_search.api_key
const activeTab = useMemo(() => {
const tab = searchParams.get('tab') || 'providers'
return new Set(['providers', 'tools', 'playground']).has(tab) ? tab : 'providers'
}, [searchParams])
const fetchAISettings = async () => {
try {
setLoading(true)
const [settingsResponse, aiPresetsResponse, webPresetsResponse] = await Promise.all([
axios.get('/api/v1/settings'),
axios.get('/api/v1/settings/integrations/ai-provider/presets'),
axios.get('/api/v1/settings/integrations/web-search/presets'),
])
setIntegrations(settingsResponse.data.integrations || null)
setAiProviderPresets(aiPresetsResponse.data.data || [])
setWebSearchPresets(webPresetsResponse.data.data || [])
} catch {
message.error('获取 AI 配置失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
void fetchAISettings()
}, [])
useEffect(() => {
if (loading || !integrations) return
form.setFieldsValue({
ai_provider: {
service_url: integrations.ai_provider.service_url,
service_token: integrations.ai_provider.service_token.configured
? integrations.ai_provider.service_token.preview
: '',
default_provider: integrations.ai_provider.default_provider || integrations.ai_provider.provider,
provider: integrations.ai_provider.provider,
provider_api: integrations.ai_provider.provider_api,
base_url: integrations.ai_provider.base_url,
model: integrations.ai_provider.model,
api_key: integrations.ai_provider.api_key.configured
? integrations.ai_provider.api_key.preview
: '',
max_tokens: integrations.ai_provider.max_tokens,
anthropic_version: integrations.ai_provider.anthropic_version,
timeout_seconds: integrations.ai_provider.timeout_seconds,
retry_attempts: integrations.ai_provider.retry_attempts,
},
web_search: {
enabled: integrations.web_search.enabled,
default_provider: integrations.web_search.default_provider || integrations.web_search.provider,
provider: integrations.web_search.provider,
base_url: integrations.web_search.base_url,
api_key: integrations.web_search.api_key.configured
? integrations.web_search.api_key.preview
: '',
max_results: integrations.web_search.max_results,
timeout_seconds: integrations.web_search.timeout_seconds,
endpoint_path: integrations.web_search.endpoint_path,
search_depth: integrations.web_search.search_depth,
engine: integrations.web_search.engine,
include_answer: integrations.web_search.include_answer,
include_raw_content: integrations.web_search.include_raw_content,
include_text: integrations.web_search.include_text,
categories: integrations.web_search.categories,
engines: integrations.web_search.engines,
search_path: integrations.web_search.search_path,
scrape_path: integrations.web_search.scrape_path,
scrape_formats: integrations.web_search.scrape_formats,
},
})
}, [form, integrations, loading])
const isSecretDraftUnchanged = (
nextSecret: string,
savedPreview?: string,
revealedSecret?: string,
) => (
!nextSecret ||
nextSecret === savedPreview ||
nextSecret === revealedSecret ||
nextSecret.startsWith('••••') ||
nextSecret.includes('*')
)
const buildAiProviderDraftPayload = (values: any) => {
const selectedProvider = String(values.ai_provider?.provider || integrations?.ai_provider.provider || 'minimax')
const providerSecret = integrations?.ai_provider.providers?.[selectedProvider]?.api_key
|| integrations?.ai_provider.api_key
const revealedSecrets = revealedAiProviderSecrets[selectedProvider]
const nextApiKey = String(values.ai_provider?.api_key || '').trim()
const apiKeyUnchanged = isSecretDraftUnchanged(
nextApiKey,
providerSecret?.preview,
revealedSecrets?.api_key,
)
const nextServiceToken = String(values.ai_provider?.service_token || '').trim()
const serviceTokenUnchanged = isSecretDraftUnchanged(
nextServiceToken,
integrations?.ai_provider.service_token.preview,
revealedSecrets?.service_token,
)
return {
...values.ai_provider,
default_provider: selectedProvider,
api_key: apiKeyUnchanged ? '' : nextApiKey,
service_token: serviceTokenUnchanged ? '' : nextServiceToken,
}
}
const buildWebSearchDraftPayload = (values: any) => {
const selectedProvider = String(values.web_search?.provider || integrations?.web_search.provider || 'tavily')
const providerSecret = integrations?.web_search.providers?.[selectedProvider]?.api_key
|| integrations?.web_search.api_key
const revealedSecrets = revealedWebSearchSecrets[selectedProvider]
const nextApiKey = String(values.web_search?.api_key || '').trim()
const apiKeyUnchanged = isSecretDraftUnchanged(
nextApiKey,
providerSecret?.preview,
revealedSecrets?.api_key,
)
return {
...values.web_search,
default_provider: selectedProvider,
api_key: apiKeyUnchanged ? '' : nextApiKey,
}
}
const buildIntegrationsPayload = (values: any) => ({
ai_provider: buildAiProviderDraftPayload(values),
web_search: buildWebSearchDraftPayload(values),
barentswatch: {
endpoint: integrations?.barentswatch.endpoint || '',
client_id: integrations?.barentswatch.client_id || '',
client_secret: '',
},
})
const saveAISettings = async (values: any) => {
try {
setSaving(true)
const response = await axios.put('/api/v1/settings/integrations', buildIntegrationsPayload(values))
setIntegrations(response.data.integrations)
setFeedback({ type: 'success', message: 'AI 配置已保存' })
message.success('AI 配置已保存')
await fetchAISettings()
} catch {
setFeedback({ type: 'error', message: 'AI 配置保存失败' })
message.error('AI 配置保存失败')
} finally {
setSaving(false)
}
}
const revealAiProviderSecrets = async (provider: string) => {
const cached = revealedAiProviderSecrets[provider]
if (cached) return cached
const response = await axios.get('/api/v1/settings/integrations/ai-provider/secrets', {
params: { provider },
})
const secrets = {
api_key: String(response.data.api_key || ''),
service_token: String(response.data.service_token || ''),
}
setRevealedAiProviderSecrets((prev) => ({ ...prev, [provider]: secrets }))
return secrets
}
const revealWebSearchSecrets = async (provider: string) => {
const cached = revealedWebSearchSecrets[provider]
if (cached) return cached
const response = await axios.get('/api/v1/settings/integrations/web-search/secrets', {
params: { provider },
})
const secrets = {
api_key: String(response.data.api_key || ''),
}
setRevealedWebSearchSecrets((prev) => ({ ...prev, [provider]: secrets }))
return secrets
}
const handleAiProviderApiKeyVisibleChange = async (visible: boolean) => {
const provider = String(form.getFieldValue(['ai_provider', 'provider']) || integrations?.ai_provider.provider || 'minimax')
const providerSecret = integrations?.ai_provider.providers?.[provider]?.api_key || integrations?.ai_provider.api_key
if (visible) {
try {
const secrets = await revealAiProviderSecrets(provider)
if (secrets.api_key) form.setFieldValue(['ai_provider', 'api_key'], secrets.api_key)
setAiProviderApiKeyRevealed(true)
} catch {
message.error('读取 LLM API Key 失败')
}
return
}
const currentValue = String(form.getFieldValue(['ai_provider', 'api_key']) || '')
const revealedValue = revealedAiProviderSecrets[provider]?.api_key
if (revealedValue && currentValue === revealedValue) {
form.setFieldValue(['ai_provider', 'api_key'], providerSecret?.preview || '')
}
setAiProviderApiKeyRevealed(false)
}
const handleServiceTokenVisibleChange = async (visible: boolean) => {
const provider = String(form.getFieldValue(['ai_provider', 'provider']) || integrations?.ai_provider.provider || 'minimax')
if (visible) {
try {
const secrets = await revealAiProviderSecrets(provider)
if (secrets.service_token) form.setFieldValue(['ai_provider', 'service_token'], secrets.service_token)
setServiceTokenRevealed(true)
} catch {
message.error('读取代理 Token 失败')
}
return
}
const currentValue = String(form.getFieldValue(['ai_provider', 'service_token']) || '')
const revealedValue = revealedAiProviderSecrets[provider]?.service_token
if (revealedValue && currentValue === revealedValue) {
form.setFieldValue(['ai_provider', 'service_token'], integrations?.ai_provider.service_token.preview || '')
}
setServiceTokenRevealed(false)
}
const handleWebSearchApiKeyVisibleChange = async (visible: boolean) => {
const provider = String(form.getFieldValue(['web_search', 'provider']) || integrations?.web_search.provider || 'tavily')
const providerSecret = integrations?.web_search.providers?.[provider]?.api_key || integrations?.web_search.api_key
if (visible) {
try {
const secrets = await revealWebSearchSecrets(provider)
if (secrets.api_key) form.setFieldValue(['web_search', 'api_key'], secrets.api_key)
setWebSearchApiKeyRevealed(true)
} catch {
message.error('读取 WebSearch API Key 失败')
}
return
}
const currentValue = String(form.getFieldValue(['web_search', 'api_key']) || '')
const revealedValue = revealedWebSearchSecrets[provider]?.api_key
if (revealedValue && currentValue === revealedValue) {
form.setFieldValue(['web_search', 'api_key'], providerSecret?.preview || '')
}
setWebSearchApiKeyRevealed(false)
}
const applyAiProviderSelection = (provider: string, presetOverride?: AIProviderPreset) => {
const preset = presetOverride || aiProviderPresets.find((item) => item.provider === provider)
const savedProvider = integrations?.ai_provider.providers?.[provider]
const providerApi = savedProvider?.provider_api || preset?.provider_api || 'openai-completions'
const apiKey = savedProvider?.api_key.configured ? savedProvider.api_key.preview : ''
form.setFieldsValue({
ai_provider: {
default_provider: provider,
provider,
provider_api: providerApi,
base_url: savedProvider?.base_url || preset?.base_url || '',
model: savedProvider?.model || preset?.model || '',
api_key: apiKey,
max_tokens: savedProvider?.max_tokens || (providerApi === 'anthropic-messages'
? ANTHROPIC_MESSAGES_MAX_TOKENS
: DEFAULT_PROVIDER_MAX_TOKENS),
anthropic_version: savedProvider?.anthropic_version || '2023-06-01',
},
})
setAiProviderApiKeyRevealed(false)
}
const applyWebSearchProviderSelection = (provider: string, presetOverride?: WebSearchPreset) => {
const preset = presetOverride || webSearchPresets.find((item) => item.provider === provider)
const savedProvider = integrations?.web_search.providers?.[provider]
const apiKey = savedProvider?.api_key.configured ? savedProvider.api_key.preview : ''
form.setFieldsValue({
web_search: {
default_provider: provider,
provider,
enabled: integrations?.web_search.enabled ?? false,
base_url: savedProvider?.base_url || preset?.base_url || '',
api_key: apiKey,
max_results: savedProvider?.max_results || preset?.max_results || 5,
timeout_seconds: savedProvider?.timeout_seconds || preset?.timeout_seconds || 20,
endpoint_path: savedProvider?.endpoint_path || preset?.endpoint_path || '',
search_depth: savedProvider?.search_depth || preset?.search_depth || 'basic',
engine: savedProvider?.engine || preset?.engine || 'google',
include_answer: savedProvider?.include_answer ?? preset?.include_answer ?? false,
include_raw_content: savedProvider?.include_raw_content ?? preset?.include_raw_content ?? false,
include_text: savedProvider?.include_text ?? preset?.include_text ?? false,
categories: savedProvider?.categories || preset?.categories || 'general',
engines: savedProvider?.engines || preset?.engines || [],
search_path: savedProvider?.search_path || preset?.search_path || '',
scrape_path: savedProvider?.scrape_path || preset?.scrape_path || '',
scrape_formats: savedProvider?.scrape_formats || preset?.scrape_formats || ['markdown'],
},
})
setWebSearchApiKeyRevealed(false)
}
const refreshSelectedAiProviderPreset = async () => {
const provider = form.getFieldValue(['ai_provider', 'provider'])
if (!provider) return
try {
setRefreshingAiPreset(true)
const response = await axios.post(`/api/v1/settings/integrations/ai-provider/presets/${provider}/refresh`)
const preset = response.data.data as AIProviderPreset
setAiProviderPresets((prev) => {
const next = prev.filter((item) => item.provider !== preset.provider)
return [...next, preset].sort((a, b) => a.label.localeCompare(b.label))
})
applyAiProviderSelection(preset.provider, preset)
if (preset.refresh_error) {
message.warning('刷新失败,已使用本地 fallback 配置')
} else {
message.success('已刷新选中 Provider 的最新模型配置')
}
} catch {
message.error('刷新 Provider 配置失败')
} finally {
setRefreshingAiPreset(false)
}
}
const testAiProviderConnection = async () => {
try {
setTestingAiProviderConnection(true)
const values = form.getFieldsValue(true)
const response = await axios.post(
'/api/v1/settings/integrations/ai-provider/connect',
buildAiProviderDraftPayload(values),
)
if (response.data.success || response.data.connected) {
if (response.data.integrations) {
setIntegrations(response.data.integrations)
await fetchAISettings()
}
setFeedback({ type: 'success', message: response.data.message || 'AI Provider 连接成功' })
message.success(response.data.message || 'AI Provider 连接成功')
} else {
setFeedback({ type: 'error', message: response.data.message || 'AI Provider 连接失败' })
message.error(response.data.message || 'AI Provider 连接失败')
}
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string; message?: string } } }
const errorMessage = err.response?.data?.message || err.response?.data?.detail || 'AI Provider 连接失败'
setFeedback({ type: 'error', message: errorMessage })
message.error(errorMessage)
} finally {
setTestingAiProviderConnection(false)
}
}
const testWebSearchConnection = async () => {
try {
setTestingWebSearchConnection(true)
const values = form.getFieldsValue(true)
const response = await axios.post(
'/api/v1/settings/integrations/web-search/connect',
buildWebSearchDraftPayload(values),
)
if (response.data.success || response.data.connected) {
setFeedback({ type: 'success', message: response.data.message || 'WebSearch 连接成功' })
message.success(response.data.message || 'WebSearch 连接成功')
} else {
setFeedback({ type: 'error', message: response.data.message || 'WebSearch 连接失败' })
message.error(response.data.message || 'WebSearch 连接失败')
}
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string; message?: string } } }
const errorMessage = err.response?.data?.message || err.response?.data?.detail || 'WebSearch 连接失败'
setFeedback({ type: 'error', message: errorMessage })
message.error(errorMessage)
} finally {
setTestingWebSearchConnection(false)
}
}
const tabItems = [
{
key: 'providers',
label: '模型供应商',
forceRender: true,
children: (
<AISettingsPanel loading={loading}>
<Form form={form} layout="vertical" onFinish={saveAISettings} onValuesChange={() => setFeedback(null)}>
<Card size="small" title={<Space><ApiOutlined />LLM Provider</Space>}>
<Form.Item name={['ai_provider', 'provider']} label="Provider">
<Select
showSearch
optionFilterProp="label"
options={aiProviderPresets.map((preset) => ({
value: preset.provider,
label: `${preset.label} · ${preset.provider_api}`,
}))}
onChange={(value) => applyAiProviderSelection(value)}
/>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
<Form.Item name={['ai_provider', 'base_url']} label="LLM Base URL">
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
<Form.Item label=" ">
<Space>
<Button icon={<SyncOutlined />} loading={refreshingAiPreset} onClick={refreshSelectedAiProviderPreset}>
</Button>
<Button icon={<PlayCircleOutlined />} loading={testingAiProviderConnection} onClick={() => { void testAiProviderConnection() }}>
</Button>
</Space>
</Form.Item>
</div>
<Form.Item name={['ai_provider', 'provider_api']} label="协议适配">
<Select>
<Select.Option value="openai-completions">OpenAI Chat Completions</Select.Option>
<Select.Option value="anthropic-messages">Anthropic Messages</Select.Option>
<Select.Option value="ollama-generate">Ollama Generate</Select.Option>
</Select>
</Form.Item>
<Form.Item name={['ai_provider', 'model']} label="默认模型">
<Select
showSearch
optionFilterProp="label"
options={(
aiProviderPresets.find((preset) => preset.provider === selectedAiProvider)?.models || []
).map((model) => ({ value: model, label: model }))}
/>
</Form.Item>
<Form.Item label="LLM API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={selectedAiProviderSecret?.configured ? 'green' : 'default'}>
{selectedAiProviderSecret?.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> Provider key</Text>
</Space>
<Form.Item name={['ai_provider', 'api_key']} noStyle>
<Input
autoComplete="new-password"
placeholder="输入新的 LLM API key"
suffix={(
<Button
type="text"
size="small"
icon={aiProviderApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleAiProviderApiKeyVisibleChange(!aiProviderApiKeyRevealed) }}
aria-label={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}
/>
)}
/>
</Form.Item>
</Space>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['ai_provider', 'max_tokens']} label="最大输出 Tokens">
<InputNumber min={1} max={200000} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['ai_provider', 'anthropic_version']} label="Anthropic Version">
<Input />
</Form.Item>
<Form.Item name={['ai_provider', 'timeout_seconds']} label="超时(秒)">
<InputNumber min={5} max={600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['ai_provider', 'retry_attempts']} label="重试次数">
<InputNumber min={1} max={10} style={{ width: '100%' }} />
</Form.Item>
</div>
<Card size="small" type="inner" title="本地 aiprovider 代理" style={{ marginTop: 8 }}>
<Form.Item name={['ai_provider', 'service_url']} label="代理地址">
<Input placeholder="http://localhost:8010" />
</Form.Item>
<Form.Item label="代理 Token">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={integrations?.ai_provider.service_token.configured ? 'green' : 'default'}>
{integrations?.ai_provider.service_token.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> backend aiprovider</Text>
</Space>
<Form.Item name={['ai_provider', 'service_token']} noStyle>
<Input
autoComplete="new-password"
placeholder="输入新的代理 token"
suffix={(
<Button
type="text"
size="small"
icon={serviceTokenRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleServiceTokenVisibleChange(!serviceTokenRevealed) }}
aria-label={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}
/>
)}
/>
</Form.Item>
</Space>
</Form.Item>
</Card>
</Card>
<Button type="primary" htmlType="submit" loading={saving} style={{ marginTop: 16 }}>
AI
</Button>
{feedback ? <Alert showIcon type={feedback.type} message={feedback.message} style={{ marginTop: 12 }} /> : null}
</Form>
</AISettingsPanel>
),
},
{
key: 'tools',
label: '工具',
forceRender: true,
children: (
<AISettingsPanel loading={loading}>
<Form form={form} layout="vertical" onFinish={saveAISettings} onValuesChange={() => setFeedback(null)}>
<Card size="small" title={<Space><ToolOutlined />WebSearch </Space>}>
<Form.Item name={['web_search', 'enabled']} label="启用 WebSearch" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={['web_search', 'provider']} label="WebSearch Provider">
<Select
showSearch
optionFilterProp="label"
options={webSearchPresets.map((preset) => ({
value: preset.provider,
label: preset.label,
}))}
onChange={(value) => applyWebSearchProviderSelection(value)}
/>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
<Form.Item name={['web_search', 'base_url']} label="API Base URL">
<Input placeholder="https://api.tavily.com" />
</Form.Item>
<Form.Item label=" ">
<Button icon={<PlayCircleOutlined />} loading={testingWebSearchConnection} onClick={() => { void testWebSearchConnection() }}>
</Button>
</Form.Item>
</div>
<Form.Item label="WebSearch API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={selectedWebSearchSecret?.configured ? 'green' : 'default'}>
{selectedWebSearchSecret?.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> WebSearch Provider key</Text>
</Space>
<Form.Item name={['web_search', 'api_key']} noStyle>
<Input
autoComplete="new-password"
placeholder="输入新的 WebSearch API keySearXNG 可留空"
suffix={(
<Button
type="text"
size="small"
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
/>
)}
/>
</Form.Item>
</Space>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'max_results']} label="最大结果数">
<InputNumber min={1} max={20} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['web_search', 'timeout_seconds']} label="超时(秒)">
<InputNumber min={3} max={120} style={{ width: '100%' }} />
</Form.Item>
</div>
<Card size="small" type="inner" title="高级选项" style={{ marginTop: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'endpoint_path']} label="Endpoint Path">
<Input placeholder="/search" />
</Form.Item>
<Form.Item name={['web_search', 'search_depth']} label="Search Depth">
<Input placeholder="basic" />
</Form.Item>
<Form.Item name={['web_search', 'engine']} label="SerpAPI Engine">
<Input placeholder="google" />
</Form.Item>
<Form.Item name={['web_search', 'categories']} label="SearXNG Categories">
<Input placeholder="general" />
</Form.Item>
<Form.Item name={['web_search', 'search_path']} label="Firecrawl Search Path">
<Input placeholder="/v2/search" />
</Form.Item>
<Form.Item name={['web_search', 'scrape_path']} label="Firecrawl Scrape Path">
<Input placeholder="/v2/scrape" />
</Form.Item>
</div>
<Space wrap>
<Form.Item name={['web_search', 'include_answer']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Answer</Checkbox>
</Form.Item>
<Form.Item name={['web_search', 'include_raw_content']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Raw Content</Checkbox>
</Form.Item>
<Form.Item name={['web_search', 'include_text']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Result Text</Checkbox>
</Form.Item>
</Space>
</Card>
</Card>
<Button type="primary" htmlType="submit" loading={saving} style={{ marginTop: 16 }}>
Tool
</Button>
{feedback ? <Alert showIcon type={feedback.type} message={feedback.message} style={{ marginTop: 12 }} /> : null}
</Form>
</AISettingsPanel>
),
},
{
key: 'playground',
label: '测试台',
forceRender: true,
children: <PlaygroundWorkspace embedded />,
},
]
return (
<AppLayout>
<div className="page-shell settings-shell">
<div className="page-shell__header">
<div>
<Title level={3} style={{ marginBottom: 4 }}>AI</Title>
<Text type="secondary"> LLM Provider</Text>
</div>
</div>
<div className="page-shell__body settings-tabs-shell">
<Tabs
className="settings-tabs"
activeKey={activeTab}
onChange={(tabKey) => setSearchParams(tabKey === 'providers' ? {} : { tab: tabKey }, { replace: true })}
items={tabItems}
/>
</div>
</div>
</AppLayout>
)
}