From e1f99365618b545d7a68264fd12d8836d03eba42 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 19:58:18 +0800 Subject: [PATCH] refactor(config): split modelProvider.tsx into modular directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分为 7 个文件:index.ts (barrel), types.ts, utils.ts, 3 个组件, index.tsx (主页面 895行) - 所有子组件 < 500 行 - 构建零错误 - 功能完全等价 --- dashboard/src/routes/config/modelProvider.tsx | 1804 ----------------- .../config/modelProvider/ProviderCard.tsx | 136 ++ .../config/modelProvider/ProviderForm.tsx | 458 +++++ .../config/modelProvider/ProviderList.tsx | 353 ++++ .../src/routes/config/modelProvider/index.ts | 13 +- .../src/routes/config/modelProvider/index.tsx | 895 ++++++++ 6 files changed, 1844 insertions(+), 1815 deletions(-) delete mode 100644 dashboard/src/routes/config/modelProvider.tsx create mode 100644 dashboard/src/routes/config/modelProvider/ProviderCard.tsx create mode 100644 dashboard/src/routes/config/modelProvider/ProviderForm.tsx create mode 100644 dashboard/src/routes/config/modelProvider/ProviderList.tsx create mode 100644 dashboard/src/routes/config/modelProvider/index.tsx diff --git a/dashboard/src/routes/config/modelProvider.tsx b/dashboard/src/routes/config/modelProvider.tsx deleted file mode 100644 index d42f8ba0..00000000 --- a/dashboard/src/routes/config/modelProvider.tsx +++ /dev/null @@ -1,1804 +0,0 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { ScrollArea } from '@/components/ui/scroll-area' - -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from '@/components/ui/alert-dialog' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover' -import { Checkbox } from '@/components/ui/checkbox' -import { Badge } from '@/components/ui/badge' -import { Plus, Pencil, Trash2, Save, Eye, EyeOff, Copy, Search, Info, Power, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Check, ChevronsUpDown, Zap, Loader2, CheckCircle2, XCircle, AlertCircle } from 'lucide-react' -import { getModelConfig, updateModelConfig, updateModelConfigSection, testProviderConnection, type TestConnectionResult } from '@/lib/config-api' -import { useToast } from '@/hooks/use-toast' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { HelpTooltip } from '@/components/ui/help-tooltip' -import { useTour } from '@/components/tour' -import { MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps, STEP_ROUTE_MAP } from '@/components/tour/tours/model-assignment-tour' -import { useNavigate } from '@tanstack/react-router' -import { RestartOverlay } from '@/components/restart-overlay' -import { RestartProvider, useRestart } from '@/lib/restart-context' -import { PROVIDER_TEMPLATES } from './providerTemplates' -import type { APIProvider, DeleteConfirmState, FormErrors } from './modelProvider/types' -import { cleanProviderData, validateProvider } from './modelProvider/utils' - -// 主导出组件:包装 RestartProvider -export function ModelProviderConfigPage() { - return ( - - - - ) -} - -// 内部实现组件 -function ModelProviderConfigPageContent() { - const [providers, setProviders] = useState([]) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [autoSaving, setAutoSaving] = useState(false) - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) - const [editDialogOpen, setEditDialogOpen] = useState(false) - const [editingProvider, setEditingProvider] = useState(null) - const [editingIndex, setEditingIndex] = useState(null) - const [selectedTemplate, setSelectedTemplate] = useState('custom') - const [templateComboboxOpen, setTemplateComboboxOpen] = useState(false) - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) - const [deletingIndex, setDeletingIndex] = useState(null) - const [showApiKey, setShowApiKey] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const [selectedProviders, setSelectedProviders] = useState>(new Set()) - const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false) - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(20) - const [jumpToPage, setJumpToPage] = useState('') - - // 删除提供商确认对话框状态(合并为单个对象以减少状态变量) - const [deleteConfirmState, setDeleteConfirmState] = useState({ - isOpen: false, - providersToDelete: [], - affectedModels: [], - pendingProviders: [], - context: 'auto', - oldProviders: [], - }) - - // 表单验证错误状态 - const [formErrors, setFormErrors] = useState({}) - - // 测试连接状态 - const [testingProviders, setTestingProviders] = useState>(new Set()) - const [testResults, setTestResults] = useState>(new Map()) - - const { toast } = useToast() - const navigate = useNavigate() - const { state: tourState, goToStep, registerTour } = useTour() - const { triggerRestart, isRestarting } = useRestart() - - // 用于防抖的定时器 - const autoSaveTimerRef = useRef | null>(null) - const initialLoadRef = useRef(true) - - // 注册 Tour(确保跨页导航时 Tour 仍然可用) - useEffect(() => { - registerTour(MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps) - }, [registerTour]) - - // 监听 Tour 步骤变化,处理页面导航 - useEffect(() => { - if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) { - const targetRoute = STEP_ROUTE_MAP[tourState.stepIndex] - if (targetRoute && !window.location.pathname.endsWith(targetRoute.replace('/config/', ''))) { - navigate({ to: targetRoute }) - } - } - }, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning, navigate]) - - // 监听 Tour 步骤变化,处理弹窗的打开和关闭 - // 提供商弹窗步骤: 3-9 (index 3-9),弹窗外步骤: 0-2 (index 0-2) 和 10+ (index 10+) - const prevTourStepRef = useRef(tourState.stepIndex) - useEffect(() => { - if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) { - const prevStep = prevTourStepRef.current - const currentStep = tourState.stepIndex - - // 如果从弹窗内步骤 (3-9) 回退到弹窗外步骤 (0-2),关闭弹窗 - if (prevStep >= 3 && prevStep <= 9 && currentStep < 3) { - setEditDialogOpen(false) - } - - // 如果从弹窗外步骤 (10+) 回退到弹窗内步骤 (3-9),重新打开弹窗 - // 这处理了从模型管理页面第 11 步点击"上一步"回到提供商弹窗的情况 - if (prevStep >= 10 && currentStep >= 3 && currentStep <= 9) { - // 需要打开空白弹窗以便 Tour 可以定位到弹窗内的元素 - setFormErrors({}) - setSelectedTemplate('custom') - setEditingProvider({ - name: '', - base_url: '', - api_key: '', - client_type: 'openai', - max_retry: 2, - timeout: 30, - retry_interval: 10, - }) - setEditingIndex(null) - setShowApiKey(false) - setEditDialogOpen(true) - } - - prevTourStepRef.current = currentStep - } - }, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning]) - - // 处理 Tour 中需要用户点击才能继续的步骤 - useEffect(() => { - if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) return - - const handleTourClick = (e: MouseEvent) => { - const target = e.target as HTMLElement - const currentStep = tourState.stepIndex - - // Step 3 (index 2): 点击添加提供商按钮 - if (currentStep === 2 && target.closest('[data-tour="add-provider-button"]')) { - setTimeout(() => goToStep(3), 300) - } - // Step 10 (index 9): 点击取消按钮(关闭提供商弹窗) - else if (currentStep === 9 && target.closest('[data-tour="provider-cancel-button"]')) { - setTimeout(() => goToStep(10), 300) - } - } - - document.addEventListener('click', handleTourClick, true) - return () => document.removeEventListener('click', handleTourClick, true) - }, [tourState, goToStep]) - - // 加载配置 - useEffect(() => { - loadConfig() - }, []) - - const loadConfig = async () => { - try { - setLoading(true) - const result = await getModelConfig() - if (!result.success) { - toast({ - title: '加载失败', - description: result.error, - variant: 'destructive', - }) - setLoading(false) - return - } - const config = result.data - setProviders((config.api_providers as APIProvider[]) || []) - setHasUnsavedChanges(false) - initialLoadRef.current = false - } catch (error) { - console.error('加载配置失败:', error) - } finally { - setLoading(false) - } - } - - // 重启麦麦 - const handleRestart = async () => { - await triggerRestart() - } - - // 保存并重启 - const handleSaveAndRestart = async () => { - try { - setSaving(true) - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current) - } - - // 清理 providers 数据:将 null 值转换为默认值 - const cleanedProviders = providers.map(provider => ({ - ...provider, - max_retry: provider.max_retry ?? 2, - timeout: provider.timeout ?? 30, - retry_interval: provider.retry_interval ?? 10, - })) - - // 检查删除提供商的影响 - const { shouldProceed } = await checkDeleteProviderImpact(cleanedProviders, 'restart') - if (!shouldProceed) { - // 需要用户确认,等待确认对话框 - setSaving(false) - return - } - - const resultGet = await getModelConfig() - if (!resultGet.success) { - toast({ - title: '保存失败', - description: resultGet.error, - variant: 'destructive', - }) - setSaving(false) - return - } - const config = resultGet.data - - // 获取所有有效的 provider 名称 - const validProviderNames = new Set(cleanedProviders.map(p => p.name)) - - // 过滤掉引用已删除 provider 的模型 - const originalModels = (config.models as any[]) || [] - const filteredModels = originalModels.filter((model: any) => { - return validProviderNames.has(model.api_provider) - }) - - config.api_providers = cleanedProviders - config.models = filteredModels - - const resultUpdate = await updateModelConfig(config) - if (!resultUpdate.success) { - toast({ - title: '保存失败', - description: resultUpdate.error, - variant: 'destructive', - }) - setSaving(false) - return - } - setHasUnsavedChanges(false) - toast({ - title: '保存成功', - description: '正在重启麦麦...', - }) - await handleRestart() - } catch (error) { - console.error('保存配置失败:', error) - toast({ - title: '保存失败', - description: (error as Error).message, - variant: 'destructive', - }) - setSaving(false) - } - } - - // 检查删除提供商的影响 - const checkDeleteProviderImpact = useCallback(async ( - newProviders: APIProvider[], - context: 'auto' | 'manual' | 'restart' = 'auto' - ) => { - try { - const result = await getModelConfig() - if (!result.success) { - console.error('加载配置失败:', result.error) - return { shouldProceed: true, providers: newProviders } - } - const config = result.data - const oldProviderNames = new Set(providers.map(p => p.name)) - const newProviderNames = new Set(newProviders.map(p => p.name)) - - // 找出被删除的提供商 - const deletedProviders = Array.from(oldProviderNames).filter( - name => !newProviderNames.has(name) - ) - - if (deletedProviders.length === 0) { - // 没有删除提供商,直接保存 - return { shouldProceed: true, providers: newProviders } - } - - // 检查受影响的模型 - const models = (config.models as any[]) || [] - const affected = models.filter((m: any) => - deletedProviders.includes(m.api_provider) - ) - - if (affected.length === 0) { - // 没有受影响的模型,直接删除 - return { shouldProceed: true, providers: newProviders } - } - - // 有受影响的模型,需要用户确认 - setDeleteConfirmState({ - isOpen: true, - providersToDelete: deletedProviders, - affectedModels: affected, - pendingProviders: newProviders, - context, - oldProviders: [...providers], - }) - - return { shouldProceed: false, providers: newProviders } - } catch (error) { - console.error('检查删除影响失败:', error) - return { shouldProceed: true, providers: newProviders } - } - }, [providers]) - - // 确认删除提供商及其关联的模型 - const handleConfirmDeleteProvider = async () => { - try { - const savingFlag = deleteConfirmState.context === 'auto' ? setAutoSaving : setSaving - savingFlag(true) - - setDeleteConfirmState(prev => ({ ...prev, isOpen: false })) - - const resultGet = await getModelConfig() - if (!resultGet.success) { - toast({ - title: '加载失败', - description: resultGet.error, - variant: 'destructive', - }) - savingFlag(false) - return - } - const config = resultGet.data - - // 清理 providers 数据 - const cleanedProviders = deleteConfirmState.pendingProviders.map(cleanProviderData) - - // 获取有效的 provider 名称 - const validProviderNames = new Set(cleanedProviders.map(p => p.name)) - - // 过滤掉引用已删除 provider 的模型 - const originalModels = (config.models as any[]) || [] - const filteredModels = originalModels.filter((model: any) => { - return validProviderNames.has(model.api_provider) - }) - - // 获取被削除的模型名称 - const deletedModelNames = new Set( - deleteConfirmState.affectedModels.map((m: any) => m.name) - ) - - // 从任务配置中移除这些模型 - const modelTaskConfig = config.model_task_config as any - if (modelTaskConfig) { - Object.keys(modelTaskConfig).forEach(taskName => { - const task = modelTaskConfig[taskName] - if (task && Array.isArray(task.model_list)) { - task.model_list = task.model_list.filter( - (modelName: string) => !deletedModelNames.has(modelName) - ) - } - }) - } - - // 更新配置 - config.api_providers = cleanedProviders - config.models = filteredModels - config.model_task_config = modelTaskConfig - - const resultUpdate = await updateModelConfig(config) - if (!resultUpdate.success) { - toast({ - title: '保存失败', - description: resultUpdate.error, - variant: 'destructive', - }) - savingFlag(false) - return - } - - // 更新本地状态 - setProviders(deleteConfirmState.pendingProviders) - setHasUnsavedChanges(false) - - toast({ - title: '删除成功', - description: `已删除 ${deleteConfirmState.providersToDelete.length} 个提供商和 ${deleteConfirmState.affectedModels.length} 个关联模型`, - }) - - // 清理状态 - setDeleteConfirmState({ - isOpen: false, - providersToDelete: [], - affectedModels: [], - pendingProviders: [], - context: 'auto', - oldProviders: [], - }) - setSelectedProviders(new Set()) // 清除选中状态(批量删除时) - - // 根据上下文执行后续操作 - if (deleteConfirmState.context === 'restart') { - // 如果是保存并重启,继续执行重启流程 - await handleRestart() - } - } catch (error) { - console.error('删除失败:', error) - toast({ - title: '删除失败', - description: (error as Error).message, - variant: 'destructive', - }) - } finally { - if (deleteConfirmState.context === 'auto') { - setAutoSaving(false) - } else { - setSaving(false) - } - } - } - - // 取消删除提供商 - const handleCancelDeleteProvider = () => { - // 恢复到删除前的 providers 状态 - if (deleteConfirmState.oldProviders.length > 0) { - setProviders(deleteConfirmState.oldProviders) - } - // 清理状态 - setDeleteConfirmState({ - isOpen: false, - providersToDelete: [], - affectedModels: [], - pendingProviders: [], - context: 'auto', - oldProviders: [], - }) - setHasUnsavedChanges(false) - } - - // 自动保存函数(使用增量 API) - const autoSaveProviders = useCallback(async (newProviders: APIProvider[]) => { - if (initialLoadRef.current) return // 初始加载时不自动保存 - - // 检查删除影响 - const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'auto') - - if (!shouldProceed) { - // 需要用户确认,对话框已打开 - setHasUnsavedChanges(true) - return - } - - try { - setAutoSaving(true) - // 清理 providers 数据:将 null 值转换为默认值 - const cleanedProviders = newProviders.map(cleanProviderData) - const result = await updateModelConfigSection('api_providers', cleanedProviders) - if (!result.success) { - console.error('自动保存失败:', result.error) - toast({ - title: '自动保存失败', - description: result.error, - variant: 'destructive', - }) - setHasUnsavedChanges(true) - return - } - setHasUnsavedChanges(false) - } catch (error) { - console.error('自动保存失败:', error) - toast({ - title: '自动保存失败', - description: (error as Error).message, - variant: 'destructive', - }) - setHasUnsavedChanges(true) - } finally { - setAutoSaving(false) - } - }, [providers, checkDeleteProviderImpact]) - - // 监听 providers 变化,触发自动保存(带防抖) - useEffect(() => { - if (initialLoadRef.current) return - - setHasUnsavedChanges(true) - - // 清除之前的定时器 - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current) - } - - // 设置新的定时器(2秒后自动保存) - autoSaveTimerRef.current = setTimeout(() => { - autoSaveProviders(providers) - }, 2000) - - // 清理函数 - return () => { - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current) - } - } - }, [providers, autoSaveProviders]) - - // 保存配置(手动保存,保存完整配置) - const saveConfig = async () => { - try { - setSaving(true) - - // 先取消自动保存定时器 - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current) - } - - // 清理 providers 数据:将 null 值转换为默认值 - const cleanedProviders = providers.map(cleanProviderData) - - // 检查删除提供商的影响 - const { shouldProceed } = await checkDeleteProviderImpact(cleanedProviders, 'manual') - if (!shouldProceed) { - // 需要用户确认,等待确认对话框 - setSaving(false) - return - } - - const resultGet = await getModelConfig() - if (!resultGet.success) { - toast({ - title: '保存失败', - description: resultGet.error, - variant: 'destructive', - }) - setSaving(false) - return - } - const config = resultGet.data - - // 获取所有有效的 provider 名称 - const validProviderNames = new Set(cleanedProviders.map(p => p.name)) - - // 过滤掉引用已删除 provider 的模型 - const originalModels = (config.models as any[]) || [] - const filteredModels = originalModels.filter((model: any) => { - const isValid = validProviderNames.has(model.api_provider) - if (!isValid) { - console.warn(`模型 "${model.name}" 引用了已删除的提供商 "${model.api_provider}"、将被移除`) - } - return isValid - }) - - // 如果有模型被移除、显示警告 - if (originalModels.length !== filteredModels.length) { - const removedCount = originalModels.length - filteredModels.length - toast({ - title: '注意', - description: `已自动移除 ${removedCount} 个引用已删除提供商的模型`, - variant: 'default', - }) - } - - console.log('发送的 providers 数据:', cleanedProviders) - config.api_providers = cleanedProviders - config.models = filteredModels - console.log('完整配置数据:', config) - - const resultUpdate = await updateModelConfig(config) - if (!resultUpdate.success) { - toast({ - title: '保存失败', - description: resultUpdate.error, - variant: 'destructive', - }) - setSaving(false) - return - } - setHasUnsavedChanges(false) - toast({ - title: '保存成功', - description: '模型提供商配置已保存', - }) - } catch (error) { - console.error('保存配置失败:', error) - toast({ - title: '保存失败', - description: (error as Error).message, - variant: 'destructive', - }) - } finally { - setSaving(false) - } - } - - // 打开编辑对话框 - const openEditDialog = (provider: APIProvider | null, index: number | null) => { - // 清除表单验证错误 - setFormErrors({}) - - if (provider) { - // 编辑现有提供商 - 检测匹配的模板 - const matchedTemplate = PROVIDER_TEMPLATES.find( - t => t.base_url === provider.base_url && t.client_type === provider.client_type - ) - setSelectedTemplate(matchedTemplate?.id || 'custom') - setEditingProvider(provider) - } else { - // 新建提供商 - 默认使用自定义模板 - setSelectedTemplate('custom') - setEditingProvider({ - name: '', - base_url: '', - api_key: '', - client_type: 'openai', - max_retry: 2, - timeout: 30, - retry_interval: 10, - }) - } - setEditingIndex(index) - setShowApiKey(false) - setEditDialogOpen(true) - } - - // 处理模板选择变化 - const handleTemplateChange = useCallback((templateId: string) => { - setSelectedTemplate(templateId) - setTemplateComboboxOpen(false) - const template = PROVIDER_TEMPLATES.find(t => t.id === templateId) - if (template && template.id !== 'custom') { - // 应用模板配置 - setEditingProvider(prev => ({ - ...prev!, - name: template.name, - base_url: template.base_url, - client_type: template.client_type, - })) - } else if (template?.id === 'custom') { - // 切换到自定义模板 - 清空URL和客户端类型(保留其他字段) - setEditingProvider(prev => ({ - ...prev!, - name: '', - base_url: '', - client_type: 'openai', - })) - } - }, []) - - // 判断当前是否使用模板(非自定义) - const isUsingTemplate = useMemo(() => { - return selectedTemplate !== 'custom' - }, [selectedTemplate]) - - // 复制 API Key - const copyApiKey = useCallback(async () => { - if (!editingProvider?.api_key) return - try { - await navigator.clipboard.writeText(editingProvider.api_key) - toast({ - title: '复制成功', - description: 'API Key 已复制到剪贴板', - }) - } catch { - toast({ - title: '复制失败', - description: '无法访问剪贴板', - variant: 'destructive', - }) - } - }, [editingProvider?.api_key, toast]) - - // 保存编辑 - const handleSaveEdit = () => { - if (!editingProvider) return - - // 验证必填项(传入现有提供商列表和当前编辑索引用于重复检查) - const { isValid, errors } = validateProvider(editingProvider, providers, editingIndex) - - if (!isValid) { - setFormErrors(errors) - return - } - - // 清除错误状态 - setFormErrors({}) - - // 填充空值的默认值 - const providerToSave = cleanProviderData(editingProvider) - - if (editingIndex !== null) { - // 更新现有提供商 - const newProviders = [...providers] - newProviders[editingIndex] = providerToSave - setProviders(newProviders) - } else { - // 添加新提供商 - setProviders([...providers, providerToSave]) - } - - setEditDialogOpen(false) - setEditingProvider(null) - setEditingIndex(null) - } - - // 处理编辑对话框关闭 - const handleEditDialogClose = (open: boolean) => { - if (!open && editingProvider) { - // 关闭时填充默认值 - const updatedProvider = { - ...editingProvider, - max_retry: editingProvider.max_retry ?? 2, - timeout: editingProvider.timeout ?? 30, - retry_interval: editingProvider.retry_interval ?? 10, - } - setEditingProvider(updatedProvider) - } - setEditDialogOpen(open) - } - - // 打开删除确认对话框 - const openDeleteDialog = (index: number) => { - setDeletingIndex(index) - setDeleteDialogOpen(true) - } - - // 确认删除提供商 - const handleConfirmDelete = async () => { - if (deletingIndex !== null) { - const newProviders = providers.filter((_, i) => i !== deletingIndex) - - // 检查删除影响 - const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'manual') - - if (shouldProceed) { - // 没有影响,直接删除 - setProviders(newProviders) - toast({ - title: '删除成功', - description: '提供商已从列表中移除', - }) - } - // 如果 shouldProceed = false,对话框会自动打开,等待用户确认 - } - setDeleteDialogOpen(false) - setDeletingIndex(null) - } - - // 切换单个提供商选择 - const toggleProviderSelection = (index: number) => { - const newSelected = new Set(selectedProviders) - if (newSelected.has(index)) { - newSelected.delete(index) - } else { - newSelected.add(index) - } - setSelectedProviders(newSelected) - } - - // 全选/取消全选 - const toggleSelectAll = () => { - if (selectedProviders.size === filteredProviders.length) { - setSelectedProviders(new Set()) - } else { - const allIndices = filteredProviders.map((_, idx) => - providers.findIndex(p => p === filteredProviders[idx]) - ) - setSelectedProviders(new Set(allIndices)) - } - } - - // 打开批量删除确认对话框 - const openBatchDeleteDialog = () => { - if (selectedProviders.size === 0) { - toast({ - title: '提示', - description: '请先选择要删除的提供商', - variant: 'default', - }) - return - } - setBatchDeleteDialogOpen(true) - } - - // 确认批量删除 - const handleConfirmBatchDelete = async () => { - const newProviders = providers.filter((_, index) => !selectedProviders.has(index)) - - // 检查删除影响 - const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'manual') - - if (shouldProceed) { - // 没有影响,直接删除 - setProviders(newProviders) - setSelectedProviders(new Set()) - toast({ - title: '批量删除成功', - description: `已删除 ${selectedProviders.size} 个提供商`, - }) - } - // 如果 shouldProceed = false,对话框会自动打开,等待用户确认 - - setBatchDeleteDialogOpen(false) - } - - // 过滤提供商列表(使用 useMemo 优化性能) - const filteredProviders = useMemo(() => { - if (!searchQuery) return providers - const query = searchQuery.toLowerCase() - return providers.filter((provider) => ( - provider.name.toLowerCase().includes(query) || - provider.base_url.toLowerCase().includes(query) || - provider.client_type.toLowerCase().includes(query) - )) - }, [providers, searchQuery]) - - // 分页逻辑(使用 useMemo 优化性能) - const { totalPages, paginatedProviders } = useMemo(() => { - const total = Math.ceil(filteredProviders.length / pageSize) - const paginated = filteredProviders.slice( - (page - 1) * pageSize, - page * pageSize - ) - return { totalPages: total, paginatedProviders: paginated } - }, [filteredProviders, page, pageSize]) - - // 页码跳转 - const handleJumpToPage = useCallback(() => { - const targetPage = parseInt(jumpToPage) - if (targetPage >= 1 && targetPage <= totalPages) { - setPage(targetPage) - setJumpToPage('') - } - }, [jumpToPage, totalPages]) - - // 测试单个提供商连接 - const handleTestConnection = async (providerName: string) => { - // 标记正在测试 - setTestingProviders(prev => new Set(prev).add(providerName)) - - try { - const result = await testProviderConnection(providerName) - if (!result.success) { - toast({ - title: '测试失败', - description: result.error, - variant: 'destructive', - }) - return - } - const testResult = result.data - setTestResults(prev => new Map(prev).set(providerName, testResult)) - - // 显示结果 toast - if (testResult.network_ok) { - if (testResult.api_key_valid === true) { - toast({ - title: '连接正常', - description: `${providerName} 网络连接正常、API Key 有效 (${testResult.latency_ms}ms)`, - }) - } else if (testResult.api_key_valid === false) { - toast({ - title: '连接正常但 Key 无效', - description: `${providerName} 网络连接正常、但 API Key 无效或已过期`, - variant: 'destructive', - }) - } else { - toast({ - title: '网络连接正常', - description: `${providerName} 可以访问 (${testResult.latency_ms}ms)`, - }) - } - } else { - toast({ - title: '连接失败', - description: testResult.error || '无法连接到提供商', - variant: 'destructive', - }) - } - } catch (error) { - toast({ - title: '测试失败', - description: (error as Error).message, - variant: 'destructive', - }) - } finally { - setTestingProviders(prev => { - const newSet = new Set(prev) - newSet.delete(providerName) - return newSet - }) - } - } - - // 批量测试所有提供商 - const handleTestAllConnections = async () => { - for (const provider of providers) { - await handleTestConnection(provider.name) - } - } - - // 渲染测试状态指示器 - const renderTestStatus = (providerName: string) => { - const isTesting = testingProviders.has(providerName) - const result = testResults.get(providerName) - - if (isTesting) { - return ( - - - 测试中 - - ) - } - - if (!result) return null - - if (result.network_ok) { - if (result.api_key_valid === true) { - return ( - - - 正常 - - ) - } else if (result.api_key_valid === false) { - return ( - - - Key无效 - - ) - } else { - return ( - - - 可访问 - - ) - } - } else { - return ( - - - 离线 - - ) - } - } - - if (loading) { - return ( -
-
-

