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

@@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown'
import { Button } from '@/components/ui/button.tsx'
import { Copy, Download, FileText, ArrowRight } from 'lucide-react'
import { toast } from 'sonner' // 你可以换成自己的通知组件
import Error from '@/components/Lottie/error.tsx'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { solarizedlight as codeStyle } from 'react-syntax-highlighter/dist/cjs/styles/prism'
import 'github-markdown-css/github-markdown-light.css'
@@ -11,14 +11,26 @@ import { FC } from 'react'
import Loading from '@/components/Lottie/Loading.tsx'
import Idle from '@/components/Lottie/Idle.tsx'
import { useTaskStore } from '@/store/taskStore'
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
interface MarkdownViewerProps {
content: string
status: 'idle' | 'loading' | 'success'
status: 'idle' | 'loading' | 'success' | 'failed'
}
const steps = [
{ label: '解析链接', key: 'PARSING' },
{ label: '下载音频', key: 'DOWNLOADING' },
{ label: '转写文字', key: 'TRANSCRIBING' },
{ label: '总结内容', key: 'SUMMARIZING' },
{ label: '保存完成', key: 'SUCCESS' },
]
const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
const [copied, setCopied] = useState(false)
const getCurrentTask = useTaskStore.getState().getCurrentTask
const currentTask = useTaskStore(state => state.getCurrentTask())
const taskStatus = currentTask?.status || 'PENDING'
const retryTask = useTaskStore.getState().retryTask
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content)
@@ -34,6 +46,7 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
const handleDownload = () => {
const currentTask = getCurrentTask()
const currentTaskName = currentTask?.audioMeta.title
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
@@ -45,6 +58,7 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
if (status === 'loading') {
return (
<div className="flex h-screen w-full flex-col items-center justify-center space-y-4 text-neutral-500">
<StepBar steps={steps} currentStep={taskStatus} />
<Loading className="h-5 w-5" />
<div className="text-center text-sm">
<p className="text-lg font-bold"></p>
@@ -63,6 +77,24 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
</div>
</div>
)
} else if (status === 'failed') {
return (
<div className="flex h-screen w-full flex-col items-center justify-center gap-4 space-y-3">
<Error /> {/* 你可以换成 Failed 动画 */}
<div className="text-center">
<p className="text-lg font-bold text-red-500"></p>
<p className="mt-2 mb-2 text-xs text-red-400"></p>
<Button
onClick={() => {
retryTask(currentTask.id)
}}
size="lg"
>
</Button>
</div>
</div>
)
}
return (

View File

@@ -6,6 +6,7 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form.tsx'
import { useEffect } from 'react'
import { Input } from '@/components/ui/input.tsx'
import {
Select,
@@ -30,7 +31,9 @@ import {
import { generateNote } from '@/services/note.ts'
import { useTaskStore } from '@/store/taskStore'
import NoteHistory from '@/pages/HomePage/components/NoteHistory.tsx'
import { useModelStore } from '@/store/modelStore'
import { Alert } from 'antd'
import { Textarea } from '@/components/ui/textarea.tsx'
// ✅ 定义表单 schema
const formSchema = z.object({
video_url: z.string().url('请输入正确的视频链接'),
@@ -40,15 +43,70 @@ const formSchema = z.object({
}),
screenshot: z.boolean().optional(),
link: z.boolean().optional(),
model_name: z.string().nonempty('请选择模型'),
format: z.array(z.string()).default([]), // ✨ 确保默认是空数组
style: z.string().nonempty('请选择笔记生成风格'),
extras: z.string().optional(),
})
type NoteFormValues = z.infer<typeof formSchema>
const noteFormats = [
{
label: '目录',
value: 'toc',
},
{ label: '原片跳转', value: 'link' },
{ label: '原片截图', value: 'screenshot' },
{ label: 'AI总结', value: 'summary' },
]
const noteStyles = [
{
label: '精简',
value: 'minimal', // 简洁、快速呈现要点
},
{
label: '详细',
value: 'detailed', // 详细记录,包含时间戳、关键点
},
{
label: '教程',
value: 'tutorial', // 详细记录,包含时间戳、关键点
},
{
label: '学术',
value: 'academic', // 适合学术报告,正式且结构化
},
{
label: '小红书',
value: 'xiaohongshu', // 适合社交平台分享,亲切、口语化
},
{
label: '生活向',
value: 'life_journal', // 记录个人生活感悟,情感化表达
},
{
label: '任务导向',
value: 'task_oriented', // 强调任务、目标,适合工作和待办事项
},
{
label: '商业风格',
value: 'business', // 适合商业报告、会议纪要,正式且精准
},
{
label: '会议纪要',
value: 'meeting_minutes', // 适合商业报告、会议纪要,正式且精准
},
]
const NoteForm = () => {
useTaskStore(state => state.tasks)
const setCurrentTask = useTaskStore(state => state.setCurrentTask)
const currentTaskId = useTaskStore(state => state.currentTaskId)
const getCurrentTask = useTaskStore(state => state.getCurrentTask)
const loadEnabledModels = useModelStore(state => state.loadEnabledModels)
const modelList = useModelStore(state => state.modelList)
const showFeatureHint = useModelStore(state => state.showFeatureHint)
const setShowFeatureHint = useModelStore(state => state.setShowFeatureHint)
const form = useForm<NoteFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -56,9 +114,16 @@ const NoteForm = () => {
platform: 'bilibili',
quality: 'medium', // 默认中等质量
screenshot: false,
model_name: modelList[0]?.model_name || '', // 确保有值
format: [], // 初始化为空数组
style: 'minimal', // 默认选择精简风格
extras: '', // 初始化为空字符串
},
})
const onClose = () => {
setShowFeatureHint(false)
}
const isGenerating = () => {
console.log('🚀 isGenerating', getCurrentTask()?.status)
return getCurrentTask()?.status === 'PENDING'
@@ -66,14 +131,23 @@ const NoteForm = () => {
const onSubmit = async (data: NoteFormValues) => {
console.log('🎯 提交内容:', data)
await generateNote({
const payload = {
video_url: data.video_url,
platform: data.platform,
quality: data.quality,
screenshot: data.screenshot,
link: data.link,
})
model_name: data.model_name,
provider_id: modelList.find(model => model.model_name === data.model_name).provider_id,
format: data.format,
style: data.style,
extras: data.extras,
}
const res = await generateNote(payload)
const taskId = res.data.task_id
useTaskStore.getState().addPendingTask(taskId, data.platform, payload)
}
useEffect(() => {
loadEnabledModels()
}, [])
return (
<div className="flex h-full flex-col">
@@ -173,48 +247,157 @@ const NoteForm = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="model_name"
render={({ field }) => (
<FormItem>
<div className="my-3 flex items-center justify-between">
<h2 className="block"></h2>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
</TooltipTrigger>
<TooltipContent>
<p className="max-w-[200px] text-xs"></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择配置好的模型" />
</SelectTrigger>
</FormControl>
<SelectContent>
{modelList.map(item => {
return <SelectItem value={item.model_name}>{item.model_name}</SelectItem>
})}
</SelectContent>
</Select>
{/*<FormDescription className="text-xs text-neutral-500">*/}
{/* 质量越高,下载体积越大,速度越慢*/}
{/*</FormDescription>*/}
<FormMessage />
</FormItem>
)}
/>
</div>
{/* 是否需要原片位置 */}
<FormField
control={form.control}
name="link"
name="style"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
{/* Tooltip 部分 */}
<FormItem>
<div className="my-3 flex items-center justify-between">
<h2 className="block"></h2>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
</TooltipTrigger>
<TooltipContent>
<p className="max-w-[200px] text-xs"></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} id="link" />
</FormControl>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择笔记风格" />
</SelectTrigger>
</FormControl>
<SelectContent>
{noteStyles.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormLabel htmlFor="link" className="text-sm leading-none font-medium">
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
{/* 是否需要下载 */}
<FormField
control={form.control}
name="screenshot"
name="format"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
{/* Tooltip 部分 */}
<FormItem>
<div className="my-3 flex items-center justify-between">
<h2 className="block"></h2>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
</TooltipTrigger>
<TooltipContent>
<p className="text-xs"></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
id="screenshot"
/>
<div className="flex space-x-1.5">
{noteFormats.map(item => (
<label key={item.value} className="flex items-center space-x-2">
<Checkbox
checked={field.value?.includes(item.value)}
onCheckedChange={checked => {
const currentValue = field.value ?? [] // ✨ 保底是数组
if (checked) {
field.onChange([...currentValue, item.value])
} else {
field.onChange(currentValue.filter(v => v !== item.value))
}
}}
/>
<span>{item.label}</span>
</label>
))}
</div>
</FormControl>
<FormField
control={form.control}
name="extras"
render={({ field }) => (
<FormItem>
<div className="my-3 flex items-center justify-between">
<h2 className="block"></h2>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Prompt最后 </p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Textarea placeholder={'笔记需要罗列出 xxx 关键点'} />
<FormLabel htmlFor="screenshot" className="text-sm leading-none font-medium">
</FormLabel>
{/*<FormDescription className="text-xs text-neutral-500">*/}
{/* 质量越高,下载体积越大,速度越慢*/}
{/*</FormDescription>*/}
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<div className={'flex w-full items-center gap-2 py-1.5'}>
{/* 提交按钮 */}
<Button type="submit" className="bg-primary w-full" disabled={isGenerating()}>
@@ -235,27 +418,35 @@ const NoteForm = () => {
</div>
{/* 添加一些额外的说明或功能介绍 */}
<div className="bg-primary-light mt-6 rounded-lg p-4">
<h3 className="text-primary mb-2 font-medium"></h3>
<ul className="space-y-2 text-sm text-neutral-600">
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span></span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span>YouTube等</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span>Markdown格式</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span></span>
</li>
</ul>
</div>
{showFeatureHint && (
<Alert
message="功能介绍 v2.0.0"
description={
<ul className="space-y-2 text-sm text-neutral-600">
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span></span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span>YouTube等</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span>Markdown格式</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span></span>
</li>
</ul>
}
type="info"
onClose={onClose}
closable
/>
)}
{/*<div className="bg-primary-light mt-6 rounded-lg p-4"></div>*/}
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { FC } from 'react'
interface Step {
label: string
key: string
Icon?: React.ReactNode // 加一个可选的 Lottie 动画
}
interface StepBarProps {
steps: Step[]
currentStep: string
}
const StepBar: FC<StepBarProps> = ({ steps, currentStep }) => {
const currentIndex = steps.findIndex(step => step.key === currentStep)
return (
<div className="flex w-full items-center justify-between">
{steps.map((step, index) => {
const isActive = index <= currentIndex
const isCurrent = index === currentIndex
const isLast = index === steps.length - 1
return (
<div key={step.key} className="relative flex flex-1 flex-col items-center">
{/* 圆圈或者Lottie */}
<div className="relative flex flex-col items-center justify-center">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
isActive ? 'bg-primary text-white' : 'bg-gray-300 text-gray-600'
}`}
>
{index + 1}
</div>
{/* 当前步骤显示动画 */}
{isCurrent && step.Icon && (
<div className="absolute top-10 h-16 w-16">{step.Icon}</div>
)}
</div>
{/* 步骤名称 */}
<div className="mt-4 text-center text-xs text-gray-700">{step.label}</div>
{/* 连接线 */}
{!isLast && (
<div className={`h-1 w-full ${isActive ? 'bg-primary' : 'bg-gray-300'}`}></div>
)}
</div>
)
})}
</div>
)
}
export default StepBar