mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-31 05:00:33 +08:00
1207 lines
41 KiB
TypeScript
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>
|
|
);
|
|
}
|