加载中...

-
-
- ) - } - - return ( -
- {/* 页面标题 */} -
-
-

AI模型厂商配置

-

管理 AI 模型厂商的 API 配置

-
-
- {selectedProviders.size > 0 && ( - - )} - - - - - - - - - - 确认重启麦麦? - -
-

- {hasUnsavedChanges - ? '当前有未保存的配置更改。点击确认将先保存配置,然后重启麦麦使新配置生效。重启过程中麦麦将暂时离线。' - : '即将重启麦麦主程序。重启过程中麦麦将暂时离线,配置将在重启后生效。' - } -

-
-
-
- - 取消 - - {hasUnsavedChanges ? '保存并重启' : '确认重启'} - - -
-
-
-
- - {/* 重启提示 */} - - - - 配置更新后需要重启麦麦才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。 - - - - - {/* 搜索框 */} -
-
- - setSearchQuery(e.target.value)} - className="pl-9" - /> -
- {searchQuery && ( -

- 找到 {filteredProviders.length} 个结果 -

- )} -
- - {/* 提供商列表 - 移动端卡片视图 */} -
- {filteredProviders.length === 0 ? ( -
- {searchQuery ? '未找到匹配的提供商' : '暂无提供商配置,点击"添加提供商"开始配置'} -
- ) : ( - paginatedProviders.map((provider, displayIndex) => { - const actualIndex = providers.findIndex(p => p === provider) - return ( -
-
-
-
-

{provider.name}

- {renderTestStatus(provider.name)} -
-

{provider.base_url}

-
-
- - - -
-
-
-
- 客户端类型 -

{provider.client_type}

-
-
- 最大重试 -

{provider.max_retry}

-
-
- 超时(秒) -

{provider.timeout}

-
-
- 重试间隔(秒) -

{provider.retry_interval}

-
-
-
- ) - }) - )} -
- - {/* 提供商列表 - 桌面端表格视图 */} -
-
- - - - - 0} - onCheckedChange={toggleSelectAll} - /> - - 状态 - 名称 - 基础URL - 客户端类型 - 最大重试 - 超时(秒) - 重试间隔(秒) - 操作 - - - - {paginatedProviders.length === 0 ? ( - - - {searchQuery ? '未找到匹配的提供商' : '暂无提供商配置,点击"添加提供商"开始配置'} - - - ) : ( - paginatedProviders.map((provider, displayIndex) => { - const actualIndex = providers.findIndex(p => p === provider) - return ( - - - toggleProviderSelection(actualIndex)} - /> - - - {renderTestStatus(provider.name) || ( - - 未测试 - - )} - - {provider.name} - - {provider.base_url} - - {provider.client_type} - {provider.max_retry} - {provider.timeout} - {provider.retry_interval} - -
- - - -
-
-
- ) - }) - )} -
-
-
-
- - {/* 分页 - 增强版 */} - {filteredProviders.length > 0 && ( -
-
- - - - 显示 {(page - 1) * pageSize + 1} 到{' '} - {Math.min(page * pageSize, filteredProviders.length)} 条,共 {filteredProviders.length} 条 - -
-
- - -
- setJumpToPage(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleJumpToPage()} - placeholder={page.toString()} - className="w-16 h-8 text-center" - min={1} - max={totalPages} - /> - -
- - -
-
- )} -
- - {/* 编辑对话框 */} - - - - - {editingIndex !== null ? '编辑提供商' : '添加提供商'} - - - 配置 API 提供商的连接信息和参数 - - - -
{ e.preventDefault(); handleSaveEdit(); }} autoComplete="off"> -
-
- - - - - - - - - - - 未找到匹配的模板 - - {PROVIDER_TEMPLATES.map((template) => ( - handleTemplateChange(template.id)} - > - - {template.display_name} - - ))} - - - - - - -

