Release 0.58.0 includes the Earth high-precision boundary PMTiles/MVT pipeline, standardized Earth boundary source collectors, China POV boundary configuration templates, and removal of the legacy low-precision GeoJSON fallback. It also adds Earth news target-location queueing/archive support, fixes datasource task status visibility, documents the Earth surface depth-spacing rules that prevent far-zoom z-fighting snow/black blocks, and updates bilingual operations/developer docs.
1020 lines
44 KiB
TypeScript
1020 lines
44 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||
import {
|
||
ApiOutlined,
|
||
EyeInvisibleOutlined,
|
||
EyeOutlined,
|
||
SyncOutlined,
|
||
ToolOutlined,
|
||
} from '@ant-design/icons'
|
||
import {
|
||
Alert,
|
||
AutoComplete,
|
||
Button,
|
||
Card,
|
||
Checkbox,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
message,
|
||
Select,
|
||
Space,
|
||
Switch,
|
||
Tabs,
|
||
Tag,
|
||
Tooltip,
|
||
Typography,
|
||
} from 'antd'
|
||
import axios from 'axios'
|
||
import { useSearchParams } from 'react-router-dom'
|
||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||
import ConnectionTestInput from '../../components/ConnectionTestInput/ConnectionTestInput'
|
||
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
|
||
const TOOL_OPTIONS = [
|
||
{ value: 'web_search', label: 'WebSearch 证据层' },
|
||
{ value: 'ocr', label: 'OCR 识别' },
|
||
]
|
||
|
||
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
|
||
}
|
||
ocr: {
|
||
enabled: boolean
|
||
provider: string
|
||
base_url: string
|
||
api_key: SecretStatus
|
||
model: string
|
||
languages: string[]
|
||
timeout_seconds: number
|
||
max_file_size_mb: number
|
||
output_format: 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 [selectedToolKey, setSelectedToolKey] = useState('web_search')
|
||
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 [revealedOcrSecrets, setRevealedOcrSecrets] = useState<{ api_key: string } | null>(null)
|
||
const [aiProviderApiKeyRevealed, setAiProviderApiKeyRevealed] = useState(false)
|
||
const [serviceTokenRevealed, setServiceTokenRevealed] = useState(false)
|
||
const [webSearchApiKeyRevealed, setWebSearchApiKeyRevealed] = useState(false)
|
||
const [ocrApiKeyRevealed, setOcrApiKeyRevealed] = useState(false)
|
||
|
||
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], form)
|
||
const selectedWebSearchProvider = Form.useWatch(['web_search', 'provider'], form)
|
||
const webSearchEnabled = Form.useWatch(['web_search', 'enabled'], form)
|
||
const ocrEnabled = Form.useWatch(['ocr', 'enabled'], 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,
|
||
},
|
||
ocr: {
|
||
enabled: integrations.ocr.enabled,
|
||
provider: integrations.ocr.provider,
|
||
base_url: integrations.ocr.base_url,
|
||
api_key: integrations.ocr.api_key.configured
|
||
? integrations.ocr.api_key.preview
|
||
: '',
|
||
model: integrations.ocr.model,
|
||
languages: integrations.ocr.languages,
|
||
timeout_seconds: integrations.ocr.timeout_seconds,
|
||
max_file_size_mb: integrations.ocr.max_file_size_mb,
|
||
output_format: integrations.ocr.output_format,
|
||
},
|
||
})
|
||
}, [form, integrations, loading])
|
||
|
||
const isSecretDraftUnchanged = (
|
||
nextSecret: string,
|
||
savedPreview?: string,
|
||
revealedSecret?: string,
|
||
) => (
|
||
!nextSecret ||
|
||
nextSecret === savedPreview ||
|
||
nextSecret === revealedSecret ||
|
||
nextSecret.startsWith('••••') ||
|
||
Array.from(nextSecret).every((char) => char === '*' || char === '•' || /\s/.test(char))
|
||
)
|
||
|
||
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 buildOcrDraftPayload = (values: any) => {
|
||
const nextApiKey = String(values.ocr?.api_key || '').trim()
|
||
const apiKeyUnchanged = isSecretDraftUnchanged(
|
||
nextApiKey,
|
||
integrations?.ocr.api_key.preview,
|
||
revealedOcrSecrets?.api_key,
|
||
)
|
||
return {
|
||
...values.ocr,
|
||
api_key: apiKeyUnchanged ? '' : nextApiKey,
|
||
}
|
||
}
|
||
|
||
const buildIntegrationsPayload = (values: any) => ({
|
||
ai_provider: buildAiProviderDraftPayload(values),
|
||
web_search: buildWebSearchDraftPayload(values),
|
||
ocr: buildOcrDraftPayload(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 (error: unknown) {
|
||
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||
const errorMessage = err.response?.data?.message || err.response?.data?.detail || 'AI 配置保存失败'
|
||
setFeedback({ type: 'error', message: errorMessage })
|
||
message.error(errorMessage)
|
||
} 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 revealOcrSecrets = async () => {
|
||
if (revealedOcrSecrets) return revealedOcrSecrets
|
||
const response = await axios.get('/api/v1/settings/integrations/ocr/secrets')
|
||
const secrets = {
|
||
api_key: String(response.data.api_key || ''),
|
||
}
|
||
setRevealedOcrSecrets(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 handleOcrApiKeyVisibleChange = async (visible: boolean) => {
|
||
if (visible) {
|
||
try {
|
||
const secrets = await revealOcrSecrets()
|
||
if (secrets.api_key) form.setFieldValue(['ocr', 'api_key'], secrets.api_key)
|
||
setOcrApiKeyRevealed(true)
|
||
} catch {
|
||
message.error('读取 OCR API Key 失败')
|
||
}
|
||
return
|
||
}
|
||
const currentValue = String(form.getFieldValue(['ocr', 'api_key']) || '')
|
||
const revealedValue = revealedOcrSecrets?.api_key
|
||
if (revealedValue && currentValue === revealedValue) {
|
||
form.setFieldValue(['ocr', 'api_key'], integrations?.ocr.api_key.preview || '')
|
||
}
|
||
setOcrApiKeyRevealed(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) {
|
||
setFeedback({ type: 'success', message: response.data.message || '连接测试通过' })
|
||
message.success(response.data.message || '连接测试通过')
|
||
} 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">
|
||
<AutoComplete
|
||
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
|
||
options={aiProviderPresets.map((preset) => ({
|
||
value: preset.provider,
|
||
label: `${preset.label} · ${preset.provider_api}`,
|
||
}))}
|
||
onSelect={(value) => applyAiProviderSelection(value)}
|
||
placeholder="选择或输入 provider id,例如 openai"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name={['ai_provider', 'base_url']} label="LLM Base URL">
|
||
<ConnectionTestInput
|
||
placeholder="https://api.example.com/v1"
|
||
testing={testingAiProviderConnection}
|
||
testDisabled={!selectedAiProvider}
|
||
onTest={() => { void testAiProviderConnection() }}
|
||
extraSuffix={(
|
||
<Tooltip title="刷新当前 Provider 的模型配置">
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<SyncOutlined spin={refreshingAiPreset} />}
|
||
disabled={!selectedAiProvider}
|
||
onClick={(event) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
void refreshSelectedAiProviderPreset()
|
||
}}
|
||
aria-label="刷新当前 Provider 的模型配置"
|
||
/>
|
||
</Tooltip>
|
||
)}
|
||
/>
|
||
</Form.Item>
|
||
<div style={{ marginTop: -16, marginBottom: 12 }}>
|
||
<Text type="secondary">测试不保存,保存才生效。</Text>
|
||
</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="默认模型">
|
||
<AutoComplete
|
||
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
|
||
options={(
|
||
aiProviderPresets.find((preset) => preset.provider === selectedAiProvider)?.models || []
|
||
).map((model) => ({ value: model, label: model }))}
|
||
placeholder="选择或输入模型名,例如 gpt-5.1"
|
||
/>
|
||
</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={(
|
||
<Tooltip title={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={aiProviderApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||
onClick={() => { void handleAiProviderApiKeyVisibleChange(!aiProviderApiKeyRevealed) }}
|
||
aria-label={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}
|
||
/>
|
||
</Tooltip>
|
||
)}
|
||
/>
|
||
</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={(
|
||
<Tooltip title={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={serviceTokenRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||
onClick={() => { void handleServiceTokenVisibleChange(!serviceTokenRevealed) }}
|
||
aria-label={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}
|
||
/>
|
||
</Tooltip>
|
||
)}
|
||
/>
|
||
</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 />AI Tools</Space>}>
|
||
<Form.Item label="工具">
|
||
<Select
|
||
value={selectedToolKey}
|
||
options={TOOL_OPTIONS}
|
||
onChange={setSelectedToolKey}
|
||
/>
|
||
</Form.Item>
|
||
{selectedToolKey === 'web_search' ? (
|
||
<>
|
||
<Form.Item name={['web_search', 'enabled']} label="启用 WebSearch" valuePropName="checked">
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item name={['web_search', 'provider']} label="WebSearch Provider">
|
||
<Select
|
||
showSearch
|
||
disabled={!webSearchEnabled}
|
||
optionFilterProp="label"
|
||
options={webSearchPresets.map((preset) => ({
|
||
value: preset.provider,
|
||
label: preset.label,
|
||
}))}
|
||
onChange={(value) => applyWebSearchProviderSelection(value)}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name={['web_search', 'base_url']} label="API Base URL">
|
||
<ConnectionTestInput
|
||
placeholder="https://api.tavily.com"
|
||
disabled={!webSearchEnabled}
|
||
testing={testingWebSearchConnection}
|
||
testDisabled={!webSearchEnabled || !selectedWebSearchProvider}
|
||
onTest={() => { void testWebSearchConnection() }}
|
||
/>
|
||
</Form.Item>
|
||
<div style={{ marginTop: -16, marginBottom: 12 }}>
|
||
<Text type="secondary">测试不保存,保存才生效。</Text>
|
||
</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"
|
||
disabled={!webSearchEnabled}
|
||
placeholder="输入新的 WebSearch API key;SearXNG 可留空"
|
||
suffix={(
|
||
<Tooltip title={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
disabled={!webSearchEnabled}
|
||
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
|
||
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
|
||
/>
|
||
</Tooltip>
|
||
)}
|
||
/>
|
||
</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} disabled={!webSearchEnabled} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name={['web_search', 'timeout_seconds']} label="超时(秒)">
|
||
<InputNumber min={3} max={120} disabled={!webSearchEnabled} 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 disabled={!webSearchEnabled} placeholder="/search" />
|
||
</Form.Item>
|
||
<Form.Item name={['web_search', 'search_depth']} label="Search Depth">
|
||
<Input disabled={!webSearchEnabled} placeholder="basic" />
|
||
</Form.Item>
|
||
<Form.Item name={['web_search', 'engine']} label="SerpAPI Engine">
|
||
<Input disabled={!webSearchEnabled} placeholder="google" />
|
||
</Form.Item>
|
||
<Form.Item name={['web_search', 'categories']} label="SearXNG Categories">
|
||
<Input disabled={!webSearchEnabled} placeholder="general" />
|
||
</Form.Item>
|
||
<Form.Item name={['web_search', 'search_path']} label="Firecrawl Search Path">
|
||
<Input disabled={!webSearchEnabled} placeholder="/v2/search" />
|
||
</Form.Item>
|
||
<Form.Item name={['web_search', 'scrape_path']} label="Firecrawl Scrape Path">
|
||
<Input disabled={!webSearchEnabled} placeholder="/v2/scrape" />
|
||
</Form.Item>
|
||
</div>
|
||
<Space wrap>
|
||
<Form.Item name={['web_search', 'include_answer']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||
<Checkbox disabled={!webSearchEnabled}>包含 Answer</Checkbox>
|
||
</Form.Item>
|
||
<Form.Item name={['web_search', 'include_raw_content']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||
<Checkbox disabled={!webSearchEnabled}>包含 Raw Content</Checkbox>
|
||
</Form.Item>
|
||
<Form.Item name={['web_search', 'include_text']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||
<Checkbox disabled={!webSearchEnabled}>包含 Result Text</Checkbox>
|
||
</Form.Item>
|
||
</Space>
|
||
</Card>
|
||
</>
|
||
) : null}
|
||
{selectedToolKey === 'ocr' ? (
|
||
<>
|
||
<Form.Item name={['ocr', 'enabled']} label="启用 OCR" valuePropName="checked">
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item name={['ocr', 'provider']} label="OCR Provider">
|
||
<Select
|
||
disabled={!ocrEnabled}
|
||
options={[
|
||
{ value: 'paddleocr', label: 'PaddleOCR / Local' },
|
||
{ value: 'tesseract', label: 'Tesseract / Local' },
|
||
{ value: 'azure', label: 'Azure AI Vision' },
|
||
{ value: 'google_vision', label: 'Google Cloud Vision' },
|
||
{ value: 'custom', label: 'Custom HTTP OCR' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name={['ocr', 'base_url']} label="OCR Base URL">
|
||
<Input disabled={!ocrEnabled} placeholder="http://localhost:8020 或云 OCR endpoint" />
|
||
</Form.Item>
|
||
<Form.Item label="OCR API Key">
|
||
<Space direction="vertical" style={{ width: '100%' }}>
|
||
<Space>
|
||
<Tag color={integrations?.ocr.api_key.configured ? 'green' : 'default'}>
|
||
{integrations?.ocr.api_key.configured ? '已配置' : '未配置'}
|
||
</Tag>
|
||
<Text type="secondary">本地 OCR 可留空;云 OCR 或自定义 HTTP 服务按需填写。</Text>
|
||
</Space>
|
||
<Form.Item name={['ocr', 'api_key']} noStyle>
|
||
<Input
|
||
autoComplete="new-password"
|
||
disabled={!ocrEnabled}
|
||
placeholder="输入新的 OCR API key"
|
||
suffix={(
|
||
<Tooltip title={ocrApiKeyRevealed ? '隐藏 OCR API key' : '显示 OCR API key'}>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
disabled={!ocrEnabled}
|
||
icon={ocrApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||
onClick={() => { void handleOcrApiKeyVisibleChange(!ocrApiKeyRevealed) }}
|
||
aria-label={ocrApiKeyRevealed ? '隐藏 OCR API key' : '显示 OCR API key'}
|
||
/>
|
||
</Tooltip>
|
||
)}
|
||
/>
|
||
</Form.Item>
|
||
</Space>
|
||
</Form.Item>
|
||
<Form.Item name={['ocr', 'model']} label="模型 / Engine">
|
||
<Input disabled={!ocrEnabled} placeholder="例如:PP-OCRv5、tesseract-default、prebuilt-read" />
|
||
</Form.Item>
|
||
<Form.Item name={['ocr', 'languages']} label="识别语言">
|
||
<Select
|
||
mode="tags"
|
||
disabled={!ocrEnabled}
|
||
options={[
|
||
{ value: 'zh', label: '中文' },
|
||
{ value: 'en', label: 'English' },
|
||
{ value: 'ja', label: '日本語' },
|
||
{ value: 'ko', label: '한국어' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||
<Form.Item name={['ocr', 'timeout_seconds']} label="超时(秒)">
|
||
<InputNumber min={3} max={300} disabled={!ocrEnabled} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name={['ocr', 'max_file_size_mb']} label="最大文件(MB)">
|
||
<InputNumber min={1} max={200} disabled={!ocrEnabled} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</div>
|
||
<Form.Item name={['ocr', 'output_format']} label="输出格式">
|
||
<Select
|
||
disabled={!ocrEnabled}
|
||
options={[
|
||
{ value: 'markdown', label: 'Markdown' },
|
||
{ value: 'text', label: 'Plain Text' },
|
||
{ value: 'json', label: 'JSON Blocks' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
</>
|
||
) : null}
|
||
</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>
|
||
)
|
||
}
|