import { Alert, Button, Card, Col, Checkbox, Drawer, Empty, Form, Input, InputNumber, List, Modal, Row, Select, Space, Tag, Tooltip, Typography, Steps, Tabs, message, } from 'antd'; import { ArrowLeftOutlined, ArrowRightOutlined, AppstoreOutlined, BuildOutlined, CopyOutlined, DeleteOutlined, EditOutlined, EyeOutlined, MessageOutlined, PlusOutlined, ReloadOutlined, RobotOutlined, SoundOutlined, SortAscendingOutlined, ToolOutlined, } from '@ant-design/icons'; import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import type { AIDefaultAssignments, AIDefaultModels, AIAbility, AIModel, AIModelPayload, AIProvider, AIProviderPayload, } from '../../../api/aiProviders'; import { createModel, createProvider, deleteModel, deleteProvider, fetchDefaults, fetchProviders, fetchRemoteModels, updateDefaults, updateModel, updateProvider, } from '../../../api/aiProviders'; import { useI18n } from '../../../i18n'; import '../../../styles/ai-settings.css'; type ProviderModalState = { open: boolean; step: 1 | 2; editing?: AIProvider | null; }; type ModelModalState = { open: boolean; provider?: AIProvider | null; editing?: AIModel | null; }; interface ProviderTemplate { key: string; nameKey: string; descriptionKey: string; api_format: AIProviderPayload['api_format']; identifier: string; base_url?: string; logo_url?: string; provider_type?: string; doc_url?: string; allow_format_switch?: boolean; } const abilityOrder: AIAbility[] = ['chat', 'vision', 'embedding', 'rerank', 'voice', 'tools']; const abilityInfo: Record = { chat: { icon: , label: 'Main Chat Model', color: 'purple', description: 'Primary assistant for conversations, reasoning, and tool calls.', }, vision: { icon: , label: 'Vision Model', color: 'geekblue', description: 'Handles multimodal perception such as image understanding.', }, embedding: { icon: , label: 'Embedding Model', color: 'gold', description: 'Transforms content into dense vectors for search and retrieval.', }, rerank: { icon: , label: 'Rerank Model', color: 'cyan', description: 'Optimises ranking quality for search candidates.', }, voice: { icon: , label: 'Voice Model', color: 'orange', description: 'Covers text-to-speech and speech understanding scenarios.', }, tools: { icon: , label: 'Tools Model', color: 'magenta', description: 'Supports function calling, orchestration, and automation.', }, }; const providerTemplates: ProviderTemplate[] = [ { key: 'custom-provider', nameKey: 'Custom Provider', descriptionKey: 'Custom Provider Description', api_format: 'openai', identifier: 'custom-provider', allow_format_switch: true, }, { key: 'openai', nameKey: 'OpenAI Provider', descriptionKey: 'OpenAI Provider Description', api_format: 'openai', identifier: 'openai', base_url: 'https://api.openai.com/v1', logo_url: '/icon/openai.svg', provider_type: 'builtin', doc_url: 'https://platform.openai.com/docs/api-reference', }, { key: 'azure-openai', nameKey: 'Azure OpenAI Provider', descriptionKey: 'Azure OpenAI Provider Description', api_format: 'openai', identifier: 'azure-openai', base_url: 'https://{resource-name}.openai.azure.com/openai/deployments/{deployment-name}', logo_url: '/icon/azure-color.svg', provider_type: 'builtin', doc_url: 'https://learn.microsoft.com/en-us/azure/ai-services/openai/reference', }, { key: 'anthropic', nameKey: 'Anthropic Provider', descriptionKey: 'Anthropic Provider Description', api_format: 'anthropic', identifier: 'anthropic', base_url: 'https://api.anthropic.com/v1', logo_url: '/icon/anthropic.svg', provider_type: 'builtin', doc_url: 'https://docs.anthropic.com/claude/reference/messages_post', }, { key: 'google-ai', nameKey: 'Google AI Provider', descriptionKey: 'Google AI Provider Description', api_format: 'gemini', identifier: 'google-ai', base_url: 'https://generativelanguage.googleapis.com/v1beta', logo_url: '/icon/gemini-color.svg', provider_type: 'builtin', doc_url: 'https://ai.google.dev/api/rest', }, { key: 'zai', nameKey: 'Z.ai Provider', descriptionKey: 'Z.ai Provider Description', api_format: 'openai', identifier: 'zai', base_url: 'https://open.bigmodel.cn/api/paas/v4', logo_url: '/icon/zai.svg', provider_type: 'builtin', doc_url: 'https://open.bigmodel.cn/dev/api', }, { key: 'siliconflow', nameKey: 'SiliconFlow Provider', descriptionKey: 'SiliconFlow Provider Description', api_format: 'openai', identifier: 'siliconflow', base_url: 'https://api.siliconflow.cn/v1', logo_url: '/icon/siliconcloud-color.svg', provider_type: 'builtin', doc_url: 'https://docs.siliconflow.cn/', }, { key: 'deepseek', nameKey: 'DeepSeek Provider', descriptionKey: 'DeepSeek Provider Description', api_format: 'openai', identifier: 'deepseek', base_url: 'https://api.deepseek.com/v1', logo_url: '/icon/deepseek-color.svg', provider_type: 'builtin', doc_url: 'https://platform.deepseek.com/api-docs', }, { key: 'ollama', nameKey: 'Ollama Provider', descriptionKey: 'Ollama Provider Description', api_format: 'ollama', identifier: 'ollama', base_url: 'http://localhost:11434', logo_url: '/icon/ollama.svg', provider_type: 'builtin', doc_url: 'https://github.com/ollama/ollama/blob/main/docs/api.md', }, ]; const abilityTagColor: Record = { chat: 'purple', vision: 'geekblue', embedding: 'gold', rerank: 'cyan', voice: 'orange', tools: 'magenta', }; type AIProviderFormValues = { name?: string; identifier?: string; api_format: AIProviderPayload['api_format']; base_url?: string; api_key?: string; logo_url?: string; provider_type?: string; }; type AIModelFormValues = Omit; interface RemoteModelCandidate extends AIModelPayload { exists: boolean; } const { Title, Text } = Typography; export default function AiSettingsTab() { const { t } = useI18n(); const [providers, setProviders] = useState([]); const [defaults, setDefaults] = useState({}); const [defaultSelections, setDefaultSelections] = useState({}); const [loading, setLoading] = useState(true); const [savingDefaults, setSavingDefaults] = useState(false); const [providerModal, setProviderModal] = useState({ open: false, step: 1 }); const [modelModal, setModelModal] = useState({ open: false }); const [selectedTemplate, setSelectedTemplate] = useState(null); const [providerForm] = Form.useForm(); const [modelForm] = Form.useForm(); const [modelMetadata, setModelMetadata] = useState | null>(null); const [remoteModels, setRemoteModels] = useState([]); const [selectedRemoteModels, setSelectedRemoteModels] = useState([]); const [pullingRemoteModels, setPullingRemoteModels] = useState(false); const [addingRemoteModels, setAddingRemoteModels] = useState(false); const [modelModalTab, setModelModalTab] = useState<'remote' | 'manual'>('remote'); const [remoteSearchKeyword, setRemoteSearchKeyword] = useState(''); const capabilitiesValue = Form.useWatch('capabilities', modelForm); const showEmbeddingDimensions = useMemo(() => { const capabilities = Array.isArray(capabilitiesValue) ? capabilitiesValue : []; return capabilities.includes('embedding') || capabilities.includes('rerank'); }, [capabilitiesValue]); useEffect(() => { if (!showEmbeddingDimensions) { modelForm.setFieldsValue({ embedding_dimensions: null }); } }, [modelForm, showEmbeddingDimensions]); const filteredRemoteModels = useMemo(() => { const keyword = remoteSearchKeyword.trim().toLowerCase(); if (!keyword) { return remoteModels; } return remoteModels.filter((item) => { const name = (item.name || '').toLowerCase(); const displayName = (item.display_name || '').toLowerCase(); return name.includes(keyword) || displayName.includes(keyword); }); }, [remoteModels, remoteSearchKeyword]); const refreshData = useCallback(async () => { setLoading(true); try { const [providerList, defaultMap] = await Promise.all([ fetchProviders(), fetchDefaults(), ]); setProviders(providerList.map((item) => ({ ...item, models: (item.models || []).sort((a, b) => a.name.localeCompare(b.name)), }))); setDefaults(defaultMap); const initialSelections: AIDefaultAssignments = {}; abilityOrder.forEach((ability) => { const model = defaultMap[ability]; initialSelections[ability] = model ? model.id : null; }); setDefaultSelections(initialSelections); } catch (err) { const msg = err instanceof Error ? err.message : String(err); message.error(msg || t('Load failed')); } finally { setLoading(false); } }, [t]); useEffect(() => { void refreshData(); }, [refreshData]); const handleOpenProviderModal = (existing?: AIProvider | null) => { if (existing) { setProviderModal({ open: true, editing: existing, step: 2 }); const matchedTemplate = providerTemplates.find((item) => item.identifier === existing.identifier) ?? null; setSelectedTemplate(matchedTemplate); providerForm.setFieldsValue({ name: existing.name, identifier: existing.identifier, api_format: existing.api_format, base_url: existing.base_url ?? undefined, api_key: '', logo_url: existing.logo_url ?? undefined, provider_type: existing.provider_type ?? undefined, }); } else { providerForm.resetFields(); providerForm.setFieldsValue({ api_format: 'openai' }); setSelectedTemplate(null); setProviderModal({ open: true, step: 1 }); } }; const handleCloseProviderModal = () => { setProviderModal({ open: false, step: 1 }); setSelectedTemplate(null); providerForm.resetFields(); }; const handleTemplateSelect = (template: ProviderTemplate) => { setSelectedTemplate(template); setProviderModal((prev) => ({ ...prev, step: 2 })); providerForm.setFieldsValue({ name: t(template.nameKey), identifier: template.identifier, api_format: template.api_format, base_url: template.base_url ?? '', api_key: '', logo_url: template.logo_url ?? '', provider_type: template.provider_type ?? '', }); }; const handleBackToTemplateStep = () => { setProviderModal((prev) => ({ ...prev, step: 1, editing: undefined })); setSelectedTemplate(null); providerForm.resetFields(); providerForm.setFieldsValue({ api_format: 'openai' }); }; const handleSubmitProvider = async () => { const values = await providerForm.validateFields(); const trimmedBaseUrl = values.base_url?.trim(); const trimmedApiKey = values.api_key?.trim(); const trimmedLogoUrl = values.logo_url?.trim(); const trimmedProviderType = values.provider_type?.trim(); const payload: AIProviderPayload = { name: (values.name || '').trim(), identifier: (values.identifier || '').trim(), api_format: values.api_format, base_url: trimmedBaseUrl ? trimmedBaseUrl : null, logo_url: trimmedLogoUrl ? trimmedLogoUrl : null, provider_type: trimmedProviderType ? trimmedProviderType : null, }; if (trimmedApiKey) { payload.api_key = trimmedApiKey; } try { if (providerModal.editing) { await updateProvider(providerModal.editing.id, payload); message.success(t('Updated successfully')); } else { await createProvider(payload); message.success(t('Created successfully')); } handleCloseProviderModal(); await refreshData(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); message.error(msg || t('Save failed')); } }; const handleDeleteProvider = (provider: AIProvider) => { Modal.confirm({ title: t('Delete provider?'), content: t('Deleting this provider will also remove all associated models. Continue?'), okText: t('Confirm'), cancelText: t('Cancel'), okButtonProps: { danger: true }, onOk: async () => { try { await deleteProvider(provider.id); message.success(t('Deleted successfully')); await refreshData(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); message.error(msg || t('Delete failed')); } }, }); }; const handleOpenModelModal = (provider: AIProvider, editing?: AIModel | null) => { setModelModal({ open: true, provider, editing }); setRemoteModels([]); setSelectedRemoteModels([]); setPullingRemoteModels(false); setAddingRemoteModels(false); setModelModalTab(editing ? 'manual' : 'remote'); setRemoteSearchKeyword(''); if (editing) { modelForm.setFieldsValue({ name: editing.name, display_name: editing.display_name, description: editing.description, capabilities: editing.capabilities || [], context_window: editing.context_window, embedding_dimensions: editing.embedding_dimensions, }); setModelMetadata(editing.metadata ?? null); } else { modelForm.resetFields(); modelForm.setFieldValue('capabilities', []); setModelMetadata(null); } }; const handleCloseModelModal = () => { setModelModal({ open: false }); modelForm.resetFields(); setModelMetadata(null); setRemoteModels([]); setSelectedRemoteModels([]); setPullingRemoteModels(false); setAddingRemoteModels(false); setModelModalTab('remote'); setRemoteSearchKeyword(''); }; const handlePullRemoteModels = async () => { if (!modelModal.provider) return; setPullingRemoteModels(true); try { const { models: remoteList } = await fetchRemoteModels(modelModal.provider.id); const existingNames = new Set((modelModal.provider.models || []).map((item) => item.name)); const mapped = remoteList.map((item) => ({ ...item, metadata: item.metadata ?? null, exists: existingNames.has(item.name), })); if (!mapped.length) { message.info(t('No remote models found')); } setRemoteModels(mapped); setSelectedRemoteModels([]); setRemoteSearchKeyword(''); } catch (err) { const msg = err instanceof Error ? err.message : String(err); message.error(msg || t('Fetch failed')); } finally { setPullingRemoteModels(false); } }; const handleToggleRemoteSelection = (name: string, checked: boolean) => { setSelectedRemoteModels((prev) => { if (checked) { return prev.includes(name) ? prev : [...prev, name]; } return prev.filter((item) => item !== name); }); }; const handleAddRemoteModels = async () => { if (!modelModal.provider) return; const candidates = remoteModels.filter( (item) => !item.exists && selectedRemoteModels.includes(item.name), ); if (!candidates.length) { message.warning(t('Select models to add')); return; } setAddingRemoteModels(true); try { await Promise.all(candidates.map(async (item) => { const { exists, ...candidate } = item; void exists; const payload: AIModelPayload = { ...candidate, metadata: candidate.metadata ?? null, }; await createModel(modelModal.provider!.id, payload); })); message.success(t('Added {count} models', { count: candidates.length })); handleCloseModelModal(); await refreshData(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); message.error(msg || t('Save failed')); } finally { setAddingRemoteModels(false); } }; const renderManualModelForm = () => (
{!modelModal.editing && ( )} {modelModal.editing && ( )} setRemoteSearchKeyword(event.target.value)} />
{ const disabled = item.exists; const checked = selectedRemoteModels.includes(item.name); return ( {t('Already Added')}] : undefined} > handleToggleRemoteSelection(item.name, event.target.checked)} >
{item.display_name || item.name} {item.description ? (
{item.description}
) : null} {(item.capabilities || []).map((cap) => ( {cap.toUpperCase()} ))} {item.context_window ? {item.context_window} tokens : null} {item.embedding_dimensions ? {item.embedding_dimensions} dims : null}
); }} />
) : null} ); const handleSubmitModel = async () => { const values = await modelForm.validateFields(); if (!showEmbeddingDimensions) { values.embedding_dimensions = null; } const payload: AIModelPayload = { ...values, metadata: modelMetadata ?? null, }; if (!modelModal.provider) return; try { if (modelModal.editing) { await updateModel(modelModal.editing.id, payload); message.success(t('Updated successfully')); } else { await createModel(modelModal.provider.id, payload); message.success(t('Created successfully')); } handleCloseModelModal(); await refreshData(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); message.error(msg || t('Save failed')); } }; const handleDeleteModel = (model: AIModel) => { Modal.confirm({ title: t('Delete model?'), content: t('This operation cannot be undone. Continue?'), okText: t('Confirm'), cancelText: t('Cancel'), okButtonProps: { danger: true }, onOk: async () => { try { await deleteModel(model.id); message.success(t('Deleted successfully')); await refreshData(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); message.error(msg || t('Delete failed')); } }, }); }; const updateSelection = (ability: AIAbility, value: number | null) => { setDefaultSelections((prev) => ({ ...prev, [ability]: value ?? null, })); }; const allModels = useMemo(() => { const map = new Map(); providers.forEach((provider) => { (provider.models || []).forEach((model) => { map.set(model.id, { ...model, provider }); }); }); return map; }, [providers]); const collectionsByAbility = (ability: AIAbility) => { return providers .map((provider) => { const models = (provider.models || []).filter((model) => (model.capabilities || []).includes(ability)); if (!models.length) return null; return { label: (
{provider.logo_url ? ( {provider.name} ) : ( )} {provider.name}
), options: models.map((model) => ({ value: model.id, label: (
{model.display_name || model.name} {provider.name}
), })), }; }) .filter(Boolean) as Array<{ label: ReactNode; options: Array<{ value: number; label: ReactNode }> }>; }; const handleSaveDefaults = async () => { const previousEmbedding = defaults.embedding?.id ?? null; const nextEmbedding = defaultSelections.embedding ?? null; const previousEmbeddingModel = previousEmbedding ? allModels.get(previousEmbedding) : undefined; const nextEmbeddingModel = nextEmbedding ? allModels.get(nextEmbedding) : undefined; const dimensionChanged = previousEmbeddingModel && nextEmbeddingModel && previousEmbeddingModel.embedding_dimensions && nextEmbeddingModel.embedding_dimensions && previousEmbeddingModel.embedding_dimensions !== nextEmbeddingModel.embedding_dimensions; const proceed = async () => { setSavingDefaults(true); try { const result = await updateDefaults(defaultSelections); setDefaults(result); const nextSelections: AIDefaultAssignments = {}; abilityOrder.forEach((ability) => { const model = result[ability]; nextSelections[ability] = model ? model.id : null; }); setDefaultSelections(nextSelections); message.success(t('Saved successfully')); } catch (err) { const msg = err instanceof Error ? err.message : String(err); message.error(msg || t('Save failed')); } finally { setSavingDefaults(false); } }; if (previousEmbedding !== nextEmbedding && dimensionChanged) { Modal.confirm({ title: t('Confirm embedding dimension change'), content: t('Changing the embedding dimension will clear the vector database automatically. Continue?'), okText: t('Confirm'), cancelText: t('Cancel'), onOk: proceed, }); return; } await proceed(); }; const renderProviderCard = (provider: AIProvider) => { const models = provider.models || []; return (
{provider.logo_url ? ( {provider.name} ) : ( )}
{provider.name}
{provider.api_format.toUpperCase()} API {provider.base_url && ( {provider.base_url} )}
)} extra={( ) : ( {!providerModal.editing && ( )} ); return (
{t('AI Providers & Models')} {t('Manage AI providers, synchronize compatible models, and configure default capabilities across the system.')}
{providers.map((provider) => ( {renderProviderCard(provider)} ))} {!providers.length && !loading ? ( ) : null} {abilityOrder.map((ability) => { const info = abilityInfo[ability]; const options = collectionsByAbility(ability); return (
{info.icon}
{t(info.label)}
{t(info.description)}
{t('API Key')} {providerModal.editing ? ( {providerModal.editing.has_api_key ? '已设置' : '未设置'} ) : null} )} > )} )} > {modelModal.editing ? ( renderManualModelForm() ) : ( setModelModalTab(key as 'remote' | 'manual')} destroyInactiveTabPane={false} style={{ width: '100%' }} items={[ { key: 'remote', label: t('Pull Models'), children: renderRemoteModelsTab(), }, { key: 'manual', label: t('Manual Add'), children: renderManualModelForm(), }, ]} /> )} ); }