- 选择预设模板可自动填充 URL 和客户端类型,支持搜索 -

-
- -
-
- - -

提供商名称

-

为这个 API 提供商设置一个便于识别的名称,用于在模型配置中引用。

-
    -
  • 推荐使用厂商官方名称,如 DeepSeek、OpenAI
  • -
  • 名称需要唯一,不能与现有提供商重复
  • -
-
- } - side="right" - maxWidth="350px" - /> -
- { - setEditingProvider((prev) => - prev ? { ...prev, name: e.target.value } : null - ) - if (formErrors.name) { - setFormErrors((prev) => ({ ...prev, name: undefined })) - } - }} - placeholder="例如: DeepSeek, SiliconFlow" - className={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''} - /> - {formErrors.name && ( -

{formErrors.name}

- )} -
- -
-
- - -

API 基础地址

-

提供商的 API 端点基础 URL,通常以 /v1 结尾。

-
    -
  • OpenAI 格式:https://api.openai.com/v1
  • -
  • DeepSeek:https://api.deepseek.com
  • -
  • 硅基流动:https://api.siliconflow.cn/v1
  • -
  • 选择模板会自动填充正确的 URL
  • -
-
- } - side="right" - maxWidth="400px" - /> -
- { - setEditingProvider((prev) => - prev ? { ...prev, base_url: e.target.value } : null - ) - if (formErrors.base_url) { - setFormErrors((prev) => ({ ...prev, base_url: undefined })) - } - }} - placeholder="https://api.example.com/v1" - disabled={isUsingTemplate} - className={`${isUsingTemplate ? 'bg-muted cursor-not-allowed' : ''} ${formErrors.base_url ? 'border-destructive focus-visible:ring-destructive' : ''}`} - /> - {formErrors.base_url && ( -

{formErrors.base_url}

- )} - {isUsingTemplate && !formErrors.base_url && ( -

- 使用模板时 URL 不可编辑,切换到"自定义"以手动配置 -

- )} -
- -
-
- - -

