Files
Foxel/web/src/pages/SystemSettingsPage/components/AiSettingsTab.tsx

1207 lines
41 KiB
TypeScript

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<AIAbility, { icon: ReactNode; label: string; color: string; description: string }> = {
chat: {
icon: <MessageOutlined />,
label: 'Main Chat Model',
color: 'purple',
description: 'Primary assistant for conversations, reasoning, and tool calls.',
},
vision: {
icon: <EyeOutlined />,
label: 'Vision Model',
color: 'geekblue',
description: 'Handles multimodal perception such as image understanding.',
},
embedding: {
icon: <AppstoreOutlined />,
label: 'Embedding Model',
color: 'gold',
description: 'Transforms content into dense vectors for search and retrieval.',
},
rerank: {
icon: <SortAscendingOutlined />,
label: 'Rerank Model',
color: 'cyan',
description: 'Optimises ranking quality for search candidates.',
},
voice: {
icon: <SoundOutlined />,
label: 'Voice Model',
color: 'orange',
description: 'Covers text-to-speech and speech understanding scenarios.',
},
tools: {
icon: <ToolOutlined />,
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<AIAbility, string> = {
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<AIModelPayload, 'metadata'>;
interface RemoteModelCandidate extends AIModelPayload {
exists: boolean;
}
const { Title, Text } = Typography;
export default function AiSettingsTab() {
const { t } = useI18n();
const [providers, setProviders] = useState<AIProvider[]>([]);
const [defaults, setDefaults] = useState<AIDefaultModels>({});
const [defaultSelections, setDefaultSelections] = useState<AIDefaultAssignments>({});
const [loading, setLoading] = useState<boolean>(true);
const [savingDefaults, setSavingDefaults] = useState<boolean>(false);
const [providerModal, setProviderModal] = useState<ProviderModalState>({ open: false, step: 1 });
const [modelModal, setModelModal] = useState<ModelModalState>({ open: false });
const [selectedTemplate, setSelectedTemplate] = useState<ProviderTemplate | null>(null);
const [providerForm] = Form.useForm<AIProviderFormValues>();
const [modelForm] = Form.useForm<AIModelFormValues>();
const [modelMetadata, setModelMetadata] = useState<Record<string, unknown> | null>(null);
const [remoteModels, setRemoteModels] = useState<RemoteModelCandidate[]>([]);
const [selectedRemoteModels, setSelectedRemoteModels] = useState<string[]>([]);
const [pullingRemoteModels, setPullingRemoteModels] = useState<boolean>(false);
const [addingRemoteModels, setAddingRemoteModels] = useState<boolean>(false);
const [modelModalTab, setModelModalTab] = useState<'remote' | 'manual'>('remote');
const [remoteSearchKeyword, setRemoteSearchKeyword] = useState<string>('');
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<RemoteModelCandidate>((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 = () => (
<Form layout="vertical" form={modelForm}>
{!modelModal.editing && (
<Form.Item
name="name"
label={t('Model Identifier')}
rules={[{ required: true, message: t('Enter model identifier') }]}
>
<Input placeholder="例如 gpt-4o-mini 或 models/gemini-1.5-flash" />
</Form.Item>
)}
{modelModal.editing && (
<Form.Item label={t('Model Identifier')}>
<Input value={modelModal.editing.name} disabled />
</Form.Item>
)}
<Form.Item name="display_name" label={t('Display Name')}>
<Input />
</Form.Item>
<Form.Item name="description" label={t('Description')}>
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="capabilities" label={t('Capabilities')}>
<Select
mode="multiple"
allowClear
options={abilityOrder.map((ability) => ({
value: ability,
label: t(abilityInfo[ability].label),
}))}
/>
</Form.Item>
<Row gutter={16}>
<Col span={showEmbeddingDimensions ? 12 : 24}>
<Form.Item name="context_window" label={t('Context Window')}>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Col>
{showEmbeddingDimensions ? (
<Col span={12}>
<Form.Item name="embedding_dimensions" label={t('Embedding Dimensions')}>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Col>
) : null}
</Row>
</Form>
);
const renderRemoteModelsTab = () => (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space wrap>
<Button
icon={<ReloadOutlined />}
loading={pullingRemoteModels}
onClick={handlePullRemoteModels}
disabled={!modelModal.provider || addingRemoteModels}
>
{t('Pull Models')}
</Button>
{remoteModels.length > 0 ? (
<Button
onClick={() => {
setRemoteModels([]);
setSelectedRemoteModels([]);
setRemoteSearchKeyword('');
}}
disabled={pullingRemoteModels || addingRemoteModels}
>
{t('Clear Remote List')}
</Button>
) : null}
</Space>
{remoteModels.length > 0 ? (
<>
<Input
allowClear
placeholder={t('Search fetched models')}
value={remoteSearchKeyword}
onChange={(event) => setRemoteSearchKeyword(event.target.value)}
/>
<div className="fx-ai-remote-models">
<Alert type="info" message={t('Select models from the list to add them automatically')} showIcon />
<List
size="small"
dataSource={filteredRemoteModels}
locale={{
emptyText: remoteSearchKeyword.trim()
? t('No remote models match search')
: t('No remote models'),
}}
renderItem={(item) => {
const disabled = item.exists;
const checked = selectedRemoteModels.includes(item.name);
return (
<List.Item
key={item.name}
className="fx-ai-remote-item"
actions={disabled ? [<Tag key="exists" color="default">{t('Already Added')}</Tag>] : undefined}
>
<Checkbox
checked={checked}
disabled={disabled || addingRemoteModels}
onChange={(event) => handleToggleRemoteSelection(item.name, event.target.checked)}
>
<div className="fx-ai-remote-item-main">
<Text strong>{item.display_name || item.name}</Text>
{item.description ? (
<div className="fx-ai-remote-desc">{item.description}</div>
) : null}
<Space size={4} wrap>
{(item.capabilities || []).map((cap) => (
<Tag key={cap} color={abilityTagColor[cap as AIAbility]}>
{cap.toUpperCase()}
</Tag>
))}
</Space>
<Space size={12} className="fx-ai-model-metrics">
{item.context_window ? <span><CopyOutlined /> {item.context_window} tokens</span> : null}
{item.embedding_dimensions ? <span><BuildOutlined /> {item.embedding_dimensions} dims</span> : null}
</Space>
</div>
</Checkbox>
</List.Item>
);
}}
/>
</div>
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
<Button
type="primary"
onClick={handleAddRemoteModels}
loading={addingRemoteModels}
disabled={addingRemoteModels || !selectedRemoteModels.length}
>
{t('Add Selected Models')}
</Button>
</Space>
</>
) : null}
</Space>
);
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<number, AIModel>();
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: (
<div className="fx-ai-provider-option">
{provider.logo_url ? (
<img src={provider.logo_url} alt={provider.name} />
) : (
<RobotOutlined style={{ marginRight: 8 }} />
)}
<span>{provider.name}</span>
</div>
),
options: models.map((model) => ({
value: model.id,
label: (
<div className="fx-ai-model-option">
<span className="fx-ai-model-name">{model.display_name || model.name}</span>
<span className="fx-ai-model-provider-tag">{provider.name}</span>
</div>
),
})),
};
})
.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 (
<Card
key={provider.id}
className="fx-ai-provider-card"
title={(
<div className="fx-ai-provider-header">
<div className="fx-ai-provider-meta">
{provider.logo_url ? (
<img src={provider.logo_url} alt={provider.name} className="fx-ai-provider-logo" />
) : (
<RobotOutlined className="fx-ai-provider-logo" />
)}
<div>
<Text className="fx-ai-provider-name">{provider.name}</Text>
<div className="fx-ai-provider-sub">
<Tag color="blue">{provider.api_format.toUpperCase()} API</Tag>
{provider.base_url && (
<Tooltip title={provider.base_url}>
<Text type="secondary" ellipsis style={{ maxWidth: 180 }}>
{provider.base_url}
</Text>
</Tooltip>
)}
</div>
</div>
</div>
</div>
)}
extra={(
<Space className="fx-ai-provider-actions" size={8} wrap>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => handleOpenModelModal(provider)}
>
{t('Add Model')}
</Button>
<Button
icon={<EditOutlined />}
onClick={() => handleOpenProviderModal(provider)}
shape="circle"
aria-label={t('Edit Provider')}
/>
<Button
icon={<DeleteOutlined />}
danger
onClick={() => handleDeleteProvider(provider)}
shape="circle"
aria-label={t('Delete Provider')}
/>
</Space>
)}
>
{models.length === 0 ? (
<Empty description={t('No models yet')} />
) : (
<div className="fx-ai-model-list">
{models.map((model) => (
<div key={model.id} className="fx-ai-model-item">
<div className="fx-ai-model-info">
<div className="fx-ai-model-header">
<Title level={5} className="fx-ai-model-title">{model.display_name || model.name}</Title>
{(model.capabilities || []).length ? (
<Space size={4} wrap className="fx-ai-model-tags">
{(model.capabilities || []).map((cap) => (
<Tag key={cap} color={abilityTagColor[cap as AIAbility]}>
{cap.toUpperCase()}
</Tag>
))}
</Space>
) : null}
</div>
<div className="fx-ai-model-meta">
<Text type="secondary" className="fx-ai-model-desc">
{model.description || model.name}
</Text>
<Space size={10} className="fx-ai-model-metrics" wrap>
{model.context_window ? <span><CopyOutlined /> {model.context_window} tokens</span> : null}
{model.embedding_dimensions ? <span><BuildOutlined /> {model.embedding_dimensions} dims</span> : null}
</Space>
</div>
</div>
<Space className="fx-ai-model-actions">
<Button size="small" icon={<EditOutlined />} onClick={() => handleOpenModelModal(provider, model)} />
<Button size="small" icon={<DeleteOutlined />} danger onClick={() => handleDeleteModel(model)} />
</Space>
</div>
))}
</div>
)}
</Card>
);
};
const stepItems = useMemo(() => ([
{ title: t('Choose Template') },
{ title: t('Configure Provider') },
]), [t]);
const activeTemplate = useMemo(() => {
if (selectedTemplate) {
return selectedTemplate;
}
if (providerModal.editing) {
return providerTemplates.find((tpl) => tpl.identifier === providerModal.editing?.identifier) ?? null;
}
return null;
}, [selectedTemplate, providerModal.editing]);
const allowFormatChange = useMemo(() => {
if (providerModal.editing) {
return false;
}
if (selectedTemplate) {
return selectedTemplate.allow_format_switch ?? false;
}
return true;
}, [providerModal.editing, selectedTemplate]);
const drawerFooter = providerModal.step === 1
? (
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
<Button onClick={handleCloseProviderModal}>{t('Cancel')}</Button>
</Space>
)
: (
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
{!providerModal.editing && (
<Button icon={<ArrowLeftOutlined />} onClick={handleBackToTemplateStep}>
{t('Back to Templates')}
</Button>
)}
<Button onClick={handleCloseProviderModal}>{t('Cancel')}</Button>
<Button type="primary" onClick={handleSubmitProvider}>
{t('Save')}
</Button>
</Space>
);
return (
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<div className="fx-ai-top-bar">
<div>
<Title level={3} style={{ marginBottom: 4 }}>{t('AI Providers & Models')}</Title>
<Text type="secondary">
{t('Manage AI providers, synchronize compatible models, and configure default capabilities across the system.')}
</Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenProviderModal()}>
{t('Add Provider')}
</Button>
</div>
<Row gutter={[24, 24]}>
{providers.map((provider) => (
<Col key={provider.id} xs={24}>
{renderProviderCard(provider)}
</Col>
))}
{!providers.length && !loading ? (
<Col span={24}>
<Card className="fx-ai-empty-card">
<Empty description={t('Add your first AI provider to get started')} />
</Card>
</Col>
) : null}
</Row>
<Card className="fx-ai-defaults-card" title={t('Default Models Configuration')}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{abilityOrder.map((ability) => {
const info = abilityInfo[ability];
const options = collectionsByAbility(ability);
return (
<div key={ability} className="fx-ai-default-row">
<div className="fx-ai-default-meta">
<div className={`fx-ai-default-icon fx-ai-${ability}`}>
{info.icon}
</div>
<div>
<Text strong>{t(info.label)}</Text>
<div className="fx-ai-default-desc">{t(info.description)}</div>
</div>
</div>
<div className="fx-ai-default-control">
<Select
allowClear
className="fx-ai-default-select"
placeholder={t('Select a model')}
value={defaultSelections[ability] ?? undefined}
options={options}
onChange={(value) => updateSelection(ability, value ?? null)}
/>
</div>
</div>
);
})}
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
<Button type="primary" loading={savingDefaults} onClick={handleSaveDefaults}>
{t('Save')}
</Button>
</Space>
</Space>
</Card>
<Drawer
width={640}
open={providerModal.open}
title={providerModal.editing ? t('Edit Provider') : t('Add Provider')}
onClose={handleCloseProviderModal}
footer={drawerFooter}
>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<Steps
size="small"
current={providerModal.step - 1}
items={stepItems}
className="fx-ai-add-provider-steps"
/>
{providerModal.step === 1 ? (
<div className="fx-ai-template-grid">
{providerTemplates.map((template) => (
<div
key={template.key}
className="fx-ai-template-card"
role="button"
tabIndex={0}
onClick={() => handleTemplateSelect(template)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleTemplateSelect(template);
}
}}
>
<div className="fx-ai-template-card-main">
<div className="fx-ai-template-icon">
{template.logo_url ? (
<img src={template.logo_url} alt={t(template.nameKey)} />
) : (
<RobotOutlined />
)}
</div>
<div className="fx-ai-template-text">
<div className="fx-ai-template-name">{t(template.nameKey)}</div>
<div className="fx-ai-template-desc">{t(template.descriptionKey)}</div>
</div>
</div>
<ArrowRightOutlined className="fx-ai-template-arrow" />
</div>
))}
</div>
) : (
<>
{activeTemplate ? (
<div className="fx-ai-template-summary">
<div className="fx-ai-template-icon summary">
{activeTemplate.logo_url ? (
<img src={activeTemplate.logo_url} alt={t(activeTemplate.nameKey)} />
) : (
<RobotOutlined />
)}
</div>
<div className="fx-ai-template-summary-text">
<div className="fx-ai-template-name">{t(activeTemplate.nameKey)}</div>
<div className="fx-ai-template-desc">{t(activeTemplate.descriptionKey)}</div>
<Space size={8} wrap>
<Tag color="blue">{(providerForm.getFieldValue('api_format') || activeTemplate.api_format).toUpperCase()} API</Tag>
{activeTemplate.doc_url ? (
<a href={activeTemplate.doc_url} target="_blank" rel="noreferrer">
{t('View Docs')}
</a>
) : null}
</Space>
</div>
</div>
) : null}
<Form layout="vertical" form={providerForm}>
<Form.Item name="provider_type" hidden>
<Input />
</Form.Item>
<Form.Item
name="name"
label={t('Display Name')}
rules={[{ required: true, message: t('Enter name') }]}
>
<Input />
</Form.Item>
<Form.Item
name="identifier"
label={t('Identifier')}
rules={[
{ required: true, message: t('Enter identifier') },
{ pattern: /^[a-z0-9_\-.]+$/, message: t('Only lowercase letters, numbers, dash, dot and underscore are allowed') },
]}
>
<Input disabled={!!providerModal.editing} />
</Form.Item>
<Form.Item
name="api_format"
label={t('API Format')}
rules={[{ required: true }]}
>
<Select
disabled={!allowFormatChange}
options={[
{ value: 'openai', label: 'OpenAI Compatible' },
{ value: 'gemini', label: 'Gemini Compatible' },
{ value: 'anthropic', label: 'Anthropic Native' },
{ value: 'ollama', label: 'Ollama Native' },
]}
/>
</Form.Item>
<Form.Item name="base_url" label={t('Base URL')} rules={[{ required: true, message: t('Enter base url') }]}>
<Input placeholder="https://" />
</Form.Item>
<Form.Item
name="api_key"
label={(
<Space size={8}>
{t('API Key')}
{providerModal.editing ? (
<Tag color={providerModal.editing.has_api_key ? 'green' : 'default'}>
{providerModal.editing.has_api_key ? '已设置' : '未设置'}
</Tag>
) : null}
</Space>
)}
>
<Input.Password
placeholder={
providerModal.editing
? '留空不更新,填写将覆盖'
: t('Optional, can also be provided per request')
}
autoComplete="new-password"
visibilityToggle={false}
/>
</Form.Item>
<Form.Item name="logo_url" label={t('Logo URL')}>
<Input placeholder="https://" />
</Form.Item>
</Form>
</>
)}
</Space>
</Drawer>
<Drawer
width={520}
open={modelModal.open}
title={modelModal.editing ? t('Edit Model') : t('Add Model')}
onClose={handleCloseModelModal}
footer={(
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
<Button onClick={handleCloseModelModal}>{t('Cancel')}</Button>
<Button type="primary" onClick={handleSubmitModel} disabled={addingRemoteModels}>
{t('Save')}
</Button>
</Space>
)}
>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
{modelModal.editing ? (
renderManualModelForm()
) : (
<Tabs
activeKey={modelModalTab}
onChange={(key) => 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(),
},
]}
/>
)}
</Space>
</Drawer>
</Space>
);
}