Files
planet/frontend/src/pages/AISettings/AISettings.tsx
linkong 93eb41a9f7
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.58.0
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.
2026-05-15 17:40:07 +08:00

1020 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 keySearXNG 可留空"
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>
)
}