API 密钥

-

从提供商平台获取的身份验证密钥。

-
    -
  • 通常以 sk- 开头
  • -
  • 请妥善保管,不要泄露给他人
  • -
  • 可以点击眼睛图标切换显示/隐藏
  • -
  • 点击复制图标可快速复制密钥
  • -
-
- } - side="right" - maxWidth="350px" - /> -
-
- { - setEditingProvider((prev) => - prev ? { ...prev, api_key: e.target.value } : null - ) - if (formErrors.api_key) { - setFormErrors((prev) => ({ ...prev, api_key: undefined })) - } - }} - placeholder="sk-..." - className={`flex-1 ${formErrors.api_key ? 'border-destructive focus-visible:ring-destructive' : ''}`} - /> - - -
- {formErrors.api_key && ( -

{formErrors.api_key}

- )} - - -
-
- - -

API 客户端类型

-

指定与提供商通信时使用的 API 协议格式。

-
    -
  • OpenAI:兼容 OpenAI API 格式的提供商
  • -
  • Gemini:Google Gemini 专用格式
  • -
  • 大部分第三方提供商都兼容 OpenAI 格式
  • -
-
- } - side="right" - maxWidth="350px" - /> -
- - {isUsingTemplate && ( -

- 使用模板时客户端类型不可编辑,切换到"自定义"以手动配置 -

- )} - - -
-
-
- - -
- { - const val = e.target.value === '' ? null : parseInt(e.target.value) - setEditingProvider((prev) => - prev ? { ...prev, max_retry: val } : null - ) - }} - placeholder="默认: 2" - /> -
- -
-
- - -
- { - const val = e.target.value === '' ? null : parseInt(e.target.value) - setEditingProvider((prev) => - prev ? { ...prev, timeout: val } : null - ) - }} - placeholder="默认: 30" - /> -
- -
-
- - -
- { - const val = e.target.value === '' ? null : parseInt(e.target.value) - setEditingProvider((prev) => - prev - ? { ...prev, retry_interval: val } - : null - ) - }} - placeholder="默认: 10" - /> -
-
- - - - - - - - - - - {/* 删除确认对话框 */} - - - - 确认删除 - - 确定要删除提供商 "{deletingIndex !== null ? providers[deletingIndex]?.name : ''}" 吗? - 此操作无法撤销。 - - - - 取消 - 删除 - - - - - {/* 批量删除确认对话框 */} - - - - 确认批量删除 - - 确定要删除选中的 {selectedProviders.size} 个提供商吗? - 此操作无法撤销。 - - - - 取消 - - 批量删除 - - - - - - {/* 删除提供商影响确认对话框 */} - setDeleteConfirmState(prev => ({ ...prev, isOpen: open }))}> - - - 确认删除提供商 - -
-

