mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-06 20:42:52 +08:00
Merge pull request #72 from JefferyHcool/feature/regenerate
Feature/regenerate: 新增多版本笔记功能,并做了向下兼容。
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.2.2",
|
||||
"@radix-ui/react-tabs": "^1.1.4",
|
||||
"@radix-ui/react-tabs": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@uiw/react-markdown-preview": "^5.1.3",
|
||||
@@ -29,16 +29,19 @@
|
||||
"axios": "^1.8.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"katex": "^0.16.22",
|
||||
"lottie-react": "^2.4.1",
|
||||
"lucide-react": "^0.487.0",
|
||||
"markdown-navbar": "^1.4.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"pinyin-match": "^1.2.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-medium-image-zoom": "^5.2.14",
|
||||
"react-resizable-panels": "^2.1.8",
|
||||
@@ -51,6 +54,7 @@
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
|
||||
BIN
BillNote_frontend/public/preview_1.png
Normal file
BIN
BillNote_frontend/public/preview_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
@@ -11,6 +11,8 @@ import Transcriber from '@/pages/SettingPage/transcriber.tsx'
|
||||
import ProviderForm from '@/components/Form/modelForm/Form.tsx'
|
||||
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
|
||||
import Downloading from '@/components/Lottie/download.tsx'
|
||||
import Prompt from '@/pages/SettingPage/Prompt.tsx'
|
||||
import AboutPage from '@/pages/SettingPage/about.tsx'
|
||||
function App() {
|
||||
useTaskPolling(3000) // 每 3 秒轮询一次
|
||||
const steps = [
|
||||
@@ -33,7 +35,10 @@ function App() {
|
||||
{/*<Route index element={<Navigate to="openai" replace />} />*/}
|
||||
<Route path=":id" element={<ProviderForm />} />
|
||||
</Route>
|
||||
<Route path="transcriber" elment={<Transcriber />}></Route>
|
||||
{/*<Route path="transcriber" elment={<Transcriber />}></Route>*/}
|
||||
<Route path="prompt" element={<Prompt />}></Route>
|
||||
<Route path="about" element={<AboutPage />}></Route>
|
||||
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
|
||||
34
BillNote_frontend/src/components/LazyImage.tsx
Normal file
34
BillNote_frontend/src/components/LazyImage.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
// components/LazyImage.tsx
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { FC, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface LazyImageProps {
|
||||
src: string
|
||||
alt?: string
|
||||
className?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const LazyImage: FC<LazyImageProps> = ({ src, alt, className, placeholder = '.src/assets/placeholder.png' }) => {
|
||||
const { ref, inView } = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
return (
|
||||
<div ref={ref} className={clsx('overflow-hidden', className)}>
|
||||
{inView ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
onLoad={() => setLoaded(true)}
|
||||
className={clsx('transition-opacity duration-300', loaded ? 'opacity-100' : 'opacity-0') + ' h-10 w-14 rounded-md object-cover'}
|
||||
/>
|
||||
) : (
|
||||
<img src={placeholder} alt="loading" className="opacity-30" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LazyImage
|
||||
64
BillNote_frontend/src/components/ui/tabs.tsx
Normal file
64
BillNote_frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
19
BillNote_frontend/src/constant/note.ts
Normal file
19
BillNote_frontend/src/constant/note.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/* -------------------- 常量 -------------------- */
|
||||
export const noteFormats = [
|
||||
{ label: '目录', value: 'toc' },
|
||||
{ label: '原片跳转', value: 'link' },
|
||||
{ label: '原片截图', value: 'screenshot' },
|
||||
{ label: 'AI总结', value: 'summary' },
|
||||
] as const
|
||||
|
||||
export 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' },
|
||||
] as const
|
||||
@@ -4,6 +4,7 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 修改滚动条轨道颜色 */
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable'
|
||||
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
|
||||
|
||||
interface IProps {
|
||||
NoteForm: React.ReactNode
|
||||
@@ -47,7 +48,9 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-auto p-4">{NoteForm}</div>
|
||||
<ScrollArea className="flex-1 overflow-auto">
|
||||
<div className=' p-4' >{NoteForm}</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -56,7 +59,9 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
|
||||
{/* 中间历史 */}
|
||||
<ResizablePanel defaultSize={16} minSize={10} maxSize={30}>
|
||||
<aside className="flex h-full flex-col overflow-hidden border-r border-neutral-200 bg-white">
|
||||
<div className="flex-1 overflow-auto p-4">{History}</div>
|
||||
<ScrollArea className="flex-1 overflow-auto">
|
||||
<div className="">{History}</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export const HomePage: FC = () => {
|
||||
return (
|
||||
<HomeLayout
|
||||
NoteForm={<NoteForm />}
|
||||
Preview={<MarkdownViewer status={status} content={content} />}
|
||||
Preview={<MarkdownViewer status={status} />}
|
||||
History={<History />}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Copy, Download } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
interface VersionNote {
|
||||
ver_id: string
|
||||
model_name?: string
|
||||
style?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
interface NoteHeaderProps {
|
||||
currentTask?: {
|
||||
markdown: VersionNote[] | string
|
||||
}
|
||||
isMultiVersion: boolean
|
||||
currentVerId: string
|
||||
setCurrentVerId: (id: string) => void
|
||||
modelName: string
|
||||
style: string
|
||||
noteStyles: { value: string; label: string }[]
|
||||
onCopy: () => void
|
||||
onDownload: () => void
|
||||
createAt?: string | Date
|
||||
setShowTranscribe: (show: boolean) => void
|
||||
}
|
||||
|
||||
export function MarkdownHeader({
|
||||
currentTask,
|
||||
isMultiVersion,
|
||||
currentVerId,
|
||||
setCurrentVerId,
|
||||
modelName,
|
||||
style,
|
||||
noteStyles,
|
||||
onCopy,
|
||||
onDownload,
|
||||
createAt,
|
||||
showTranscribe,
|
||||
setShowTranscribe
|
||||
}: NoteHeaderProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout
|
||||
if (copied) {
|
||||
timer = setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return () => clearTimeout(timer)
|
||||
}, [copied])
|
||||
|
||||
const handleCopy = () => {
|
||||
onCopy()
|
||||
setCopied(true)
|
||||
}
|
||||
|
||||
const styleName = noteStyles.find((v) => v.value === style)?.label || style
|
||||
|
||||
const reversedMarkdown: VersionNote[] =
|
||||
Array.isArray(currentTask?.markdown) ? [...currentTask!.markdown].reverse() : []
|
||||
|
||||
const formatDate = (date: string | Date | undefined) => {
|
||||
if (!date) return ""
|
||||
const d = typeof date === "string" ? new Date(date) : date
|
||||
if (isNaN(d.getTime())) return ""
|
||||
return d
|
||||
.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
.replace(/\//g, "-")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-3 border-b bg-white/95 py-2 px-4 backdrop-blur-sm">
|
||||
{/* 左侧区域:版本 + 标签 + 创建时间 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{isMultiVersion && (
|
||||
<Select value={currentVerId} onValueChange={setCurrentVerId}>
|
||||
<SelectTrigger className="h-8 w-[160px] text-sm">
|
||||
<div className="flex items-center">
|
||||
{(() => {
|
||||
const idx = currentTask?.markdown.findIndex(v => v.ver_id === currentVerId)
|
||||
return idx !== -1 ? `版本(${currentVerId.slice(0, 6)})` : ''
|
||||
})()}
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{(currentTask?.markdown || []).map((v, idx) => {
|
||||
const shortId = v.ver_id.slice(-6)
|
||||
return (
|
||||
<SelectItem key={v.ver_id} value={v.ver_id}>
|
||||
{`版本(${shortId})`}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Badge variant="secondary" className="bg-pink-100 text-pink-700 hover:bg-pink-200">
|
||||
{modelName}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="bg-cyan-100 text-cyan-700 hover:bg-cyan-200">
|
||||
{styleName}
|
||||
</Badge>
|
||||
|
||||
{createAt && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
创建时间: {formatDate(createAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧操作按钮 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={handleCopy} variant="ghost" size="sm" className="h-8 px-2">
|
||||
<Copy className="mr-1.5 h-4 w-4" />
|
||||
<span className="text-sm">{copied ? "已复制" : "复制"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>复制内容</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onDownload} variant="ghost" size="sm" className="h-8 px-2">
|
||||
<Download className="mr-1.5 h-4 w-4" />
|
||||
<span className="text-sm">导出 Markdown</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>下载为 Markdown 文件</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={
|
||||
() => {
|
||||
setShowTranscribe(!showTranscribe)
|
||||
}
|
||||
} variant="ghost" size="sm" className="h-8 px-2">
|
||||
{/*<Download className="mr-1.5 h-4 w-4" />*/}
|
||||
<span className="text-sm">原文参照</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent >原文参照</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,38 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { Copy, Download, FileText, ArrowRight } from 'lucide-react'
|
||||
import { toast } from 'react-hot-toast' // 你可以换成自己的通知组件
|
||||
import { Copy, Download, ArrowRight,Play } from 'lucide-react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import Error from '@/components/Lottie/error.tsx'
|
||||
import Loading from '@/components/Lottie/Loading.tsx'
|
||||
import Idle from '@/components/Lottie/Idle.tsx'
|
||||
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { atomDark as codeStyle } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import Zoom from 'react-medium-image-zoom'
|
||||
import 'react-medium-image-zoom/dist/styles.css'
|
||||
|
||||
import 'github-markdown-css/github-markdown-light.css'
|
||||
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'
|
||||
import gfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import 'github-markdown-css/github-markdown-light.css'
|
||||
import { FC } from 'react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
import { noteStyles } from '@/constant/note.ts'
|
||||
import { MarkdownHeader } from '@/pages/HomePage/components/MarkdownHeader.tsx'
|
||||
import TranscriptViewer from "@/pages/HomePage/components/transcriptViewer.tsx";
|
||||
|
||||
interface VersionNote {
|
||||
ver_id: string
|
||||
content: string
|
||||
style: string
|
||||
model_name: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string
|
||||
content: string | VersionNote[]
|
||||
status: 'idle' | 'loading' | 'success' | 'failed'
|
||||
}
|
||||
|
||||
@@ -36,227 +44,396 @@ const steps = [
|
||||
{ label: '保存完成', key: 'SUCCESS' },
|
||||
]
|
||||
|
||||
const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
|
||||
const MarkdownViewer: FC<MarkdownViewerProps> = ({ status }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [currentVerId, setCurrentVerId] = useState<string>('')
|
||||
const [selectedContent, setSelectedContent] = useState<string>('')
|
||||
const [modelName, setModelName] = useState<string>('')
|
||||
const [style, setStyle] = useState<string>('')
|
||||
const [createTime, setCreateTime] = useState<string>('')
|
||||
|
||||
const getCurrentTask = useTaskStore.getState().getCurrentTask
|
||||
const currentTask = useTaskStore(state => state.getCurrentTask())
|
||||
const taskStatus = currentTask?.status || 'PENDING'
|
||||
const retryTask = useTaskStore.getState().retryTask
|
||||
let firstHeadingRendered = false
|
||||
const isMultiVersion = Array.isArray(currentTask?.markdown)
|
||||
const [showTranscribe, setShowTranscribe]=useState(false)
|
||||
// 多版本内容处理
|
||||
useEffect(() => {
|
||||
if (!currentTask) return;
|
||||
|
||||
if (!isMultiVersion) {
|
||||
setCurrentVerId('') // 清空旧版本 ID
|
||||
setModelName(currentTask.formData.model_name)
|
||||
setStyle(currentTask.formData.style)
|
||||
setCreateTime(currentTask.createdAt)
|
||||
setSelectedContent(currentTask?.markdown)
|
||||
} else {
|
||||
const latestVerId = currentTask.markdown[currentTask.markdown.length - 1]?.ver_id
|
||||
setCurrentVerId(latestVerId) // 重置为最新版本
|
||||
}
|
||||
}, [currentTask?.id,taskStatus])
|
||||
useEffect(() => {
|
||||
if (!currentTask || !isMultiVersion) return;
|
||||
|
||||
const currentVer = currentTask.markdown.find(v => v.ver_id === currentVerId)
|
||||
if (currentVer) {
|
||||
setModelName(currentVer.model_name)
|
||||
setStyle(currentVer.style)
|
||||
setCreateTime(currentVer.created_at || '')
|
||||
setSelectedContent(currentVer.content)
|
||||
}
|
||||
}, [currentVerId, currentTask?.id])
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
await navigator.clipboard.writeText(selectedContent)
|
||||
setCopied(true)
|
||||
toast.success('已复制到剪贴板')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (e) {
|
||||
toast.error(`复制失败${e}`)
|
||||
toast.error('复制失败', e)
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
const currentTask = getCurrentTask()
|
||||
const currentTaskName = currentTask?.audioMeta.title
|
||||
|
||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
|
||||
const task = getCurrentTask()
|
||||
const name = task?.audioMeta.title || 'note'
|
||||
const blob = new Blob([selectedContent], { type: 'text/markdown;charset=utf-8' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `${currentTaskName}.md`
|
||||
link.download = `${name}.md`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
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>
|
||||
<p className="mt-2 text-xs text-neutral-500">这可能需要几秒钟时间,取决于视频长度</p>
|
||||
<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>
|
||||
<p className="mt-2 text-xs text-neutral-500">这可能需要几秒钟时间,取决于视频长度</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else if (status === 'idle') {
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center space-y-3 text-neutral-500">
|
||||
<Idle></Idle>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold">输入视频链接并点击“生成笔记”</p>
|
||||
<p className="mt-2 text-xs text-neutral-500">支持哔哩哔哩、YouTube 等视频平台</p>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'idle') {
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center space-y-3 text-neutral-500">
|
||||
<Idle />
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold">输入视频链接并点击“生成笔记”</p>
|
||||
<p className="mt-2 text-xs text-neutral-500">支持哔哩哔哩、YouTube 、抖音等视频平台</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'failed' && !isMultiVersion) {
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center gap-4 space-y-3">
|
||||
<Error />
|
||||
<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 (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* 顶部操作栏 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-xl font-semibold text-neutral-900">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
笔记内容
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleCopy} variant="outline" size="sm">
|
||||
<Copy className="mr-1 h-4 w-4" />
|
||||
{copied ? '已复制' : '复制'}
|
||||
</Button>
|
||||
<Button onClick={handleDownload} variant="outline" size="sm">
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
导出 Markdown
|
||||
</Button>
|
||||
<div className="flex h-screen w-full flex-col overflow-hidden">
|
||||
<MarkdownHeader
|
||||
currentTask={currentTask}
|
||||
isMultiVersion={isMultiVersion}
|
||||
currentVerId={currentVerId}
|
||||
setCurrentVerId={setCurrentVerId}
|
||||
modelName={modelName}
|
||||
style={style}
|
||||
noteStyles={noteStyles}
|
||||
onCopy={handleCopy}
|
||||
onDownload={handleDownload}
|
||||
createAt={createTime}
|
||||
showTranscribe={showTranscribe}
|
||||
setShowTranscribe={setShowTranscribe}
|
||||
/>
|
||||
|
||||
{/* 中间内容区域:滚动容器 */}
|
||||
<div className="flex-1 flex overflow-hidden bg-white py-2">
|
||||
{selectedContent && selectedContent !== 'loading' && selectedContent !== 'empty' ? (
|
||||
<>
|
||||
<ScrollArea className="w-full ">
|
||||
<div className={"w-full px-2 markdown-body"}>
|
||||
|
||||
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[gfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
// Headings with improved styling and anchor links
|
||||
h1: ({ children, ...props }) => (
|
||||
<h1
|
||||
className="scroll-m-20 text-3xl font-extrabold tracking-tight text-primary lg:text-4xl my-6"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children, ...props }) => (
|
||||
<h2
|
||||
className="scroll-m-20 border-b pb-2 text-2xl font-semibold tracking-tight text-primary first:mt-0 mt-10 mb-4"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children, ...props }) => (
|
||||
<h3
|
||||
className="scroll-m-20 text-xl font-semibold tracking-tight text-primary mt-8 mb-4"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children, ...props }) => (
|
||||
<h4
|
||||
className="scroll-m-20 text-lg font-semibold tracking-tight text-primary mt-6 mb-2"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
|
||||
// Paragraphs with better line height
|
||||
p: ({ children, ...props }) => (
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6" {...props}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
|
||||
// Enhanced links with special handling for "原片" links
|
||||
a: ({ href, children, ...props }) => {
|
||||
const isOriginLink = typeof children[0] === 'string' && (children[0] as string).startsWith('原片 @')
|
||||
|
||||
if (isOriginLink) {
|
||||
const timeMatch = (children[0] as string).match(/原片 @ (\d{2}:\d{2})/)
|
||||
const timeText = timeMatch ? timeMatch[1] : '原片'
|
||||
|
||||
return (
|
||||
<span className="origin-link inline-flex my-2">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors"
|
||||
{...props}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
<span>原片({timeText})</span>
|
||||
</a>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Default link styling with external indicator
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-primary underline underline-offset-4 hover:text-primary/80 inline-flex items-center gap-0.5"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{href?.startsWith('http') && <ExternalLink className="h-3 w-3 inline-block ml-0.5" />}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
|
||||
// Enhanced image with zoom capability
|
||||
img: ({ node, ...props }) => (
|
||||
<div className="my-8 flex justify-center">
|
||||
<Zoom>
|
||||
<img
|
||||
{...props}
|
||||
className="rounded-lg shadow-md max-w-full cursor-zoom-in object-cover transition-all hover:shadow-lg"
|
||||
style={{ maxHeight: '500px' }}
|
||||
/>
|
||||
</Zoom>
|
||||
</div>
|
||||
),
|
||||
|
||||
// Better strong/bold text
|
||||
strong: ({ children, ...props }) => (
|
||||
<strong className="font-bold text-primary" {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
|
||||
// Enhanced list items with support for "fake headings"
|
||||
li: ({ children, ...props }) => {
|
||||
const rawText = String(children)
|
||||
const isFakeHeading = /^(\*\*.+\*\*)$/.test(rawText.trim())
|
||||
|
||||
if (isFakeHeading) {
|
||||
return (
|
||||
<div className="text-lg font-bold my-4 text-primary">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="my-1" {...props}>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
|
||||
// Enhanced unordered lists
|
||||
ul: ({ children, ...props }) => (
|
||||
<ul className="my-6 ml-6 list-disc [&>li]:mt-2" {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
|
||||
// Enhanced ordered lists
|
||||
ol: ({ children, ...props }) => (
|
||||
<ol className="my-6 ml-6 list-decimal [&>li]:mt-2" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
|
||||
// Enhanced blockquotes
|
||||
blockquote: ({ children, ...props }) => (
|
||||
<blockquote
|
||||
className="mt-6 border-l-4 border-primary/20 pl-4 italic text-muted-foreground"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
// Enhanced code blocks with syntax highlighting and copy button
|
||||
code: ({ inline, className, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const codeContent = String(children).replace(/\n$/, '')
|
||||
|
||||
if (!inline && match) {
|
||||
return (
|
||||
<div className="group relative my-6 overflow-hidden rounded-lg border bg-muted shadow-sm">
|
||||
<div className="flex items-center justify-between bg-muted px-4 py-1.5 text-sm font-medium text-muted-foreground">
|
||||
<div>{match[1].toUpperCase()}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(codeContent)
|
||||
toast.success('代码已复制')
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-md bg-background/80 px-2 py-1 text-xs font-medium hover:bg-background transition-colors"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
style={codeStyle}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="!m-0 !bg-muted !p-0"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
background: 'transparent',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{codeContent}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline code styling
|
||||
return (
|
||||
<code
|
||||
className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
|
||||
// Enhanced tables
|
||||
table: ({ children, ...props }) => (
|
||||
<div className="my-6 w-full overflow-y-auto">
|
||||
<table className="w-full border-collapse text-sm" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
|
||||
// Table headers
|
||||
th: ({ children, ...props }) => (
|
||||
<th
|
||||
className="border border-muted-foreground/20 px-4 py-2 text-left font-medium [&[align=center]]:text-center [&[align=right]]:text-right"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
|
||||
// Table cells
|
||||
td: ({ children, ...props }) => (
|
||||
<td
|
||||
className="border border-muted-foreground/20 px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
|
||||
// Horizontal rule
|
||||
hr: ({ ...props }) => (
|
||||
<hr className="my-8 border-muted-foreground/20" {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{selectedContent}
|
||||
</ReactMarkdown>
|
||||
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{
|
||||
showTranscribe && (
|
||||
<div className={'ml-2 w-2/4'}>
|
||||
<TranscriptViewer/>
|
||||
</div>
|
||||
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="w-[300px] flex-col justify-items-center">
|
||||
<div className="bg-primary-light mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<ArrowRight className="text-primary h-8 w-8" />
|
||||
</div>
|
||||
<p className="mb-2 text-neutral-600">输入视频链接并点击"生成笔记"按钮</p>
|
||||
<p className="text-xs text-neutral-500">支持哔哩哔哩、YouTube等视频网站</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 滚动容器 */}
|
||||
|
||||
<div className="overflow-y-auto">
|
||||
{(content && content != 'loading') || content != 'empty' ? (
|
||||
<div className="markdown-body flex-1 bg-white">
|
||||
{' '}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[gfm,remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
img: ({ node, ...props }) => (
|
||||
<Zoom>
|
||||
<img
|
||||
{...props}
|
||||
className="rounded-lg shadow-md max-w-full cursor-pointer mx-auto my-4 max-h-[300px] "
|
||||
alt={props.alt || ''}
|
||||
/>
|
||||
</Zoom>
|
||||
),
|
||||
strong({ node, children, ...props }){
|
||||
return <strong className="text-lg font-bold my-4 text-blue-600" {...props}>{children}</strong>
|
||||
},
|
||||
li({ node, children, ...props }) {
|
||||
const rawText = String(children)
|
||||
|
||||
// 检测是否是“加粗的编号开头项”,比如 "**2. 算法摄影的兴起**"
|
||||
const isFakeHeading = /^(\*\*.+\*\*)$/.test(rawText.trim())
|
||||
|
||||
if (isFakeHeading) {
|
||||
return (
|
||||
<p className="text-lg font-bold my-4 text-gray-800 text-left">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return <li {...props}>{children}</li>
|
||||
},
|
||||
h1({ node, children, ...props }) {
|
||||
return (
|
||||
<h1
|
||||
className="text-3xl text-center font-bold my-6 text-blue-600"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
},
|
||||
h2({ node, children, ...props }) {
|
||||
return (
|
||||
<h2
|
||||
className="text-2xl font-bold my-4 text-blue-600"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
},
|
||||
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const codeContent = String(children).replace(/\n$/, '')
|
||||
|
||||
if (!inline && match) {
|
||||
return (
|
||||
<div className="group relative">
|
||||
<SyntaxHighlighter
|
||||
style={codeStyle}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
{...props}
|
||||
>
|
||||
{codeContent}
|
||||
</SyntaxHighlighter>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('点击负责')
|
||||
navigator.clipboard.writeText(codeContent)
|
||||
toast.success('代码已复制')
|
||||
}}
|
||||
className="absolute top-2 right-2 hidden items-center gap-1 rounded border border-gray-300 bg-white/70 px-2 py-1 text-xs shadow-sm transition group-hover:flex hover:bg-white"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<code className="rounded bg-gray-100 px-1 py-0.5 text-sm" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<div className="w-[300px] flex-col justify-items-center">
|
||||
<div className="bg-primary-light mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<ArrowRight className="text-primary h-8 w-8" />
|
||||
</div>
|
||||
<p className="mb-2 text-neutral-600">输入视频链接并点击"生成笔记"按钮</p>
|
||||
<p className="text-xs text-neutral-500">支持哔哩哔哩、YouTube等视频网站</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/*<div className="markdown-body flex-1 overflow-y-auto bg-white">*/}
|
||||
{/* {content ? (*/}
|
||||
{/* */}
|
||||
{/* ) : (*/}
|
||||
{/* <>*/}
|
||||
{/* <div className="w-16 h-16 bg-primary-light rounded-full flex items-center justify-center mb-4">*/}
|
||||
{/* <ArrowRight className="h-8 w-8 text-primary" />*/}
|
||||
{/* </div>*/}
|
||||
{/* <p className="text-neutral-600 mb-2">输入视频链接并点击"生成笔记"按钮</p>*/}
|
||||
{/* <p className="text-xs text-neutral-500">支持哔哩哔哩、YouTube、腾讯视频和爱奇艺</p>*/}
|
||||
{/* </>*/}
|
||||
{/* )}*/}
|
||||
{/*</div>*/}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@ import { useForm, useWatch } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Info, Loader2 } from 'lucide-react'
|
||||
import { Info, Loader2 ,Plus} from 'lucide-react'
|
||||
import { message, Alert } from 'antd'
|
||||
|
||||
import { generateNote } from '@/services/note.ts'
|
||||
import { uploadFile } from '@/services/upload.ts'
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
@@ -21,26 +20,8 @@ import {Button} from "@/components/ui/button.tsx";
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Textarea} from "@/components/ui/textarea.tsx";
|
||||
import {noteStyles,noteFormats} from "@/constant/note.ts";
|
||||
|
||||
/* -------------------- 常量 -------------------- */
|
||||
const noteFormats = [
|
||||
{ label: '目录', value: 'toc' },
|
||||
{ label: '原片跳转', value: 'link' },
|
||||
{ label: '原片截图', value: 'screenshot' },
|
||||
{ label: 'AI总结', value: 'summary' },
|
||||
] as const
|
||||
|
||||
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' },
|
||||
] as const
|
||||
|
||||
/* -------------------- 校验 Schema -------------------- */
|
||||
const formSchema = z.object({
|
||||
@@ -118,9 +99,10 @@ const CheckboxGroup = ({
|
||||
/* -------------------- 主组件 -------------------- */
|
||||
const NoteForm = () => {
|
||||
/* ---- 全局状态 ---- */
|
||||
const { addPendingTask, currentTaskId, getCurrentTask } = useTaskStore()
|
||||
const { addPendingTask, currentTaskId, setCurrentTask,getCurrentTask ,retryTask} = useTaskStore()
|
||||
const { loadEnabledModels, modelList, showFeatureHint, setShowFeatureHint } = useModelStore()
|
||||
|
||||
|
||||
/* ---- 表单 ---- */
|
||||
const form = useForm<NoteFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -134,21 +116,35 @@ const NoteForm = () => {
|
||||
format: [],
|
||||
},
|
||||
})
|
||||
const currentTask = getCurrentTask()
|
||||
|
||||
/* ---- 派生状态(只 watch 一次,提高性能) ---- */
|
||||
const platform = useWatch({ control: form.control, name: 'platform' }) as string
|
||||
const videoUnderstandingEnabled = useWatch({ control: form.control, name: 'video_understanding' })
|
||||
const editing = currentTask && currentTask.id
|
||||
|
||||
/* ---- 副作用 ---- */
|
||||
/* ---- 副作用 ---- */
|
||||
useEffect(() => {
|
||||
loadEnabledModels()
|
||||
|
||||
return}, [])
|
||||
useEffect(() => {
|
||||
const currentTask = getCurrentTask()
|
||||
const { formData } = currentTask || {}
|
||||
if (!currentTask) return
|
||||
form.reset(
|
||||
{
|
||||
...formData,
|
||||
extras: formData?.extras || '',
|
||||
}
|
||||
|
||||
)
|
||||
}, [currentTaskId])
|
||||
|
||||
/* ---- 帮助函数 ---- */
|
||||
const isGenerating = () =>
|
||||
!['SUCCESS', 'FAILED', undefined].includes(getCurrentTask()?.status)
|
||||
|
||||
const generating = isGenerating()
|
||||
const handleFileUpload = async (file: File, cb: (url: string) => void) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
@@ -162,40 +158,83 @@ const NoteForm = () => {
|
||||
}
|
||||
|
||||
const onSubmit = async (values: NoteFormValues) => {
|
||||
const payload:NoteFormValues = {
|
||||
...values,
|
||||
provider_id: modelList.find(m => m.model_name === values.model_name)!.provider_id,
|
||||
|
||||
const payload:NoteFormValues = {
|
||||
...values,
|
||||
provider_id: modelList.find(m => m.model_name === values.model_name)!.provider_id,
|
||||
task_id: currentTaskId || '',
|
||||
}
|
||||
if (currentTaskId){
|
||||
retryTask(currentTaskId,payload)
|
||||
return
|
||||
}
|
||||
|
||||
message.success('已提交任务')
|
||||
const { data } = await generateNote(payload)
|
||||
addPendingTask(data.task_id, values.platform, payload)
|
||||
}
|
||||
const handleCreateNew = () => {
|
||||
// 🔁 这里清空当前任务状态
|
||||
// 比如调用 resetCurrentTask() 或者 navigate 到一个新页面
|
||||
setCurrentTask(null)
|
||||
|
||||
}
|
||||
const FormButton = () => {
|
||||
|
||||
|
||||
const label = generating
|
||||
? '正在生成…'
|
||||
: editing
|
||||
? '重新生成'
|
||||
: '生成笔记'
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className={!editing?'w-full':'w-2/3'+" bg-primary"} disabled={generating}>
|
||||
{generating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
{editing && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-1/3"
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建笔记
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------- 渲染 -------------------- */
|
||||
return (
|
||||
<ScrollArea className="sm:h-[400px] md:h-[800px]">
|
||||
<div className="h-full w-full">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* 顶部按钮 */}
|
||||
<Button type="submit" className="w-full bg-primary" disabled={isGenerating()}>
|
||||
{isGenerating() && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isGenerating() ? '正在生成…' : '生成笔记'}
|
||||
</Button>
|
||||
<FormButton></FormButton>
|
||||
|
||||
{/* 视频链接 & 平台 */}
|
||||
<SectionHeader title="视频链接" tip="支持 B 站、YouTube 等平台" />
|
||||
<div className="flex gap-2">
|
||||
{/* 平台选择 */}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="platform"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<Select disabled={!!editing} value={field.value} onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectContent >
|
||||
<SelectItem value="bilibili">哔哩哔哩</SelectItem>
|
||||
<SelectItem value="youtube">YouTube</SelectItem>
|
||||
<SelectItem value="douyin">抖音</SelectItem>
|
||||
@@ -214,65 +253,106 @@ const NoteForm = () => {
|
||||
<FormItem className="flex-1">
|
||||
{platform === 'local' ? (
|
||||
<>
|
||||
<Input placeholder="请输入本地视频路径" {...field} />
|
||||
<div
|
||||
className="hover:border-primary mt-2 flex h-40 cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-gray-300 transition-colors"
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation() }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) handleFileUpload(file, field.onChange)
|
||||
}}
|
||||
onClick={() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'video/*'
|
||||
input.onchange = e => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) handleFileUpload(file, field.onChange)
|
||||
}
|
||||
input.click()
|
||||
}}
|
||||
>
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
拖拽文件到这里上传 <br />
|
||||
<span className="text-xs text-gray-400">或点击选择文件</span>
|
||||
</p>
|
||||
</div>
|
||||
<Input disabled={!!editing} placeholder="请输入本地视频路径" {...field} />
|
||||
|
||||
</>
|
||||
) : (
|
||||
<Input placeholder="请输入视频网站链接" {...field} />
|
||||
<Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} />
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 模型选择 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" />
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{modelList.map(m => (
|
||||
<SelectItem key={m.model_name} value={m.model_name}>
|
||||
{m.model_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="video_url"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
{platform === 'local' && (
|
||||
<>
|
||||
<div
|
||||
className="hover:border-primary mt-2 flex h-40 cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-gray-300 transition-colors"
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation() }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) handleFileUpload(file, field.onChange)
|
||||
}}
|
||||
onClick={() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'video/*'
|
||||
input.onchange = e => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) handleFileUpload(file, field.onChange)
|
||||
}
|
||||
input.click()
|
||||
}}
|
||||
>
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
拖拽文件到这里上传 <br />
|
||||
<span className="text-xs text-gray-400">或点击选择文件</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
{/* 模型选择 */}
|
||||
<FormField
|
||||
className="w-full"
|
||||
control={form.control}
|
||||
name="model_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" />
|
||||
<Select value={field.value} onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full min-w-0 truncate" ><SelectValue /></SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{modelList.map(m => (
|
||||
<SelectItem key={m.id} value={m.model_name}>
|
||||
{m.model_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* 笔记风格 */}
|
||||
<FormField
|
||||
className="w-full"
|
||||
control={form.control}
|
||||
name="style"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="笔记风格" tip="选择生成笔记的呈现风格" />
|
||||
<Select value={field.value} onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full min-w-0 truncate" ><SelectValue /></SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{noteStyles.map(({ label, value }) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* 视频理解 */}
|
||||
<SectionHeader title="视频理解" tip="将视频截图发给多模态模型辅助分析" />
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -293,11 +373,7 @@ const NoteForm = () => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Alert
|
||||
type="info"
|
||||
message="推荐多模态模型:qwen2.5-vl-72b-instruct / gpt-4o"
|
||||
className="text-sm"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 采样间隔 */}
|
||||
<FormField
|
||||
@@ -346,29 +422,21 @@ const NoteForm = () => {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Alert
|
||||
closable
|
||||
type="error"
|
||||
message={
|
||||
<div>
|
||||
<strong>提示:</strong>
|
||||
<p>视频理解功能必须使用多模态模型。</p>
|
||||
|
||||
</div>
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 笔记风格 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="style"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="笔记风格" tip="选择生成笔记的呈现风格" />
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{noteStyles.map(({ label, value }) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
{/* 笔记格式 */}
|
||||
<FormField
|
||||
@@ -404,7 +472,7 @@ const NoteForm = () => {
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
import { FC } from 'react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||
import { Badge } from '@/components/ui/badge.tsx'
|
||||
import { cn } from '@/lib/utils.ts'
|
||||
import { Trash } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import PinyinMatch from 'pinyin-match'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip.tsx'
|
||||
import LazyImage from "@/components/LazyImage.tsx";
|
||||
import {FC, useState ,useEffect } from 'react'
|
||||
|
||||
interface NoteHistoryProps {
|
||||
onSelect: (taskId: string) => void
|
||||
@@ -20,20 +24,59 @@ interface NoteHistoryProps {
|
||||
const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
|
||||
const tasks = useTaskStore(state => state.tasks)
|
||||
const removeTask = useTaskStore(state => state.removeTask)
|
||||
const [rawSearch, setRawSearch] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const fuse = new Fuse(tasks, {
|
||||
keys: ['audioMeta.title'],
|
||||
threshold: 0.4 // 匹配精度(越低越严格)
|
||||
})
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (rawSearch === '') return
|
||||
setSearch(rawSearch)
|
||||
}, 300) // 300ms 防抖
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return () => clearTimeout(timer)
|
||||
}, [rawSearch])
|
||||
const filteredTasks = search.trim()
|
||||
? fuse.search(search).map(result => result.item)
|
||||
: tasks
|
||||
if (filteredTasks.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-neutral-200 bg-neutral-50 py-6 text-center">
|
||||
<p className="text-sm text-neutral-500">暂无历史记录</p>
|
||||
</div>
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索笔记标题..."
|
||||
className="w-full rounded border border-neutral-300 px-3 py-1 text-sm outline-none focus:border-primary"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border border-neutral-200 bg-neutral-50 py-6 text-center">
|
||||
<p className="text-sm text-neutral-500">暂无记录</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索笔记标题..."
|
||||
className="w-full rounded border border-neutral-300 px-3 py-1 text-sm outline-none focus:border-primary"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 overflow-hidden">
|
||||
{tasks.map(task => (
|
||||
{filteredTasks.map(task => (
|
||||
<div
|
||||
onClick={() => onSelect(task.id)}
|
||||
className={cn(
|
||||
'flex cursor-pointer flex-col rounded-md border border-neutral-200 p-3',
|
||||
selectedId === task.id && 'border-primary bg-primary-light'
|
||||
@@ -42,7 +85,7 @@ const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
|
||||
<div
|
||||
key={task.id}
|
||||
className={cn('flex items-center gap-4')}
|
||||
onClick={() => onSelect(task.id)}
|
||||
|
||||
>
|
||||
{/* 封面图 */}
|
||||
{task.platform === 'local' ? (
|
||||
@@ -54,15 +97,15 @@ const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
|
||||
className="h-10 w-12 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={
|
||||
task.audioMeta.cover_url
|
||||
? `/api/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
|
||||
: '/placeholder.png'
|
||||
}
|
||||
alt="封面"
|
||||
className="h-10 w-12 rounded-md object-cover"
|
||||
/>
|
||||
<LazyImage
|
||||
|
||||
src={
|
||||
task.audioMeta.cover_url
|
||||
? `/api/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
|
||||
: '/placeholder.png'
|
||||
}
|
||||
alt="封面"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 标题 + 状态 */}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import { useTaskStore } from "@/store/taskStore"
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import { Play } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
|
||||
|
||||
interface Segment {
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
|
||||
}
|
||||
|
||||
interface Task {
|
||||
transcript?: {
|
||||
segments?: Segment[]
|
||||
}
|
||||
}
|
||||
|
||||
const TranscriptViewer = () => {
|
||||
const getCurrentTask = useTaskStore((state) => state.getCurrentTask)
|
||||
const currentTaskId = useTaskStore((state) => state.currentTaskId)
|
||||
const [task, setTask] = useState<Task | null>(null)
|
||||
const [activeSegment, setActiveSegment] = useState<number | null>(null)
|
||||
const segmentRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setTask(getCurrentTask())
|
||||
}, [currentTaskId, getCurrentTask])
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
const handleSegmentClick = (index: number) => {
|
||||
setActiveSegment(index)
|
||||
// Here you could add functionality to play the audio from this segment
|
||||
}
|
||||
|
||||
const scrollToSegment = (index: number) => {
|
||||
segmentRefs.current[index]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="transcript-viewer flex h-full w-full flex-col rounded-md border bg-white p-4 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-medium">转写结果</h2>
|
||||
{!task?.transcript?.segments?.length ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">暂无转写内容</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
|
||||
<div className="mb-3 grid grid-cols-[80px_1fr] gap-2 border-b pb-2 text-xs font-medium text-muted-foreground">
|
||||
<div>时间</div>
|
||||
<div>内容</div>
|
||||
</div>
|
||||
<ScrollArea className="w-full overflow-y-auto">
|
||||
|
||||
<div className="space-y-1">
|
||||
{task.transcript.segments.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={(el) => (segmentRefs.current[index] = el)}
|
||||
className={cn(
|
||||
"group grid grid-cols-[80px_1fr] gap-2 rounded-md p-2 transition-colors hover:bg-slate-50",
|
||||
activeSegment === index && "bg-slate-100",
|
||||
)}
|
||||
onClick={() => handleSegmentClick(index)}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs text-slate-500">
|
||||
<button
|
||||
className="invisible rounded-full p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-700 group-hover:visible"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// Add play functionality here
|
||||
}}
|
||||
>
|
||||
{/*<Play className="h-3 w-3" />*/}
|
||||
</button>
|
||||
<span>{formatTime(segment.start)}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm leading-relaxed text-slate-700">
|
||||
{segment.speaker && (
|
||||
<span className="mr-2 rounded bg-slate-200 px-1.5 py-0.5 text-xs font-medium text-slate-700">
|
||||
{segment.speaker}
|
||||
</span>
|
||||
)}
|
||||
{segment.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{task?.transcript?.segments?.length > 0 && (
|
||||
<div className="mt-4 flex justify-between border-t pt-3 text-xs text-slate-500">
|
||||
<span>共 {task.transcript.segments.length} 条片段</span>
|
||||
<span>总时长: {formatTime(task.transcript.segments[task.transcript.segments.length - 1]?.end || 0)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TranscriptViewer
|
||||
@@ -1,4 +1,11 @@
|
||||
import { BotMessageSquare, Captions, HardDriveDownload, Wrench } from 'lucide-react'
|
||||
import {
|
||||
BotMessageSquare,
|
||||
SquareChevronRight,
|
||||
Captions,
|
||||
HardDriveDownload,
|
||||
Wrench,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
import MenuBar, { IMenuProps } from '@/pages/SettingPage/components/menuBar.tsx'
|
||||
|
||||
const Menu = () => {
|
||||
@@ -25,6 +32,18 @@ const Menu = () => {
|
||||
// },
|
||||
// //其他配置
|
||||
// {
|
||||
// id: 'prompt',
|
||||
// name: '提示词设置',
|
||||
// icon: <SquareChevronRight />,
|
||||
// path: '/settings/prompt',
|
||||
// },
|
||||
{
|
||||
id: 'about',
|
||||
name: '关于',
|
||||
icon: <Info />,
|
||||
path: '/settings/about',
|
||||
},
|
||||
// {
|
||||
// id: 'other',
|
||||
// name: '其他配置',
|
||||
// icon: <Wrench />,
|
||||
|
||||
4
BillNote_frontend/src/pages/SettingPage/Prompt.tsx
Normal file
4
BillNote_frontend/src/pages/SettingPage/Prompt.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
const Prompt = () => {
|
||||
return <div className={'flex h-full w-full bg-white'}>prompt</div>
|
||||
}
|
||||
export default Prompt
|
||||
226
BillNote_frontend/src/pages/SettingPage/about.tsx
Normal file
226
BillNote_frontend/src/pages/SettingPage/about.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Github, Star, ExternalLink, Download } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||
|
||||
export default function AboutPage() {
|
||||
const images = [
|
||||
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504102850.png',
|
||||
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504103028.png',
|
||||
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504103304.png',
|
||||
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504103625.png',
|
||||
]
|
||||
return (
|
||||
<ScrollArea className={'h-full overflow-y-auto bg-white'}>
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
{/* Hero Section */}
|
||||
<div className="mb-16 flex flex-col items-center justify-center text-center">
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<img
|
||||
src="/public/icon.svg"
|
||||
alt="BiliNote Logo"
|
||||
width={50}
|
||||
height={50}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<h1 className="text-4xl font-bold">BiliNote v1.4.0</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6 text-xl italic">
|
||||
AI 视频笔记生成工具 让 AI 为你的视频做笔记
|
||||
</p>
|
||||
|
||||
<div className="mb-8 flex flex-wrap justify-center gap-2">
|
||||
<Badge variant="secondary">MIT License</Badge>
|
||||
<Badge variant="secondary">React</Badge>
|
||||
<Badge variant="secondary">FastAPI</Badge>
|
||||
<Badge variant="secondary">Docker Compose</Badge>
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Button asChild>
|
||||
<a href="https://www.bilinote.app" target="_blank">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
体验 BiliNote
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/JefferyHcool/BiliNote" target="_blank">
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
GitHub 仓库
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/JefferyHcool/BiliNote/releases" target="_blank">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
下载桌面版
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Introduction */}
|
||||
<section className="mb-16">
|
||||
<h2 className="mb-6 text-center text-3xl font-bold">✨ 项目简介</h2>
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<p className="text-lg">
|
||||
BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、YouTube、抖音等视频链接,
|
||||
自动提取内容并生成结构清晰、重点明确的 Markdown
|
||||
格式笔记。支持插入截图、原片跳转等功能。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="mb-16">
|
||||
<h2 className="mb-8 text-center text-3xl font-bold">🔧 功能特性</h2>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[
|
||||
{ title: '多平台支持', desc: '支持 Bilibili、YouTube、本地视频、抖音等多个平台' },
|
||||
{ title: '笔记格式选择', desc: '支持返回多种笔记格式,满足不同需求' },
|
||||
{ title: '笔记风格选择', desc: '支持多种笔记风格,个性化定制' },
|
||||
{ title: '多模态视频理解', desc: '结合视觉和音频内容,全面理解视频' },
|
||||
{ title: '自定义 GPT 配置', desc: '支持自行配置 GPT 大模型' },
|
||||
{ title: '本地音频转写', desc: '支持 Fast-Whisper 等本地模型音频转写' },
|
||||
{ title: '结构化笔记', desc: '自动生成结构化 Markdown 笔记' },
|
||||
{ title: '智能截图', desc: '可选插入自动截取的关键画面' },
|
||||
{ title: '内容跳转', desc: '支持关联原视频的内容跳转链接' },
|
||||
].map((feature, index) => (
|
||||
<Card key={index} className="h-full">
|
||||
<CardContent className="pt-2">
|
||||
<h3 className="mb-2 text-xl font-semibold">{feature.title}</h3>
|
||||
<p className="text-muted-foreground">{feature.desc}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Screenshots Section */}
|
||||
<section className="mb-16">
|
||||
<h2 className="mb-8 text-center text-3xl font-bold">📸 截图预览</h2>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{images.map(num => (
|
||||
<div key={num} className="overflow-hidden rounded-lg border shadow-sm">
|
||||
<img
|
||||
src={num}
|
||||
alt={`BiliNote Screenshot ${num}`}
|
||||
width={600}
|
||||
height={400}
|
||||
className="w-full object-cover transition-transform hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Start Section */}
|
||||
<section className="mb-16">
|
||||
<h2 className="mb-8 text-center text-3xl font-bold">🚀 快速开始</h2>
|
||||
<Tabs defaultValue="manual" className="mx-auto max-w-3xl">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="manual">手动安装</TabsTrigger>
|
||||
<TabsTrigger value="docker">Docker 部署</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="manual" className="mt-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">1. 克隆仓库</h3>
|
||||
<div className="bg-muted rounded-md p-4 font-mono text-sm">
|
||||
git clone https://github.com/JefferyHcool/BiliNote.git
|
||||
<br />
|
||||
cd BiliNote
|
||||
<br />
|
||||
mv .env.example .env
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">2. 启动后端(FastAPI)</h3>
|
||||
<div className="bg-muted rounded-md p-4 font-mono text-sm">
|
||||
cd backend
|
||||
<br />
|
||||
pip install -r requirements.txt
|
||||
<br />
|
||||
python main.py
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">3. 启动前端(Vite + React)</h3>
|
||||
<div className="bg-muted rounded-md p-4 font-mono text-sm">
|
||||
cd BiliNote_frontend
|
||||
<br />
|
||||
pnpm install
|
||||
<br />
|
||||
pnpm dev
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
访问:<code className="bg-muted rounded px-2 py-1">http://localhost:5173</code>
|
||||
</p>
|
||||
</TabsContent>
|
||||
<TabsContent value="docker" className="mt-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">1. 克隆仓库</h3>
|
||||
<div className="bg-muted rounded-md p-4 font-mono text-sm">
|
||||
git clone https://github.com/JefferyHcool/BiliNote.git
|
||||
<br />
|
||||
cd BiliNote
|
||||
<br />
|
||||
mv .env.example .env
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">2. 启动 Docker Compose</h3>
|
||||
<div className="bg-muted rounded-md p-4 font-mono text-sm">
|
||||
docker compose up --build
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
默认端口:
|
||||
<br />
|
||||
前端:http://localhost:${'{FRONTEND_PORT}'}
|
||||
<br />
|
||||
后端:http://localhost:${'{BACKEND_PORT}'}
|
||||
<br />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
.env 文件中可自定义端口与环境配置
|
||||
</span>
|
||||
</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
{/* Community Section */}
|
||||
<section className="mb-16">
|
||||
<h2 className="mb-8 text-center text-3xl font-bold">联系和加入社区</h2>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="flex flex-col items-center justify-center gap-8">
|
||||
<div className="text-center">
|
||||
<h3 className="mb-3 text-xl font-semibold">BiliNote 交流 QQ 群</h3>
|
||||
<p className="text-lg font-medium">785367111</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="mb-3 text-xl font-semibold">BiliNote 交流微信群</h3>
|
||||
<div className="bg-muted mx-auto flex h-52 w-52 items-center justify-center rounded-md">
|
||||
<img src={'https://common-1304618721.cos.ap-chengdu.myqcloud.com/wechat.png'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* License Section */}
|
||||
<section className="mb-8 text-center">
|
||||
<h2 className="mb-4 text-3xl font-bold">📜 License</h2>
|
||||
<p>MIT License</p>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t pt-8 text-center">
|
||||
<p className="mb-4">💬 你的支持与反馈是我持续优化的动力!欢迎 PR、提 issue、Star ⭐️</p>
|
||||
</footer>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
@@ -13,10 +13,10 @@ interface IMenuItem {
|
||||
menuItem: IMenuProps
|
||||
}
|
||||
|
||||
const MenuBar: FC<IMenuItem> = ({ menuItem }) => {
|
||||
const MenuBar: ({ menuItem }: { menuItem: any }) => JSX.Element = ({ menuItem }) => {
|
||||
const location = useLocation()
|
||||
const isActive = location.pathname.startsWith(menuItem.path + '/')
|
||||
|| location.pathname === menuItem.path
|
||||
const isActive =
|
||||
location.pathname.startsWith(menuItem.path + '/') || location.pathname === menuItem.path
|
||||
|
||||
return (
|
||||
<Link to={menuItem.path} className="w-full">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { delete_task, generateNote } from '@/services/note.ts'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
|
||||
export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILD'
|
||||
|
||||
@@ -26,10 +28,17 @@ export interface Transcript {
|
||||
raw: any
|
||||
segments: Segment[]
|
||||
}
|
||||
export interface Markdown {
|
||||
ver_id: string
|
||||
content: string
|
||||
style: string
|
||||
model_name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
markdown: string
|
||||
markdown: string|Markdown [] //为了兼容之前的笔记
|
||||
transcript: Transcript
|
||||
status: TaskStatus
|
||||
audioMeta: AudioMeta
|
||||
@@ -64,6 +73,7 @@ export const useTaskStore = create<TaskStore>()(
|
||||
currentTaskId: null,
|
||||
|
||||
addPendingTask: (taskId: string, platform: string, formData: any) =>
|
||||
|
||||
set(state => ({
|
||||
tasks: [
|
||||
{
|
||||
@@ -95,24 +105,82 @@ export const useTaskStore = create<TaskStore>()(
|
||||
})),
|
||||
|
||||
updateTaskContent: (id, data) =>
|
||||
set(state => ({
|
||||
tasks: state.tasks.map(task => (task.id === id ? { ...task, ...data } : task)),
|
||||
})),
|
||||
set(state => ({
|
||||
tasks: state.tasks.map(task => {
|
||||
if (task.id !== id) return task
|
||||
|
||||
if (task.status === 'SUCCESS' && data.status === 'SUCCESS') return task
|
||||
|
||||
// 如果是 markdown 字符串,封装为版本
|
||||
if (typeof data.markdown === 'string') {
|
||||
const prev = task.markdown
|
||||
const newVersion: Markdown = {
|
||||
ver_id: `${task.id}-${uuidv4()}`,
|
||||
content: data.markdown,
|
||||
style: task.formData.style || '',
|
||||
model_name: task.formData.model_name || '',
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
let updatedMarkdown: Markdown[]
|
||||
if (Array.isArray(prev)) {
|
||||
updatedMarkdown = [newVersion, ...prev]
|
||||
} else {
|
||||
updatedMarkdown = [
|
||||
newVersion,
|
||||
...(typeof prev === 'string' && prev
|
||||
? [{
|
||||
ver_id: `${task.id}-${uuidv4()}`,
|
||||
content: prev,
|
||||
style: task.formData.style || '',
|
||||
model_name: task.formData.model_name || '',
|
||||
created_at: new Date().toISOString(),
|
||||
}]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
...task,
|
||||
...data,
|
||||
markdown: updatedMarkdown,
|
||||
}
|
||||
}
|
||||
|
||||
return { ...task, ...data }
|
||||
}),
|
||||
})),
|
||||
|
||||
|
||||
getCurrentTask: () => {
|
||||
const currentTaskId = get().currentTaskId
|
||||
return get().tasks.find(task => task.id === currentTaskId) || null
|
||||
},
|
||||
retryTask: async (id: string) => {
|
||||
const task = get().tasks.find(task => task.id === id).formData
|
||||
retryTask: async (id: string, payload?: any) => {
|
||||
const task = get().tasks.find(task => task.id === id)
|
||||
if (!task) return
|
||||
|
||||
const newFormData = payload || task.formData
|
||||
|
||||
await generateNote({
|
||||
task_id: id,
|
||||
...task,
|
||||
...newFormData,
|
||||
})
|
||||
|
||||
set(state => ({
|
||||
tasks: state.tasks.map(task => (task.id === id ? { ...task, status: 'PENDING' } : task)),
|
||||
tasks: state.tasks.map(t =>
|
||||
t.id === id
|
||||
? {
|
||||
...t,
|
||||
formData: newFormData, // ✅ 显式更新 formData
|
||||
status: 'PENDING',
|
||||
}
|
||||
: t
|
||||
),
|
||||
}))
|
||||
},
|
||||
|
||||
|
||||
removeTask: async id => {
|
||||
const task = get().tasks.find(t => t.id === id)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import yt_dlp
|
||||
from app.downloaders.base import Downloader, DownloadQuality, QUALITY_MAP
|
||||
from app.models.notes_model import AudioDownloadResult
|
||||
from app.utils.path_helper import get_data_dir
|
||||
from app.utils.url_parser import extract_video_id
|
||||
|
||||
|
||||
class BilibiliDownloader(Downloader, ABC):
|
||||
@@ -69,10 +70,19 @@ class BilibiliDownloader(Downloader, ABC):
|
||||
"""
|
||||
下载视频,返回视频文件路径
|
||||
"""
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = get_data_dir()
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
print("video_url",video_url)
|
||||
video_id=extract_video_id(video_url, "bilibili")
|
||||
video_path = os.path.join(output_dir, f"{video_id}.mp4")
|
||||
if os.path.exists(video_path):
|
||||
return video_path
|
||||
|
||||
# 检查是否已经存在
|
||||
|
||||
|
||||
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
|
||||
|
||||
ydl_opts = {
|
||||
|
||||
@@ -249,13 +249,21 @@ class DouyinDownloader(Downloader):
|
||||
)
|
||||
|
||||
def download_video(self, video_url: str, output_dir: Union[str, None] = None) -> str:
|
||||
|
||||
try:
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = get_data_dir()
|
||||
if not output_dir:
|
||||
output_dir = self.cache_data
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
video_id = self.extract_video_id(video_url)
|
||||
video_path = os.path.join(output_dir, f"{video_id}.mp4")
|
||||
if os.path.exists(video_path):
|
||||
return video_path
|
||||
|
||||
|
||||
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
|
||||
|
||||
video_data = self.fetch_video_info(video_url)
|
||||
|
||||
@@ -7,6 +7,7 @@ import yt_dlp
|
||||
from app.downloaders.base import Downloader, DownloadQuality
|
||||
from app.models.notes_model import AudioDownloadResult
|
||||
from app.utils.path_helper import get_data_dir
|
||||
from app.utils.url_parser import extract_video_id
|
||||
|
||||
|
||||
class YoutubeDownloader(Downloader, ABC):
|
||||
@@ -67,12 +68,15 @@ class YoutubeDownloader(Downloader, ABC):
|
||||
"""
|
||||
if output_dir is None:
|
||||
output_dir = get_data_dir()
|
||||
|
||||
video_id = extract_video_id(video_url, "youtube")
|
||||
video_path = os.path.join(output_dir, f"{video_id}.mp4")
|
||||
if os.path.exists(video_path):
|
||||
return video_path
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
|
||||
|
||||
ydl_opts = {
|
||||
'format': 'worst[ext=mp4]/worst',
|
||||
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]',
|
||||
'outtmpl': output_path,
|
||||
'noplaylist': True,
|
||||
'quiet': False,
|
||||
|
||||
@@ -35,11 +35,10 @@ BASE_PROMPT = '''
|
||||
根据上面的分段转录内容,生成结构化的笔记,遵循以下原则:
|
||||
|
||||
1. **完整信息**:记录尽可能多的相关细节,确保内容全面。
|
||||
2. **清晰结构**:用合适的标题级别(`##`,`###`)整理内容,概述每个部分的要点。主标题用`#`来标识(如果额外重要的任务有格式需求可以不遵守)
|
||||
3. **去除无关内容**:省略广告、填充词、问候语和不相关的言论。
|
||||
4. **保留关键细节**:保留重要事实、示例、结论和建议。(如果额外重要的任务有格式需求可以不遵守)
|
||||
5. **可读布局**:必要时使用项目符号,并保持段落简短,增强可读性。(如果额外重要的任务有格式需求可以不遵守)
|
||||
6. 视频中提及的数学公式必须保留,并以 LaTeX 语法形式呈现,适合 Markdown 渲染。
|
||||
2. **去除无关内容**:省略广告、填充词、问候语和不相关的言论。
|
||||
3. **保留关键细节**:保留重要事实、示例、结论和建议。(如果额外重要的任务有格式需求可以不遵守)
|
||||
4. **可读布局**:必要时使用项目符号,并保持段落简短,增强可读性。(如果额外重要的任务有格式需求可以不遵守)
|
||||
5. 视频中提及的数学公式必须保留,并以 LaTeX 语法形式呈现,适合 Markdown 渲染。
|
||||
|
||||
|
||||
请始终遵循此规则。
|
||||
|
||||
@@ -58,7 +58,7 @@ def get_format_function(format_type):
|
||||
def get_style_format(style):
|
||||
style_map = {
|
||||
'minimal': '1. **精简信息**: 仅记录最重要的内容,简洁明了。',
|
||||
'detailed': '2. **详细记录**: 包含完整的时间戳和每个部分的详细讨论。',
|
||||
'detailed': '2. **详细记录**: 包含完整的内容和每个部分的详细讨论。需要尽可能多的记录视频内容,最好详细的笔记',
|
||||
'academic': '3. **学术风格**: 适合学术报告,正式且结构化。',
|
||||
'xiaohongshu': '''4. **小红书风格**:
|
||||
### 擅长使用下面的爆款关键词:
|
||||
|
||||
@@ -217,17 +217,25 @@ def get_task_status(task_id: str):
|
||||
@router.get("/image_proxy")
|
||||
async def image_proxy(request: Request, url: str):
|
||||
headers = {
|
||||
"Referer": "https://www.bilibili.com/", # 模拟B站来源
|
||||
"Referer": "https://www.bilibili.com/",
|
||||
"User-Agent": request.headers.get("User-Agent", ""),
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=resp.status_code, detail="图片获取失败")
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "image/jpeg")
|
||||
return StreamingResponse(resp.aiter_bytes(), media_type=content_type)
|
||||
return StreamingResponse(
|
||||
resp.aiter_bytes(),
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=86400", # ✅ 缓存一天
|
||||
"Content-Type": content_type,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -47,26 +47,26 @@ def add_provider(data: ProviderRequest):
|
||||
@router.get("/get_all_providers")
|
||||
def get_all_providers():
|
||||
try:
|
||||
res = ProviderService.get_all_providers()
|
||||
res = ProviderService.get_all_providers_safe()
|
||||
return R.success(data=res)
|
||||
except Exception as e:
|
||||
return R.error(msg=e)
|
||||
|
||||
@router.get("/get_provider_by_id/{id}")
|
||||
def get_provider_by_id(id: str):
|
||||
try:
|
||||
res = ProviderService.get_provider_by_id(id)
|
||||
return R.success(data=res)
|
||||
except Exception as e:
|
||||
return R.error(msg=e)
|
||||
|
||||
@router.get("/get_provider_by_name/{name}")
|
||||
def get_provider_by_name(name: str):
|
||||
try:
|
||||
res = ProviderService.get_provider_by_name(name)
|
||||
return R.success(data=res)
|
||||
except Exception as e:
|
||||
return R.error(msg=e)
|
||||
# @router.get("/get_provider_by_id/{id}")
|
||||
# def get_provider_by_id(id: str):
|
||||
# try:
|
||||
# res = ProviderService.get_provider_by_id(id)
|
||||
# return R.success(data=res)
|
||||
# except Exception as e:
|
||||
# return R.error(msg=e)
|
||||
#
|
||||
# @router.get("/get_provider_by_name/{name}")
|
||||
# def get_provider_by_name(name: str):
|
||||
# try:
|
||||
# res = ProviderService.get_provider_by_name(name)
|
||||
# return R.success(data=res)
|
||||
# except Exception as e:
|
||||
# return R.error(msg=e)
|
||||
|
||||
|
||||
@router.post("/update_provider")
|
||||
|
||||
@@ -45,7 +45,16 @@ class ModelService:
|
||||
except Exception as e:
|
||||
print(f"获取所有模型失败: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_all_models_safe(verbose: bool = False):
|
||||
try:
|
||||
raw_models = get_all_models()
|
||||
if verbose:
|
||||
print(f"所有模型列表: {raw_models}")
|
||||
return ModelService._format_models(raw_models)
|
||||
except Exception as e:
|
||||
print(f"获取所有模型失败: {e}")
|
||||
return []
|
||||
@staticmethod
|
||||
def _format_models(raw_models: list) -> list:
|
||||
"""
|
||||
|
||||
@@ -140,7 +140,6 @@ class NoteGenerator:
|
||||
replacement = f""
|
||||
new_markdown = new_markdown.replace(marker, replacement, 1)
|
||||
|
||||
|
||||
return new_markdown
|
||||
except Exception as e:
|
||||
logger.error(f"截图生成失败:{e}")
|
||||
@@ -201,16 +200,23 @@ class NoteGenerator:
|
||||
# -------- 1. 下载音频 --------
|
||||
try:
|
||||
self.update_task_status(task_id, TaskStatus.DOWNLOADING)
|
||||
|
||||
# 加载音频缓存(如果存在)
|
||||
audio = None
|
||||
if os.path.exists(audio_cache_path):
|
||||
logger.info(f"检测到已有音频缓存,直接读取,task_id={task_id}")
|
||||
with open(audio_cache_path, "r", encoding="utf-8") as f:
|
||||
audio_data = json.load(f)
|
||||
audio = AudioDownloadResult(**audio_data)
|
||||
else:
|
||||
if 'screenshot' in _format or video_understanding:
|
||||
|
||||
# 需要视频的情况(截图 or 视频理解)
|
||||
need_video = 'screenshot' in _format or video_understanding
|
||||
if need_video:
|
||||
try:
|
||||
video_path = downloader.download_video(video_url)
|
||||
self.video_path = video_path
|
||||
logger.info(f"成功下载视频文件: {video_path}")
|
||||
|
||||
video_img_urls = VideoReader(
|
||||
video_path=video_path,
|
||||
grid_size=tuple(grid_size),
|
||||
@@ -219,13 +225,17 @@ class NoteGenerator:
|
||||
unit_height=720,
|
||||
save_quality=90,
|
||||
).run()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 下载视频失败,task_id={task_id},错误信息:{e}")
|
||||
self.update_task_status(task_id, TaskStatus.FAILED, message=f"下载音频失败:{e}")
|
||||
|
||||
screenshot = 'screenshot' in _format
|
||||
audio: AudioDownloadResult = downloader.download(
|
||||
# 没有音频缓存就下载音频(可能同时也带上视频)
|
||||
if audio is None:
|
||||
audio = downloader.download(
|
||||
video_url=video_url,
|
||||
quality=quality,
|
||||
output_dir=path,
|
||||
need_video=screenshot
|
||||
need_video='screenshot' in _format, # 注意这里只为了截图需要
|
||||
)
|
||||
with open(audio_cache_path, "w", encoding="utf-8") as f:
|
||||
json.dump(asdict(audio), f, ensure_ascii=False, indent=2)
|
||||
@@ -266,27 +276,27 @@ class NoteGenerator:
|
||||
# -------- 3. 总结内容 --------
|
||||
try:
|
||||
self.update_task_status(task_id, TaskStatus.SUMMARIZING)
|
||||
if os.path.exists(markdown_cache_path):
|
||||
logger.info(f"检测到已有总结缓存,直接读取,task_id={task_id}")
|
||||
with open(markdown_cache_path, "r", encoding="utf-8") as f:
|
||||
markdown = f.read()
|
||||
else:
|
||||
source = GPTSource(
|
||||
title=audio.title,
|
||||
segment=transcript.segments,
|
||||
tags=audio.raw_info.get('tags'),
|
||||
screenshot=screenshot,
|
||||
video_img_urls=video_img_urls,
|
||||
link=link,
|
||||
_format=_format,
|
||||
style=style,
|
||||
extras=extras
|
||||
)
|
||||
# if os.path.exists(markdown_cache_path):
|
||||
# logger.info(f"检测到已有总结缓存,直接读取,task_id={task_id}")
|
||||
# with open(markdown_cache_path, "r", encoding="utf-8") as f:
|
||||
# markdown = f.read()
|
||||
# else:
|
||||
source = GPTSource(
|
||||
title=audio.title,
|
||||
segment=transcript.segments,
|
||||
tags=audio.raw_info.get('tags'),
|
||||
screenshot=screenshot,
|
||||
video_img_urls=video_img_urls,
|
||||
link=link,
|
||||
_format=_format,
|
||||
style=style,
|
||||
extras=extras
|
||||
)
|
||||
|
||||
markdown: str = gpt.summarize(source)
|
||||
with open(markdown_cache_path, "w", encoding="utf-8") as f:
|
||||
f.write(markdown)
|
||||
logger.info(f"GPT总结并缓存成功,task_id={task_id}")
|
||||
markdown: str = gpt.summarize(source)
|
||||
with open(markdown_cache_path, "w", encoding="utf-8") as f:
|
||||
f.write(markdown)
|
||||
logger.info(f"GPT总结并缓存成功,task_id={task_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 总结内容失败,task_id={task_id},错误信息:{e}")
|
||||
self.update_task_status(task_id, TaskStatus.FAILED, message=f"总结内容失败:{e}")
|
||||
@@ -313,10 +323,11 @@ class NoteGenerator:
|
||||
# -------- 6. 完成 --------
|
||||
self.update_task_status(task_id, TaskStatus.SUCCESS)
|
||||
logger.info(f"✅ 笔记生成成功,task_id={task_id}")
|
||||
if platform != 'local':
|
||||
transcription_finished.send({
|
||||
"file_path": audio.file_path,
|
||||
})
|
||||
# TODO :改为前端一键清除缓存
|
||||
# if platform != 'local':
|
||||
# transcription_finished.send({
|
||||
# "file_path": audio.file_path,
|
||||
# })
|
||||
return NoteResult(
|
||||
markdown=markdown,
|
||||
transcript=transcript,
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.models.model_config import ModelConfig
|
||||
|
||||
|
||||
class ProviderService:
|
||||
|
||||
@staticmethod
|
||||
def serialize_provider(row: tuple) -> dict:
|
||||
if not row:
|
||||
@@ -28,7 +29,25 @@ class ProviderService:
|
||||
"enabled": row[6],
|
||||
"created_at": row[7],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def serialize_provider_safe(row: tuple) -> dict:
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row[0],
|
||||
"name": row[1],
|
||||
"logo": row[2],
|
||||
"type": row[3],
|
||||
"api_key": ProviderService.mask_key(row[4]),
|
||||
"base_url": row[5],
|
||||
"enabled": row[6],
|
||||
"created_at": row[7],
|
||||
}
|
||||
@staticmethod
|
||||
def mask_key(key: str) -> str:
|
||||
if not key or len(key) < 8:
|
||||
return '*' * len(key)
|
||||
return key[:4] + '*' * (len(key) - 8) + key[-4:]
|
||||
@staticmethod
|
||||
def add_provider( name: str, api_key: str, base_url: str, logo: str, type_: str, enabled: int = 1):
|
||||
try:
|
||||
@@ -42,7 +61,10 @@ class ProviderService:
|
||||
def get_all_providers():
|
||||
rows = get_all_providers()
|
||||
return [ProviderService.serialize_provider(row) for row in rows] if rows else []
|
||||
|
||||
@staticmethod
|
||||
def get_all_providers_safe():
|
||||
rows = get_all_providers()
|
||||
return [ProviderService.serialize_provider(row) for row in rows] if rows else []
|
||||
@staticmethod
|
||||
def get_provider_by_name(name: str):
|
||||
row = get_provider_by_name(name)
|
||||
|
||||
Reference in New Issue
Block a user