feat: 新增模型管理和供应商配置功能

### v1.1.0
- #### Added
  - 新增 AI 笔记风格选择
  - 新增 AI 笔记返回格式选择
  - 添加 AI 自定义笔记备注 Prompt
  - 添加任务失败重试
  - 添加全局设置页,可在设置页进行模型设置

- #### Optimize
  - 优化前端样式,优化用户体验
  - 增加生成中间产物,可用于失败后加快生成速度
- #### Fix
  - 修复视频截图视频过早删除错误
This commit is contained in:
思诺特
2025-04-26 23:40:17 +08:00
parent 1323cfd1ec
commit 171dea5e0d
51 changed files with 2511 additions and 414 deletions

View File

@@ -1,153 +1,281 @@
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useParams } from 'react-router-dom';
import { useProviderStore } from '@/store/providerStore';
import {useEffect, useState} from 'react';
FormDescription,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { useParams, useNavigate } from 'react-router-dom'
import { useProviderStore } from '@/store/providerStore'
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { testConnection, fetchModels } from '@/services/model.ts'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select.tsx' // ⚡新增 fetchModels
import { ModelSelector } from '@/components/Form/modelForm/ModelSelector.tsx'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert.tsx'
// ✅ 表单校验 schema
// ✅ Provider表单schema
const ProviderSchema = z.object({
name: z.string().min(2, '名称不能少于 2 个字符'),
apiKey: z.string().optional(),
baseUrl: z.string().url('必须是合法 URL'),
type: z.string(), // 只展示,不可改
});
type: z.string(),
})
type ProviderFormValues = z.infer<typeof ProviderSchema>;
type ProviderFormValues = z.infer<typeof ProviderSchema>
const ProviderForm = () => {
const rawId= useParams();
console.log('rawId',rawId)
// @ts-ignore
const [providerName, idPart] = rawId.id.split('&');
const [id,setId ]= useState(Number(idPart?.split('=')[1])) // => "1"
const getProviderById = useProviderStore((state) => state.getProviderById);
const provider = getProviderById(id);
// ✅ Model表单schema
const ModelSchema = z.object({
modelName: z.string().min(1, '请选择或填写模型名称'),
})
const form = useForm<ProviderFormValues>({
type ModelFormValues = z.infer<typeof ModelSchema>
interface IModel {
id: string
created: number
object: string
owned_by: string
permission: string
root: string
}
const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
const { id } = useParams()
const navigate = useNavigate()
const isEditMode = !isCreate
const getProviderById = useProviderStore(state => state.getProviderById)
const loadProviderById = useProviderStore(state => state.loadProviderById)
const updateProvider = useProviderStore(state => state.updateProvider)
const addNewProvider = useProviderStore(state => state.addNewProvider)
const [loading, setLoading] = useState(true)
const [testing, setTesting] = useState(false)
const [isBuiltIn, setIsBuiltIn] = useState(false)
const [modelOptions, setModelOptions] = useState<IModel[]>([]) // ⚡新增,保存模型列表
const [modelLoading, setModelLoading] = useState(false)
const [search, setSearch] = useState('')
const providerForm = useForm<ProviderFormValues>({
resolver: zodResolver(ProviderSchema),
defaultValues: {
name: '',
apiKey: '',
baseUrl: '',
type: '',
type: 'custom',
},
});
})
const filteredModelOptions = modelOptions.filter(model => {
const keywords = search.trim().toLowerCase().split(/\s+/) // 支持多个关键词
const target = model.id.toLowerCase()
return keywords.every(kw => target.includes(kw))
})
const modelForm = useForm<ModelFormValues>({
resolver: zodResolver(ModelSchema),
defaultValues: {
modelName: '',
},
})
useEffect(() => {
console.log(provider)
// if (provider) {
// form.reset({
// name: provider.name,
// apiKey: provider.apiKey,
// baseUrl: provider.baseUrl,
// type: provider.type,
// });
// }
}, [id,provider, form]);
const load = async () => {
if (isEditMode) {
const data = await loadProviderById(id!)
providerForm.reset(data)
setIsBuiltIn(data.type === 'built-in')
} else {
providerForm.reset({
name: '',
apiKey: '',
baseUrl: '',
type: 'custom',
})
setIsBuiltIn(false)
}
setLoading(false)
}
load()
}, [id])
const isBuiltIn = provider?.type === 'built-in';
// 测试连通性
const handleTest = async () => {
const values = providerForm.getValues()
if (!values.apiKey || !values.baseUrl) {
toast.error('请填写 API Key 和 Base URL')
return
}
try {
setTesting(true)
const data = await testConnection({
api_key: values.apiKey,
base_url: values.baseUrl,
})
if (data.data.code === 0) {
toast.success('测试连通性成功 🎉')
} else {
toast.error(`连接失败: ${data.data.msg || '未知错误'}`)
}
} catch (error) {
toast.error('测试连通性异常')
} finally {
setTesting(false)
}
}
const onSubmit = (values: ProviderFormValues) => {
console.log('📝 提交表单数据:', values);
// TODO: 提交接口 /update_provider
};
// 加载模型列表
const handleModelLoad = async () => {
const values = providerForm.getValues()
if (!values.apiKey || !values.baseUrl) {
toast.error('请先填写 API Key 和 Base URL')
return
}
try {
setModelLoading(true) // ✅ 开始 loading
const res = await fetchModels(id!, { noCache: true }) // 这里稍后解释
if (res.data.code === 0 && res.data.data.models.data.length > 0) {
setModelOptions(res.data.data.models.data)
console.log('🔧 模型列表:', res.data.data)
toast.success('模型列表加载成功 🎉')
} else {
toast.error('未获取到模型列表')
}
} catch (error) {
toast.error('加载模型列表失败')
} finally {
setModelLoading(false) // ✅ 结束 loading
}
}
// if (!provider) return <div className="p-4">加载中...</div>;
// 保存Provider信息
const onProviderSubmit = async (values: ProviderFormValues) => {
if (isEditMode) {
updateProvider({ ...values, id: id! })
toast.success('更新供应商成功')
} else {
addNewProvider({ ...values })
toast.success('新增供应商成功')
}
}
// 保存Model信息
const onModelSubmit = async (values: ModelFormValues) => {
console.log('🔧 选择的模型:', values.modelName)
toast.success(`保存模型: ${values.modelName}`)
}
if (loading) return <div className="p-4">...</div>
return (
<Form {...form}>
<div className="flex flex-col gap-8 p-4">
{/* Provider信息表单 */}
<Form {...providerForm}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="w-full max-w-xl p-4 flex flex-col gap-4"
onSubmit={providerForm.handleSubmit(onProviderSubmit)}
className="flex max-w-xl flex-col gap-4"
>
<div className="text-lg font-bold"></div>
{/* 名称 */}
<div className="text-lg font-bold">
{isEditMode ? '编辑模型供应商' : '新增模型供应商'}
</div>
{!isBuiltIn && (
<div className="text-sm text-red-500 italic">
OpenAI SDK
</div>
)}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex items-center gap-4">
<FormLabel className="w-24 text-right"></FormLabel>
<FormControl>
<Input {...field} disabled={isBuiltIn} className="flex-1" />
</FormControl>
<FormMessage />
</FormItem>
)}
control={providerForm.control}
name="name"
render={({ field }) => (
<FormItem className="flex items-center gap-4">
<FormLabel className="w-24 text-right"></FormLabel>
<FormControl>
<Input {...field} disabled={isBuiltIn} className="flex-1" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* API Key */}
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem className="flex items-center gap-4">
<FormLabel className="w-24 text-right">API Key</FormLabel>
<FormControl>
<Input placeholder={'sk-xxx'} {...field} className="flex-1" />
</FormControl>
<FormMessage />
</FormItem>
)}
control={providerForm.control}
name="apiKey"
render={({ field }) => (
<FormItem className="flex items-center gap-4">
<FormLabel className="w-24 text-right">API Key</FormLabel>
<FormControl>
<Input {...field} className="flex-1" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Base URL */}
<FormField
control={form.control}
name="baseUrl"
render={({ field }) => (
<FormItem className="flex items-center gap-4">
<FormLabel className="w-24 text-right">API </FormLabel>
<FormControl>
<Input {...field} className="flex-1" />
</FormControl>
<FormMessage />
</FormItem>
)}
control={providerForm.control}
name="baseUrl"
render={({ field }) => (
<FormItem className="flex items-center gap-4">
<FormLabel className="w-24 text-right">API</FormLabel>
<FormControl>
<Input {...field} className="flex-1" />
</FormControl>
<Button type="button" onClick={handleTest} variant="ghost" disabled={testing}>
{testing ? '测试中...' : '测试连通性'}
</Button>
<FormMessage />
</FormItem>
)}
/>
{/* 类型 */}
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem className="flex items-center gap-4">
<FormLabel className="w-24 text-right"></FormLabel>
<FormControl>
<Input {...field} disabled className="flex-1" />
</FormControl>
</FormItem>
)}
control={providerForm.control}
name="type"
render={({ field }) => (
<FormItem className="flex items-center gap-4">
<FormLabel className="w-24 text-right"></FormLabel>
<FormControl>
<Input {...field} disabled className="flex-1" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="pt-2">
<Button type="submit" disabled={!form.formState.isDirty}>
<Button type="submit" disabled={!providerForm.formState.isDirty}>
{isEditMode ? '保存修改' : '保存创建'}
</Button>
</div>
</form>
</Form>
);
};
export default ProviderForm;
{/* 模型信息表单 */}
<div className="flex max-w-xl flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="font-bold"></span>
<div className={'flex flex-col gap-2 rounded bg-[#FEF0F0] p-2.5'}>
<h2 className={'font-bold'}>!</h2>
<span>,.</span>
</div>
<ModelSelector providerId={id!} />
{/*<datalist id="model-options">*/}
{/* {modelOptions.map(model => (*/}
{/* <option key={model.id + '1'} value={model.id} />*/}
{/* ))}*/}
{/*</datalist>*/}
</div>
</div>
</div>
)
}
export default ProviderForm