- 您即将删除以下提供商: - - {deleteConfirmState.providersToDelete.join(', ')} - -

-

- ⚠️ 此操作将同时删除 {deleteConfirmState.affectedModels.length} 个关联的模型: -

- -
- {deleteConfirmState.affectedModels.map((model: any, idx: number) => ( -
- - {model.name} - - ({model.model_identifier}) - -
- ))} -
-
-

- 这些模型将从模型列表和所有任务分配中移除。此操作无法撤销。 -

-
-
-
- - 取消 - - 确认删除 - - -
-
- - {/* 重启遮罩层 */} - - - ) -} diff --git a/dashboard/src/routes/config/modelProvider/ProviderCard.tsx b/dashboard/src/routes/config/modelProvider/ProviderCard.tsx new file mode 100644 index 00000000..91319b5a --- /dev/null +++ b/dashboard/src/routes/config/modelProvider/ProviderCard.tsx @@ -0,0 +1,136 @@ +import type { TestConnectionResult } from '@/lib/config-api' +import { AlertCircle, CheckCircle2, Loader2, Pencil, Trash2, XCircle, Zap } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' + +import type { APIProvider } from './types' + +interface ProviderCardProps { + provider: APIProvider + actualIndex: number + testingProviders: Set + testResults: Map + onEdit: (provider: APIProvider, index: number) => void + onDelete: (index: number) => void + onTest: (name: string) => void +} + +export function ProviderCard({ + provider, + actualIndex, + testingProviders, + testResults, + onEdit, + onDelete, + onTest, +}: ProviderCardProps) { + const renderTestStatus = () => { + const isTesting = testingProviders.has(provider.name) + const result = testResults.get(provider.name) + + if (isTesting) { + return ( + + + 测试中 + + ) + } + + if (!result) return null + + if (result.network_ok) { + if (result.api_key_valid === true) { + return ( + + + 正常 + + ) + } else if (result.api_key_valid === false) { + return ( + + + Key无效 + + ) + } else { + return ( + + + 可访问 + + ) + } + } else { + return ( + + + 离线 + + ) + } + } + + return ( +
+
+
+
+

{provider.name}

+ {renderTestStatus()} +
+

{provider.base_url}

+
+
+ + + +
+
+
+
+ 客户端类型 +

{provider.client_type}

+
+
+ 最大重试 +

{provider.max_retry}

+
+
+ 超时(秒) +

{provider.timeout}

+
+
+ 重试间隔(秒) +

{provider.retry_interval}

+
+
+
+ ) +} diff --git a/dashboard/src/routes/config/modelProvider/ProviderForm.tsx b/dashboard/src/routes/config/modelProvider/ProviderForm.tsx new file mode 100644 index 00000000..81a9adad --- /dev/null +++ b/dashboard/src/routes/config/modelProvider/ProviderForm.tsx @@ -0,0 +1,458 @@ +import { useCallback, useMemo, useState } from 'react' +import { Check, ChevronsUpDown, Copy, Eye, EyeOff } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { HelpTooltip } from '@/components/ui/help-tooltip' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { useToast } from '@/hooks/use-toast' + +import { PROVIDER_TEMPLATES } from '../providerTemplates' +import type { APIProvider, FormErrors } from './types' +import { validateProvider } from './utils' + +interface ProviderFormProps { + open: boolean + onOpenChange: (open: boolean) => void + editingProvider: APIProvider | null + editingIndex: number | null + providers: APIProvider[] + onSave: (provider: APIProvider, index: number | null) => void + tourState: { isRunning: boolean } +} + +export function ProviderForm({ + open, + onOpenChange, + editingProvider, + editingIndex, + providers, + onSave, + tourState, +}: ProviderFormProps) { + const [formErrors, setFormErrors] = useState({}) + const [selectedTemplate, setSelectedTemplate] = useState('custom') + const [templateComboboxOpen, setTemplateComboboxOpen] = useState(false) + const [showApiKey, setShowApiKey] = useState(false) + const [localProvider, setLocalProvider] = useState(editingProvider) + const { toast } = useToast() + + // 同步外部状态到本地 + if (editingProvider !== localProvider && open) { + setLocalProvider(editingProvider) + setFormErrors({}) + setShowApiKey(false) + + // 检测匹配的模板 + if (editingProvider) { + const matchedTemplate = PROVIDER_TEMPLATES.find( + t => t.base_url === editingProvider.base_url && t.client_type === editingProvider.client_type + ) + setSelectedTemplate(matchedTemplate?.id || 'custom') + } else { + setSelectedTemplate('custom') + } + } + + const isUsingTemplate = useMemo(() => selectedTemplate !== 'custom', [selectedTemplate]) + + const handleTemplateChange = useCallback((templateId: string) => { + setSelectedTemplate(templateId) + setTemplateComboboxOpen(false) + const template = PROVIDER_TEMPLATES.find(t => t.id === templateId) + if (template && template.id !== 'custom') { + setLocalProvider(prev => ({ + ...prev!, + name: template.name, + base_url: template.base_url, + client_type: template.client_type, + })) + } else if (template?.id === 'custom') { + setLocalProvider(prev => ({ + ...prev!, + name: '', + base_url: '', + client_type: 'openai', + })) + } + }, []) + + const copyApiKey = useCallback(async () => { + if (!localProvider?.api_key) return + try { + await navigator.clipboard.writeText(localProvider.api_key) + toast({ + title: '复制成功', + description: 'API Key 已复制到剪贴板', + }) + } catch { + toast({ + title: '复制失败', + description: '无法访问剪贴板', + variant: 'destructive', + }) + } + }, [localProvider?.api_key, toast]) + + const handleSaveEdit = () => { + if (!localProvider) return + + const { isValid, errors } = validateProvider(localProvider, providers, editingIndex) + + if (!isValid) { + setFormErrors(errors) + return + } + + setFormErrors({}) + onSave(localProvider, editingIndex) + } + + return ( + + + + + {editingIndex !== null ? '编辑提供商' : '添加提供商'} + + + 配置 API 提供商的连接信息和参数 + + + +
{ e.preventDefault(); handleSaveEdit(); }} autoComplete="off"> +
+
+ + + + + + + + + + + 未找到匹配的模板 + + {PROVIDER_TEMPLATES.map((template) => ( + handleTemplateChange(template.id)} + > + + {template.display_name} + + ))} + + + + + + +

+ 选择预设模板可自动填充 URL 和客户端类型,支持搜索 +

+
+ +
+
+ + +

提供商名称

+

为这个 API 提供商设置一个便于识别的名称,用于在模型配置中引用。

+
    +
  • 推荐使用厂商官方名称,如 DeepSeek、OpenAI
  • +
  • 名称需要唯一,不能与现有提供商重复
  • +
+
+ } + side="right" + maxWidth="350px" + /> +
+ { + setLocalProvider((prev) => + prev ? { ...prev, name: e.target.value } : null + ) + if (formErrors.name) { + setFormErrors((prev) => ({ ...prev, name: undefined })) + } + }} + placeholder="例如: DeepSeek, SiliconFlow" + className={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''} + /> + {formErrors.name && ( +

{formErrors.name}

+ )} +
+ +
+
+ + +

API 基础地址

+

提供商的 API 端点基础 URL,通常以 /v1 结尾。

+
    +
  • OpenAI 格式:https://api.openai.com/v1
  • +
  • DeepSeek:https://api.deepseek.com
  • +
  • 硅基流动:https://api.siliconflow.cn/v1
  • +
  • 选择模板会自动填充正确的 URL
  • +
+
+ } + side="right" + maxWidth="400px" + /> +
+ { + setLocalProvider((prev) => + prev ? { ...prev, base_url: e.target.value } : null + ) + if (formErrors.base_url) { + setFormErrors((prev) => ({ ...prev, base_url: undefined })) + } + }} + placeholder="https://api.example.com/v1" + disabled={isUsingTemplate} + className={`${isUsingTemplate ? 'bg-muted cursor-not-allowed' : ''} ${formErrors.base_url ? 'border-destructive focus-visible:ring-destructive' : ''}`} + /> + {formErrors.base_url && ( +

{formErrors.base_url}

+ )} + {isUsingTemplate && !formErrors.base_url && ( +

+ 使用模板时 URL 不可编辑,切换到"自定义"以手动配置 +

+ )} + + +
+
+ + +

API 密钥

+

从提供商平台获取的身份验证密钥。

+
    +
  • 通常以 sk- 开头
  • +
  • 请妥善保管,不要泄露给他人
  • +
  • 可以点击眼睛图标切换显示/隐藏
  • +
  • 点击复制图标可快速复制密钥
  • +
