diff --git a/backend/admin/public/provider/kimi-color.svg b/backend/admin/public/provider/kimi-color.svg index 286f35e7..1915850e 100644 --- a/backend/admin/public/provider/kimi-color.svg +++ b/backend/admin/public/provider/kimi-color.svg @@ -1 +1 @@ -Kimi +Kimi \ No newline at end of file diff --git a/backend/admin/src/app/admin/llm/components/provider-form.tsx b/backend/admin/src/app/admin/llm/components/provider-form.tsx index 9d6cd8a8..6a5036fb 100644 --- a/backend/admin/src/app/admin/llm/components/provider-form.tsx +++ b/backend/admin/src/app/admin/llm/components/provider-form.tsx @@ -1,54 +1,47 @@ 'use client'; import React, { useMemo, useState } from 'react'; -import { useForm, useFieldArray } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import { useRouter } from 'next/navigation'; -import Image from 'next/image'; import { useTranslation } from 'react-i18next'; import api from '@/lib/axios'; import { mutate } from 'swr'; import { Button } from '@/components/ui/button'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Textarea } from "@/components/ui/textarea"; -import { Badge } from "@/components/ui/badge"; +import { Form } from '@/components/ui/form'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useToast } from '@/components/ui/use-toast'; -import { Plus, Trash2, Plug, X, ChevronDown, ChevronRight, Save, ArrowLeft, Eye, EyeOff, RefreshCw } from 'lucide-react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { PRESET_COST_DIMENSIONS, MODEL_TYPE_OPTIONS, PROVIDER_OPTIONS, createFormSchema, FormValues, LLMProvider } from '../schema'; +import { Plug, Save, ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react'; +import { createFormSchema, FormValues, LLMProvider } from '../schema'; +import { FormStepper, StepMeta } from './wizard/form-stepper'; +import { StepBasic } from './wizard/step-basic'; +import { StepConnection } from './wizard/step-connection'; +import { StepModels } from './wizard/step-models'; interface ProviderFormProps { initialData?: LLMProvider; } +// 步骤注册表:key 对应 i18n llm.form.steps.* 与步骤内容组件,fields 用于分步校验门控 +const STEP_DEFINITIONS: { key: string; fields: (keyof FormValues)[] }[] = [ + { key: 'basic', fields: ['name', 'provider_type'] }, + { key: 'connection', fields: ['base_url', 'api_key', 'config_json'] }, + { key: 'models', fields: ['models'] }, +]; + export function ProviderForm({ initialData }: ProviderFormProps) { const router = useRouter(); const { toast } = useToast(); const { t } = useTranslation(); const [isTesting, setIsTesting] = useState(false); const [isSaving, setIsSaving] = useState(false); - const [isSyncingOllama, setIsSyncingOllama] = useState(false); - const [tagInput, setTagInput] = useState(""); + const [currentStep, setCurrentStep] = useState(0); + // 编辑模式默认开放全部步骤跳转;创建模式仅开放已访问步骤 + const [visitedSteps, setVisitedSteps] = useState(() => + initialData ? STEP_DEFINITIONS.map((_, i) => i) : [0] + ); const [modelCosts, setModelCosts] = useState>>(initialData?.model_costs || {}); - const [expandedModels, setExpandedModels] = useState>({}); - const [showApiKey, setShowApiKey] = useState(false); const formSchema = useMemo(() => createFormSchema(t), [t]); @@ -68,60 +61,42 @@ export function ProviderForm({ initialData }: ProviderFormProps) { }, }); - const { fields, append, remove, replace } = useFieldArray({ - control: form.control, - name: "models", - }); + const steps: StepMeta[] = STEP_DEFINITIONS.map((def) => ({ + key: def.key, + title: t(`llm.form.steps.${def.key}.title`), + description: t(`llm.form.steps.${def.key}.description`), + })); - // 同步本地 Ollama 模型列表:调用后端代理 GET /api/tags, - // 合并保留已有同名条目的别名/类型配置;cost 配置仍由 modelCosts state 按名匹配。 - const handleSyncOllamaModels = async () => { - try { - setIsSyncingOllama(true); - const baseUrl = (form.getValues('base_url') || '').trim() || 'http://localhost:11434'; - const res = await api.post('/admin/llm-providers/ollama-models', { base_url: baseUrl }); - if (!res.data?.success) { - toast({ - variant: 'destructive', - title: '同步失败', - description: res.data?.message || '未能连接本地 Ollama 服务', - }); - return; - } - const remoteNames: string[] = res.data.models || []; - if (remoteNames.length === 0) { - toast({ - variant: 'destructive', - title: '本地未发现模型', - description: '请先使用 `ollama pull ` 拉取至少一个模型', - }); - return; - } - // 按 value 合并:本地列表为准,同名条目保留已有 type/display_name - const existing = new Map( - (form.getValues('models') || []).map((m: { value: string; type?: string; display_name?: string }) => [m.value, m]), - ); - const merged = remoteNames.map((name) => existing.get(name) || { value: name, type: 'language', display_name: '' }); - replace(merged); - toast({ - title: '同步成功', - description: `已从 Ollama 拉取 ${remoteNames.length} 个模型`, - }); - } catch (err: any) { - toast({ - variant: 'destructive', - title: '同步出错', - description: err?.message || '请检查 Base URL 与后端服务', - }); - } finally { - setIsSyncingOllama(false); - } + const goToStep = (index: number) => { + setVisitedSteps((prev) => (prev.includes(index) ? prev : [...prev, index])); + setCurrentStep(index); + }; + + const handleNext = async () => { + const valid = await form.trigger(STEP_DEFINITIONS[currentStep].fields as any); + valid && goToStep(currentStep + 1); + }; + + const handlePrev = () => goToStep(currentStep - 1); + + const handleStepClick = (index: number) => { + visitedSteps.includes(index) && setCurrentStep(index); + }; + + // 校验失败时跳转到首个包含错误字段的步骤,使错误信息可见 + const goToFirstErrorStep = () => { + const errorKeys = Object.keys(form.formState.errors); + const stepIndex = STEP_DEFINITIONS.findIndex((def) => def.fields.some((f) => errorKeys.includes(f))); + stepIndex >= 0 && goToStep(stepIndex); }; const handleTestConnection = async () => { try { const values = await form.trigger(); - if (!values) return; + if (!values) { + goToFirstErrorStep(); + return; + } const data = form.getValues(); if (!data.models || data.models.length === 0 || !data.models[0].value) { @@ -221,486 +196,89 @@ export function ProviderForm({ initialData }: ProviderFormProps) { }; const handleSave = () => { - form.handleSubmit(onSubmit as any)(); + form.handleSubmit(onSubmit as any, goToFirstErrorStep)(); + }; + + // 步骤头部右侧的圆形图标操作组:上一步 / 下一步 / 测试连接 / 保存,hover 时显示 tooltip + const renderStepActions = (stepIndex: number) => { + const isLast = stepIndex === STEP_DEFINITIONS.length - 1; + return ( +
+ {stepIndex > 0 && ( + + + + + {t('llm.form.wizard.prev')} + + )} + {isLast && ( + + + + + {t('llm.form.testConnection')} + + )} + {isLast ? ( + + + + + {t('llm.form.save')} + + ) : ( + + + + + {t('llm.form.wizard.next')} + + )} +
+ ); }; + // 步骤内容注册表:按当前步骤索引渲染,避免条件分支 + const stepContents = [ + , + , + , + ]; + return ( - <> + {/* Header */} -
-
-

