release: bump version to 0.52.0

This commit is contained in:
linkong
2026-05-12 17:15:02 +08:00
parent b15d097b9c
commit b87cb310fd
70 changed files with 5589 additions and 2187 deletions

View File

@@ -3,12 +3,12 @@ import {
ApiOutlined,
EyeInvisibleOutlined,
EyeOutlined,
PlayCircleOutlined,
SyncOutlined,
ToolOutlined,
} from '@ant-design/icons'
import {
Alert,
AutoComplete,
Button,
Card,
Checkbox,
@@ -21,17 +21,23 @@ import {
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
@@ -115,6 +121,18 @@ interface ExternalIntegrations {
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 {
@@ -170,15 +188,20 @@ export default function AISettings() {
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
@@ -255,6 +278,19 @@ export default function AISettings() {
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])
@@ -313,9 +349,23 @@ export default function AISettings() {
}
}
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 || '',
@@ -366,6 +416,16 @@ export default function AISettings() {
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
@@ -428,6 +488,25 @@ export default function AISettings() {
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]
@@ -567,31 +646,40 @@ export default function AISettings() {
<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"
<AutoComplete
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
options={aiProviderPresets.map((preset) => ({
value: preset.provider,
label: `${preset.label} · ${preset.provider_api}`,
}))}
onChange={(value) => applyAiProviderSelection(value)}
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={{ 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>
@@ -600,12 +688,12 @@ export default function AISettings() {
</Select>
</Form.Item>
<Form.Item name={['ai_provider', 'model']} label="默认模型">
<Select
showSearch
optionFilterProp="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">
@@ -621,13 +709,15 @@ export default function AISettings() {
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'}
/>
<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>
@@ -664,13 +754,15 @@ export default function AISettings() {
autoComplete="new-password"
placeholder="输入新的代理 token"
suffix={(
<Button
type="text"
size="small"
icon={serviceTokenRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleServiceTokenVisibleChange(!serviceTokenRevealed) }}
aria-label={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}
/>
<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>
@@ -693,97 +785,196 @@ export default function AISettings() {
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">
<Card size="small" title={<Space><ToolOutlined />AI Tools</Space>}>
<Form.Item label="工具">
<Select
showSearch
optionFilterProp="label"
options={webSearchPresets.map((preset) => ({
value: preset.provider,
label: preset.label,
}))}
onChange={(value) => applyWebSearchProviderSelection(value)}
value={selectedToolKey}
options={TOOL_OPTIONS}
onChange={setSelectedToolKey}
/>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
<Form.Item name={['web_search', 'base_url']} label="API Base URL">
<Input placeholder="https://api.tavily.com" />
</Form.Item>
<Form.Item label=" ">
<Button icon={<PlayCircleOutlined />} loading={testingWebSearchConnection} onClick={() => { void testWebSearchConnection() }}>
</Button>
</Form.Item>
</div>
<Form.Item label="WebSearch API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={selectedWebSearchSecret?.configured ? 'green' : 'default'}>
{selectedWebSearchSecret?.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> WebSearch Provider key</Text>
</Space>
<Form.Item name={['web_search', 'api_key']} noStyle>
<Input
autoComplete="new-password"
placeholder="输入新的 WebSearch API keySearXNG 可留空"
suffix={(
<Button
type="text"
size="small"
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
/>
)}
{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>
</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 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>
<Form.Item name={['web_search', 'search_depth']} label="Search Depth">
<Input placeholder="basic" />
<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>
<Form.Item name={['web_search', 'engine']} label="SerpAPI Engine">
<Input placeholder="google" />
<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={['web_search', 'categories']} label="SearXNG Categories">
<Input placeholder="general" />
<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={['web_search', 'search_path']} label="Firecrawl Search Path">
<Input placeholder="/v2/search" />
<Form.Item name={['ocr', 'base_url']} label="OCR Base URL">
<Input disabled={!ocrEnabled} placeholder="http://localhost:8020 或云 OCR endpoint" />
</Form.Item>
<Form.Item name={['web_search', 'scrape_path']} label="Firecrawl Scrape Path">
<Input placeholder="/v2/scrape" />
<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>
</div>
<Space wrap>
<Form.Item name={['web_search', 'include_answer']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Answer</Checkbox>
<Form.Item name={['ocr', 'model']} label="模型 / Engine">
<Input disabled={!ocrEnabled} placeholder="例如PP-OCRv5、tesseract-default、prebuilt-read" />
</Form.Item>
<Form.Item name={['web_search', 'include_raw_content']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Raw Content</Checkbox>
<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>
<Form.Item name={['web_search', 'include_text']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Result Text</Checkbox>
<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>
</Space>
</Card>
</>
) : null}
</Card>
<Button type="primary" htmlType="submit" loading={saving} style={{ marginTop: 16 }}>
Tool