+
+ } + side="right" + maxWidth="350px" + /> +
+
+ { + setLocalProvider((prev) => + prev ? { ...prev, api_key: e.target.value } : null + ) + if (formErrors.api_key) { + setFormErrors((prev) => ({ ...prev, api_key: undefined })) + } + }} + placeholder="sk-..." + className={`flex-1 ${formErrors.api_key ? 'border-destructive focus-visible:ring-destructive' : ''}`} + /> + + +
+ {formErrors.api_key && ( +

{formErrors.api_key}

+ )} + + +
+
+ + +

API 客户端类型

+

指定与提供商通信时使用的 API 协议格式。

+
    +
  • OpenAI:兼容 OpenAI API 格式的提供商
  • +
  • Gemini:Google Gemini 专用格式
  • +
  • 大部分第三方提供商都兼容 OpenAI 格式
  • +
+
+ } + side="right" + maxWidth="350px" + /> +
+ + {isUsingTemplate && ( +

+ 使用模板时客户端类型不可编辑,切换到"自定义"以手动配置 +

+ )} + + +
+
+
+ + +
+ { + const val = e.target.value === '' ? null : parseInt(e.target.value) + setLocalProvider((prev) => + prev ? { ...prev, max_retry: val } : null + ) + }} + placeholder="默认: 2" + /> +
+ +
+
+ + +
+ { + const val = e.target.value === '' ? null : parseInt(e.target.value) + setLocalProvider((prev) => + prev ? { ...prev, timeout: val } : null + ) + }} + placeholder="默认: 30" + /> +
+ +
+
+ + +
+ { + const val = e.target.value === '' ? null : parseInt(e.target.value) + setLocalProvider((prev) => + prev + ? { ...prev, retry_interval: val } + : null + ) + }} + placeholder="默认: 10" + /> +
+
+ + + + + + +
+
+
+ ) +} diff --git a/dashboard/src/routes/config/modelProvider/ProviderList.tsx b/dashboard/src/routes/config/modelProvider/ProviderList.tsx new file mode 100644 index 00000000..8b31008a --- /dev/null +++ b/dashboard/src/routes/config/modelProvider/ProviderList.tsx @@ -0,0 +1,353 @@ +import { useCallback, useMemo, useState } from 'react' +import type { TestConnectionResult } from '@/lib/config-api' +import { AlertCircle, CheckCircle2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2, Pencil, Search, Trash2, XCircle, Zap } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' + +import { ProviderCard } from './ProviderCard' +import type { APIProvider } from './types' + +interface ProviderListProps { + providers: APIProvider[] + testingProviders: Set + testResults: Map + selectedProviders: Set + onEdit: (provider: APIProvider, index: number) => void + onDelete: (index: number) => void + onTest: (name: string) => void + onToggleSelect: (index: number) => void + onToggleSelectAll: () => void +} + +export function ProviderList({ + providers, + testingProviders, + testResults, + selectedProviders, + onEdit, + onDelete, + onTest, + onToggleSelect, + onToggleSelectAll, +}: ProviderListProps) { + const [searchQuery, setSearchQuery] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [jumpToPage, setJumpToPage] = useState('') + + const filteredProviders = useMemo(() => { + if (!searchQuery) return providers + const query = searchQuery.toLowerCase() + return providers.filter((provider) => ( + provider.name.toLowerCase().includes(query) || + provider.base_url.toLowerCase().includes(query) || + provider.client_type.toLowerCase().includes(query) + )) + }, [providers, searchQuery]) + + const { totalPages, paginatedProviders } = useMemo(() => { + const total = Math.ceil(filteredProviders.length / pageSize) + const paginated = filteredProviders.slice( + (page - 1) * pageSize, + page * pageSize + ) + return { totalPages: total, paginatedProviders: paginated } + }, [filteredProviders, page, pageSize]) + + const handleJumpToPage = useCallback(() => { + const targetPage = parseInt(jumpToPage) + if (targetPage >= 1 && targetPage <= totalPages) { + setPage(targetPage) + setJumpToPage('') + } + }, [jumpToPage, totalPages]) + + const renderTestStatus = (providerName: string) => { + const isTesting = testingProviders.has(providerName) + const result = testResults.get(providerName) + + if (isTesting) { + return ( + + + 测试中 + + ) + } + + if (!result) { + return ( + + 未测试 + + ) + } + + if (result.network_ok) { + if (result.api_key_valid === true) { + return ( + + + 正常 + + ) + } else if (result.api_key_valid === false) { + return ( + + + Key无效 + + ) + } else { + return ( + + + 可访问 + + ) + } + } else { + return ( + + + 离线 + + ) + } + } + + return ( + <> + {/* 搜索框 */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ {searchQuery && ( +

+ 找到 {filteredProviders.length} 个结果 +