View File

@@ -0,0 +1,4 @@
// iconMap.ts
import * as Icons from '@lobehub/icons'
export const IconMap = Icons;

View File

@@ -0,0 +1,29 @@
import * as Icons from '@lobehub/icons'
import CustomLogo from '@/assets/customAI.png'
interface AILogoProps {
name: string // 图标名称(区分大小写!如 OpenAI、DeepSeek
style?: 'Color' | 'Text' | 'Outlined' | 'Glyph'
size?: number
}
const AILogo = ({ name, style = 'Color', size = 24 }: AILogoProps) => {
const Icon = Icons[name as keyof typeof Icons]
if (!Icon) {
console.error(`❌ 图标组件不存在: ${name}`)
return (
<span style={{ fontSize: size }}>
<img src={CustomLogo} alt="CustomLogo" style={{ width: size, height: size }} />
</span>
)
}
const Variant = Icon[style as keyof typeof Icon]
if (!Variant) {
return <Icon size={size} />
}
return <Variant size={size} />
}
export default AILogo

View File

@@ -0,0 +1,92 @@
import { useState, useEffect } from 'react'
import { useModelStore } from '@/store/modelStore'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import toast from 'react-hot-toast'
interface ModelSelectorProps {
providerId: string
}
export function ModelSelector({ providerId }: ModelSelectorProps) {
const { models, loading, selectedModel, loadModels, setSelectedModel, addNewModel } =
useModelStore()
const [search, setSearch] = useState('')
const [submitting, setSubmitting] = useState(false)
const filteredModels = models.filter(model => {
const keywords = search.trim().toLowerCase().split(/\s+/)
const target = model.id.toLowerCase()
return keywords.every(kw => target.includes(kw))
})
useEffect(() => {
if (providerId) {
loadModels(providerId)
}
}, [providerId])
const handleSubmit = async () => {
if (!selectedModel) {
toast.error('请选择一个模型')
return
}
try {
setSubmitting(true)
await addNewModel(providerId, selectedModel)
toast.success('保存模型成功 🎉')
} catch (error) {
toast.error('保存失败')
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 font-bold">
<span></span>
<Button
variant="ghost"
type="button"
onClick={() => loadModels(providerId)}
disabled={loading}
>
{loading ? '加载中...' : '刷新模型'}
</Button>
</div>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="请选择模型" />
</SelectTrigger>
<SelectContent>
<div className="p-2">
<Input
placeholder="搜索模型..."
value={search}
onChange={e => setSearch(e.target.value)}
className="h-8"
/>
</div>
{filteredModels.map(model => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleSubmit} disabled={submitting || !selectedModel}>
{submitting ? '保存中...' : '保存模型'}
</Button>
</div>
)
}

View File

@@ -1,28 +1,39 @@
import ProviderCard from '@/components/Form/modelForm/components/providerCard.tsx'
import { Button } from '@/components/ui/button.tsx'
import { useProviderStore } from '@/store/providerStore'
import { useNavigate } from 'react-router-dom'
const Provider = () => {
const providers = useProviderStore(state => state.provider)
const providers = useProviderStore(state => state.provider)
const navigate = useNavigate()
const handleClick = () => {
navigate(`/settings/model/new`)
}
return (
<div className="flex flex-col gap-2">
<div className={'search flex gap-1 py-1.5'}>
<Button type={'button'} className="w-full">
<Button
type={'button'}
onClick={() => {
handleClick()
}}
className="w-full"
>
</Button>
</div>
<div className="text-sm font-light"></div>
<div>
{providers &&
providers.map((provider, index) => {
providers.map((provider, index) => {
return (
<ProviderCard
key={index}
providerName={provider.name}
Icon={provider.logo}
id={provider.id}
enable={provider.enabled}
/>
)
})}

View File

@@ -1,22 +1,37 @@
import { Switch } from '@/components/ui/switch'
import { FC } from 'react'
import styles from './index.module.css'
import {useNavigate, useParams} from 'react-router-dom'
import AILogo from "@/components/Icons";
import { useNavigate, useParams } from 'react-router-dom'
import AILogo from '@/components/Form/modelForm/Icons'
import { useProviderStore } from '@/store/providerStore'
export interface IProviderCardProps {
id: string
providerName: string
Icon: string
enable: number
}
const ProviderCard: FC<IProviderCardProps> = ({ providerName, Icon, id }: IProviderCardProps) => {
const ProviderCard: FC<IProviderCardProps> = ({
providerName,
Icon,
id,
enable,
}: IProviderCardProps) => {
const navigate = useNavigate()
const updateProvider = useProviderStore(state => state.updateProvider)
const handleClick = () => {
navigate(`/settings/model/${providerName}&id=${id}`)
navigate(`/settings/model/${id}`)
}
const rawId= useParams();
console.log('rawId',rawId)
const handleEnable = () => {
console.log('enable', enable)
updateProvider({
id,
enabled: enable == 1 ? 0 : 1,
})
}
const rawId = useParams()
console.log('rawId', rawId)
// @ts-ignore
const { id: currentId } = useParams();
const { id: currentId } = useParams()
const isActive = currentId === id
return (
<div
@@ -24,18 +39,26 @@ const ProviderCard: FC<IProviderCardProps> = ({ providerName, Icon, id }: IProvi
handleClick()
}}
className={
styles.card + ' flex h-14 items-center justify-between rounded border border-[#f3f3f3] p-2'
+(isActive ? ' bg-[#F0F0F0] font-semibold text-blue-600' : '')
styles.card +
' flex h-14 items-center justify-between rounded border border-[#f3f3f3] p-2' +
(isActive ? ' bg-[#F0F0F0] font-semibold text-blue-600' : '')
}
>
<div className="flex items-center text-lg">
<div className="h-9 w-9 flex items-center">
<AILogo name={Icon} />
<div className="flex h-9 w-9 items-center">
<AILogo name={Icon} />
</div>
<div className="font-semibold">{providerName}</div>
</div>
<div>
<Switch />
<Switch
onClick={e => {
e.preventDefault()
handleEnable()
}}
checked={enable == 1}
/>
</div>
</div>
)