release: bump version to 0.51.0
This commit is contained in:
824
frontend/src/pages/AISettings/AISettings.tsx
Normal file
824
frontend/src/pages/AISettings/AISettings.tsx
Normal 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 key;SearXNG 可留空"
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user