{initialData ? t('llm.form.editTitle') : t('llm.form.createTitle')}

-

{t('llm.form.subtitle')}

-
-
- {initialData && {t('llm.form.idLabel', { id: initialData.id })}} - - - +
+ +
+

{initialData ? t('llm.form.editTitle') : t('llm.form.createTitle')}

+

{t('llm.form.subtitle')}

+ {initialData && ( + {t('llm.form.idLabel', { id: initialData.id })} + )}
- {/* Content: Left-Right layout */} -
- -
- {/* ==================== Left Column ==================== */} -
- {/* 基本信息 */} - - - {t('llm.form.basic.title')} - {t('llm.form.basic.description')} - - -
- ( - - {t('llm.form.basic.name')} - - - - - - )} - /> - ( - - {t('llm.form.basic.brand')} - - - - )} - /> -
- - ( - - {t('llm.form.basic.tags')} - -
- {field.value?.map((tag, index) => ( - - {tag} - { - const newTags = [...(field.value || [])]; - newTags.splice(index, 1); - field.onChange(newTags); - }} - /> - - ))} - setTagInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - const val = tagInput.trim(); - if (val) { - const currentTags = field.value || []; - if (!currentTags.includes(val)) { - field.onChange([...currentTags, val]); - } - setTagInput(""); - } - } else if (e.key === 'Backspace' && !tagInput && field.value?.length) { - const newTags = [...(field.value || [])]; - newTags.pop(); - field.onChange(newTags); - } - }} - /> -
-
- {t('llm.form.basic.tagsDescription')} - -
- )} - /> -
-
+ {/* Stepper */} + - {/* 连接与认证 */} - - - {t('llm.form.connection.title')} - {t('llm.form.connection.description')} - - - ( - - {t('llm.form.connection.baseUrl')} - - - - {t('llm.form.connection.baseUrlDescription')} - - - )} - /> - - ( - - {t('llm.form.connection.apiKey')} - -
- - -
-
- -
- )} - /> - - ( - - {t('llm.form.connection.advancedConfig')} - -