+ )} +
+ + {/* 移动端卡片视图 */} +
+ {filteredProviders.length === 0 ? ( +
+ {searchQuery ? '未找到匹配的提供商' : '暂无提供商配置,点击"添加提供商"开始配置'} +
+ ) : ( + paginatedProviders.map((provider, displayIndex) => { + const actualIndex = providers.findIndex(p => p === provider) + return ( + + ) + }) + )} +
+ + {/* 桌面端表格视图 */} +
+
+ + + + + 0} + onCheckedChange={onToggleSelectAll} + /> + + 状态 + 名称 + 基础URL + 客户端类型 + 最大重试 + 超时(秒) + 重试间隔(秒) + 操作 + + + + {paginatedProviders.length === 0 ? ( + + + {searchQuery ? '未找到匹配的提供商' : '暂无提供商配置,点击"添加提供商"开始配置'} + + + ) : ( + paginatedProviders.map((provider, displayIndex) => { + const actualIndex = providers.findIndex(p => p === provider) + return ( + + + onToggleSelect(actualIndex)} + /> + + + {renderTestStatus(provider.name)} + + {provider.name} + + {provider.base_url} + + {provider.client_type} + {provider.max_retry} + {provider.timeout} + {provider.retry_interval} + +
+ + + +
+
+
+ ) + }) + )} +
+
+
+
+ + {/* 分页 */} + {filteredProviders.length > 0 && ( +
+
+ + + + 显示 {(page - 1) * pageSize + 1} 到{' '} + {Math.min(page * pageSize, filteredProviders.length)} 条,共 {filteredProviders.length} 条 + +
+
+ + +
+ setJumpToPage(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleJumpToPage()} + placeholder={page.toString()} + className="w-16 h-8 text-center" + min={1} + max={totalPages} + /> + +
+ + +
+
+ )} + + ) +} diff --git a/dashboard/src/routes/config/modelProvider/index.ts b/dashboard/src/routes/config/modelProvider/index.ts index 43f86993..7d37993e 100644 --- a/dashboard/src/routes/config/modelProvider/index.ts +++ b/dashboard/src/routes/config/modelProvider/index.ts @@ -1,11 +1,2 @@ -/** - * 模型提供商配置模块 - * - * 模块结构: - * - types.ts: 类型定义 - * - utils.ts: 工具函数 - * - 主组件在上级目录的 modelProvider.tsx - */ - -export * from './types' -export * from './utils' +export { ModelProviderConfigPage } from './index.tsx' +export type * from './types' diff --git a/dashboard/src/routes/config/modelProvider/index.tsx b/dashboard/src/routes/config/modelProvider/index.tsx new file mode 100644 index 00000000..00943ccf --- /dev/null +++ b/dashboard/src/routes/config/modelProvider/index.tsx @@ -0,0 +1,895 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { getModelConfig, testProviderConnection, updateModelConfig, updateModelConfigSection } from '@/lib/config-api' +import type { TestConnectionResult } from '@/lib/config-api' +import { Info, Plus, Power, Save, Trash2, Zap } from 'lucide-react' + +import { Alert, AlertDescription } from '@/components/ui/alert' +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { ScrollArea } from '@/components/ui/scroll-area' +import { MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps, STEP_ROUTE_MAP } from '@/components/tour/tours/model-assignment-tour' +import { useTour } from '@/components/tour' +import { useToast } from '@/hooks/use-toast' +import { RestartOverlay } from '@/components/restart-overlay' +import { RestartProvider, useRestart } from '@/lib/restart-context' + +import { ProviderForm } from './ProviderForm' +import { ProviderList } from './ProviderList' +import type { APIProvider, DeleteConfirmState } from './types' +import { cleanProviderData } from './utils' + +export function ModelProviderConfigPage() { + return ( + + + + ) +} + +function ModelProviderConfigPageContent() { + const [providers, setProviders] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [autoSaving, setAutoSaving] = useState(false) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [editDialogOpen, setEditDialogOpen] = useState(false) + const [editingProvider, setEditingProvider] = useState(null) + const [editingIndex, setEditingIndex] = useState(null) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [deletingIndex, setDeletingIndex] = useState(null) + const [selectedProviders, setSelectedProviders] = useState>(new Set()) + const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false) + const [deleteConfirmState, setDeleteConfirmState] = useState({ + isOpen: false, + providersToDelete: [], + affectedModels: [], + pendingProviders: [], + context: 'auto', + oldProviders: [], + }) + const [testingProviders, setTestingProviders] = useState>(new Set()) + const [testResults, setTestResults] = useState>(new Map()) + + const { toast } = useToast() + const navigate = useNavigate() + const { state: tourState, goToStep, registerTour } = useTour() + const { triggerRestart, isRestarting } = useRestart() + + const autoSaveTimerRef = useRef | null>(null) + const initialLoadRef = useRef(true) + const prevTourStepRef = useRef(tourState.stepIndex) + + // 注册 Tour + useEffect(() => { + registerTour(MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps) + }, [registerTour]) + + // 监听 Tour 步骤变化,处理页面导航 + useEffect(() => { + if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) { + const targetRoute = STEP_ROUTE_MAP[tourState.stepIndex] + if (targetRoute && !window.location.pathname.endsWith(targetRoute.replace('/config/', ''))) { + navigate({ to: targetRoute }) + } + } + }, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning, navigate]) + + // 监听 Tour 步骤变化,处理弹窗的打开和关闭 + useEffect(() => { + if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) { + const prevStep = prevTourStepRef.current + const currentStep = tourState.stepIndex + + if (prevStep >= 3 && prevStep <= 9 && currentStep < 3) { + setEditDialogOpen(false) + } + + if (prevStep >= 10 && currentStep >= 3 && currentStep <= 9) { + setEditingProvider({ + name: '', + base_url: '', + api_key: '', + client_type: 'openai', + max_retry: 2, + timeout: 30, + retry_interval: 10, + }) + setEditingIndex(null) + setEditDialogOpen(true) + } + + prevTourStepRef.current = currentStep + } + }, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning]) + + // 处理 Tour 中需要用户点击才能继续的步骤 + useEffect(() => { + if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) return + + const handleTourClick = (e: MouseEvent) => { + const target = e.target as HTMLElement + const currentStep = tourState.stepIndex + + if (currentStep === 2 && target.closest('[data-tour="add-provider-button"]')) { + setTimeout(() => goToStep(3), 300) + } else if (currentStep === 9 && target.closest('[data-tour="provider-cancel-button"]')) { + setTimeout(() => goToStep(10), 300) + } + } + + document.addEventListener('click', handleTourClick, true) + return () => document.removeEventListener('click', handleTourClick, true) + }, [tourState, goToStep]) + + // 加载配置 + useEffect(() => { + loadConfig() + }, []) + + const loadConfig = async () => { + try { + setLoading(true) + const result = await getModelConfig() + if (!result.success) { + toast({ + title: '加载失败', + description: result.error, + variant: 'destructive', + }) + setLoading(false) + return + } + const config = result.data + setProviders((config.api_providers as APIProvider[]) || []) + setHasUnsavedChanges(false) + initialLoadRef.current = false + } catch (error) { + console.error('加载配置失败:', error) + } finally { + setLoading(false) + } + } + + const handleRestart = async () => { + await triggerRestart() + } + + const handleSaveAndRestart = async () => { + try { + setSaving(true) + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + + const cleanedProviders = providers.map(provider => ({ + ...provider, + max_retry: provider.max_retry ?? 2, + timeout: provider.timeout ?? 30, + retry_interval: provider.retry_interval ?? 10, + })) + + const { shouldProceed } = await checkDeleteProviderImpact(cleanedProviders, 'restart') + if (!shouldProceed) { + setSaving(false) + return + } + + const resultGet = await getModelConfig() + if (!resultGet.success) { + toast({ + title: '保存失败', + description: resultGet.error, + variant: 'destructive', + }) + setSaving(false) + return + } + const config = resultGet.data + + const validProviderNames = new Set(cleanedProviders.map(p => p.name)) + const originalModels = (config.models as any[]) || [] + const filteredModels = originalModels.filter((model: any) => { + return validProviderNames.has(model.api_provider) + }) + + config.api_providers = cleanedProviders + config.models = filteredModels + + const resultUpdate = await updateModelConfig(config) + if (!resultUpdate.success) { + toast({ + title: '保存失败', + description: resultUpdate.error, + variant: 'destructive', + }) + setSaving(false) + return + } + setHasUnsavedChanges(false) + toast({ + title: '保存成功', + description: '正在重启麦麦...', + }) + await handleRestart() + } catch (error) { + console.error('保存配置失败:', error) + toast({ + title: '保存失败', + description: (error as Error).message, + variant: 'destructive', + }) + setSaving(false) + } + } + + const checkDeleteProviderImpact = useCallback(async ( + newProviders: APIProvider[], + context: 'auto' | 'manual' | 'restart' = 'auto' + ) => { + try { + const result = await getModelConfig() + if (!result.success) { + console.error('加载配置失败:', result.error) + return { shouldProceed: true, providers: newProviders } + } + const config = result.data + const oldProviderNames = new Set(providers.map(p => p.name)) + const newProviderNames = new Set(newProviders.map(p => p.name)) + + const deletedProviders = Array.from(oldProviderNames).filter( + name => !newProviderNames.has(name) + ) + + if (deletedProviders.length === 0) { + return { shouldProceed: true, providers: newProviders } + } + + const models = (config.models as any[]) || [] + const affected = models.filter((m: any) => + deletedProviders.includes(m.api_provider) + ) + + if (affected.length === 0) { + return { shouldProceed: true, providers: newProviders } + } + + setDeleteConfirmState({ + isOpen: true, + providersToDelete: deletedProviders, + affectedModels: affected, + pendingProviders: newProviders, + context, + oldProviders: [...providers], + }) + + return { shouldProceed: false, providers: newProviders } + } catch (error) { + console.error('检查删除影响失败:', error) + return { shouldProceed: true, providers: newProviders } + } + }, [providers]) + + const handleConfirmDeleteProvider = async () => { + try { + const savingFlag = deleteConfirmState.context === 'auto' ? setAutoSaving : setSaving + savingFlag(true) + + setDeleteConfirmState(prev => ({ ...prev, isOpen: false })) + + const resultGet = await getModelConfig() + if (!resultGet.success) { + toast({ + title: '加载失败', + description: resultGet.error, + variant: 'destructive', + }) + savingFlag(false) + return + } + const config = resultGet.data + + const cleanedProviders = deleteConfirmState.pendingProviders.map(cleanProviderData) + const validProviderNames = new Set(cleanedProviders.map(p => p.name)) + const originalModels = (config.models as any[]) || [] + const filteredModels = originalModels.filter((model: any) => { + return validProviderNames.has(model.api_provider) + }) + + const deletedModelNames = new Set( + deleteConfirmState.affectedModels.map((m: any) => m.name) + ) + + const modelTaskConfig = config.model_task_config as any + if (modelTaskConfig) { + Object.keys(modelTaskConfig).forEach(taskName => { + const task = modelTaskConfig[taskName] + if (task && Array.isArray(task.model_list)) { + task.model_list = task.model_list.filter( + (modelName: string) => !deletedModelNames.has(modelName) + ) + } + }) + } + + config.api_providers = cleanedProviders + config.models = filteredModels + config.model_task_config = modelTaskConfig + + const resultUpdate = await updateModelConfig(config) + if (!resultUpdate.success) { + toast({ + title: '保存失败', + description: resultUpdate.error, + variant: 'destructive', + }) + savingFlag(false) + return + } + + setProviders(deleteConfirmState.pendingProviders) + setHasUnsavedChanges(false) + + toast({ + title: '删除成功', + description: `已删除 ${deleteConfirmState.providersToDelete.length} 个提供商和 ${deleteConfirmState.affectedModels.length} 个关联模型`, + }) + + setDeleteConfirmState({ + isOpen: false, + providersToDelete: [], + affectedModels: [], + pendingProviders: [], + context: 'auto', + oldProviders: [], + }) + setSelectedProviders(new Set()) + + if (deleteConfirmState.context === 'restart') { + await handleRestart() + } + } catch (error) { + console.error('删除失败:', error) + toast({ + title: '删除失败', + description: (error as Error).message, + variant: 'destructive', + }) + } finally { + if (deleteConfirmState.context === 'auto') { + setAutoSaving(false) + } else { + setSaving(false) + } + } + } + + const handleCancelDeleteProvider = () => { + if (deleteConfirmState.oldProviders.length > 0) { + setProviders(deleteConfirmState.oldProviders) + } + setDeleteConfirmState({ + isOpen: false, + providersToDelete: [], + affectedModels: [], + pendingProviders: [], + context: 'auto', + oldProviders: [], + }) + setHasUnsavedChanges(false) + } + + const autoSaveProviders = useCallback(async (newProviders: APIProvider[]) => { + if (initialLoadRef.current) return + + const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'auto') + + if (!shouldProceed) { + setHasUnsavedChanges(true) + return + } + + try { + setAutoSaving(true) + const cleanedProviders = newProviders.map(cleanProviderData) + const result = await updateModelConfigSection('api_providers', cleanedProviders) + if (!result.success) { + console.error('自动保存失败:', result.error) + toast({ + title: '自动保存失败', + description: result.error, + variant: 'destructive', + }) + setHasUnsavedChanges(true) + return + } + setHasUnsavedChanges(false) + } catch (error) { + console.error('自动保存失败:', error) + toast({ + title: '自动保存失败', + description: (error as Error).message, + variant: 'destructive', + }) + setHasUnsavedChanges(true) + } finally { + setAutoSaving(false) + } + }, [providers, checkDeleteProviderImpact]) + + useEffect(() => { + if (initialLoadRef.current) return + + setHasUnsavedChanges(true) + + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + + autoSaveTimerRef.current = setTimeout(() => { + autoSaveProviders(providers) + }, 2000) + + return () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + } + }, [providers, autoSaveProviders]) + + const saveConfig = async () => { + try { + setSaving(true) + + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + + const cleanedProviders = providers.map(cleanProviderData) + + const { shouldProceed } = await checkDeleteProviderImpact(cleanedProviders, 'manual') + if (!shouldProceed) { + setSaving(false) + return + } + + const resultGet = await getModelConfig() + if (!resultGet.success) { + toast({ + title: '保存失败', + description: resultGet.error, + variant: 'destructive', + }) + setSaving(false) + return + } + const config = resultGet.data + + const validProviderNames = new Set(cleanedProviders.map(p => p.name)) + const originalModels = (config.models as any[]) || [] + const filteredModels = originalModels.filter((model: any) => { + const isValid = validProviderNames.has(model.api_provider) + if (!isValid) { + console.warn(`模型 "${model.name}" 引用了已删除的提供商 "${model.api_provider}"、将被移除`) + } + return isValid + }) + + if (originalModels.length !== filteredModels.length) { + const removedCount = originalModels.length - filteredModels.length + toast({ + title: '注意', + description: `已自动移除 ${removedCount} 个引用已删除提供商的模型`, + variant: 'default', + }) + } + + console.log('发送的 providers 数据:', cleanedProviders) + config.api_providers = cleanedProviders + config.models = filteredModels + console.log('完整配置数据:', config) + + const resultUpdate = await updateModelConfig(config) + if (!resultUpdate.success) { + toast({ + title: '保存失败', + description: resultUpdate.error, + variant: 'destructive', + }) + setSaving(false) + return + } + setHasUnsavedChanges(false) + toast({ + title: '保存成功', + description: '模型提供商配置已保存', + }) + } catch (error) { + console.error('保存配置失败:', error) + toast({ + title: '保存失败', + description: (error as Error).message, + variant: 'destructive', + }) + } finally { + setSaving(false) + } + } + + const openEditDialog = (provider: APIProvider | null, index: number | null) => { + if (provider) { + setEditingProvider(provider) + } else { + setEditingProvider({ + name: '', + base_url: '', + api_key: '', + client_type: 'openai', + max_retry: 2, + timeout: 30, + retry_interval: 10, + }) + } + setEditingIndex(index) + setEditDialogOpen(true) + } + + const handleSaveEdit = (provider: APIProvider, index: number | null) => { + const providerToSave = cleanProviderData(provider) + + if (index !== null) { + const newProviders = [...providers] + newProviders[index] = providerToSave + setProviders(newProviders) + } else { + setProviders([...providers, providerToSave]) + } + + setEditDialogOpen(false) + setEditingProvider(null) + setEditingIndex(null) + } + + const openDeleteDialog = (index: number) => { + setDeletingIndex(index) + setDeleteDialogOpen(true) + } + + const handleConfirmDelete = async () => { + if (deletingIndex !== null) { + const newProviders = providers.filter((_, i) => i !== deletingIndex) + + const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'manual') + + if (shouldProceed) { + setProviders(newProviders) + toast({ + title: '删除成功', + description: '提供商已从列表中移除', + }) + } + } + setDeleteDialogOpen(false) + setDeletingIndex(null) + } + + const toggleProviderSelection = (index: number) => { + const newSelected = new Set(selectedProviders) + if (newSelected.has(index)) { + newSelected.delete(index) + } else { + newSelected.add(index) + } + setSelectedProviders(newSelected) + } + + const toggleSelectAll = () => { + if (selectedProviders.size === providers.length) { + setSelectedProviders(new Set()) + } else { + const allIndices = providers.map((_, idx) => idx) + setSelectedProviders(new Set(allIndices)) + } + } + + const openBatchDeleteDialog = () => { + if (selectedProviders.size === 0) { + toast({ + title: '提示', + description: '请先选择要删除的提供商', + variant: 'default', + }) + return + } + setBatchDeleteDialogOpen(true) + } + + const handleConfirmBatchDelete = async () => { + const newProviders = providers.filter((_, index) => !selectedProviders.has(index)) + + const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'manual') + + if (shouldProceed) { + setProviders(newProviders) + setSelectedProviders(new Set()) + toast({ + title: '批量删除成功', + description: `已删除 ${selectedProviders.size} 个提供商`, + }) + } + + setBatchDeleteDialogOpen(false) + } + + const handleTestConnection = async (providerName: string) => { + setTestingProviders(prev => new Set(prev).add(providerName)) + + try { + const result = await testProviderConnection(providerName) + if (!result.success) { + toast({ + title: '测试失败', + description: result.error, + variant: 'destructive', + }) + return + } + const testResult = result.data + setTestResults(prev => new Map(prev).set(providerName, testResult)) + + if (testResult.network_ok) { + if (testResult.api_key_valid === true) { + toast({ + title: '连接正常', + description: `${providerName} 网络连接正常、API Key 有效 (${testResult.latency_ms}ms)`, + }) + } else if (testResult.api_key_valid === false) { + toast({ + title: '连接正常但 Key 无效', + description: `${providerName} 网络连接正常、但 API Key 无效或已过期`, + variant: 'destructive', + }) + } else { + toast({ + title: '网络连接正常', + description: `${providerName} 可以访问 (${testResult.latency_ms}ms)`, + }) + } + } else { + toast({ + title: '连接失败', + description: testResult.error || '无法连接到提供商', + variant: 'destructive', + }) + } + } catch (error) { + toast({ + title: '测试失败', + description: (error as Error).message, + variant: 'destructive', + }) + } finally { + setTestingProviders(prev => { + const newSet = new Set(prev) + newSet.delete(providerName) + return newSet + }) + } + } + + const handleTestAllConnections = async () => { + for (const provider of providers) { + await handleTestConnection(provider.name) + } + } + + if (loading) { + return ( +
+
+

加载中...

+
+
+ ) + } + + return ( +
+ {/* 页面标题 */} +
+
+

AI模型厂商配置

+

管理 AI 模型厂商的 API 配置

+
+
+ {selectedProviders.size > 0 && ( + + )} + + + + + + + + + + 确认重启麦麦? + +
+

+ {hasUnsavedChanges + ? '当前有未保存的配置更改。点击确认将先保存配置,然后重启麦麦使新配置生效。重启过程中麦麦将暂时离线。' + : '即将重启麦麦主程序。重启过程中麦麦将暂时离线,配置将在重启后生效。' + } +

+
+
+
+ + 取消 + + {hasUnsavedChanges ? '保存并重启' : '确认重启'} + + +
+
+
+
+ + {/* 重启提示 */} + + + + 配置更新后需要重启麦麦才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。 + + + + + + + + + + {/* 删除确认对话框 */} + + + + 确认删除 + + 确定要删除提供商 "{deletingIndex !== null ? providers[deletingIndex]?.name : ''}" 吗? + 此操作无法撤销。 + + + + 取消 + 删除 + + + + + {/* 批量删除确认对话框 */} + + + + 确认批量删除 + + 确定要删除选中的 {selectedProviders.size} 个提供商吗? + 此操作无法撤销。 + + + + 取消 + + 批量删除 + + + + + + {/* 删除提供商影响确认对话框 */} + setDeleteConfirmState(prev => ({ ...prev, isOpen: open }))}> + + + 确认删除提供商 + +
+

+ 您即将删除以下提供商: + + {deleteConfirmState.providersToDelete.join(', ')} + +

+

+ ⚠️ 此操作将同时删除 {deleteConfirmState.affectedModels.length} 个关联的模型: +

+ +
+ {deleteConfirmState.affectedModels.map((model: any, idx: number) => ( +
+ + {model.name} + + ({model.model_identifier}) + +
+ ))} +
+
+

+ 这些模型将从模型列表和所有任务分配中移除。此操作无法撤销。 +

+
+
+
+ + 取消 + + 确认删除 + + +
+
+ + {/* 重启遮罩层 */} + +
+ ) +}