mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-09-04 23:26:51 +08:00
feat(frontend): 新增多版本笔记功能,并做了向下兼容。
- 新增关于页面组件,介绍项目背景、功能和使用方法 - 重构笔记生成逻辑,支持多版本笔记 - 新增笔记版本选择、复制和导出功能 -优化笔记界面布局和交互 - 调整部分组件样式,提升用户体验
This commit is contained in:
@@ -21,7 +21,7 @@
|
|||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@radix-ui/react-switch": "^1.2.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",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@tailwindcss/vite": "^4.1.3",
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
"@uiw/react-markdown-preview": "^5.1.3",
|
"@uiw/react-markdown-preview": "^5.1.3",
|
||||||
@@ -29,16 +29,19 @@
|
|||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
"katex": "^0.16.22",
|
"katex": "^0.16.22",
|
||||||
"lottie-react": "^2.4.1",
|
"lottie-react": "^2.4.1",
|
||||||
"lucide-react": "^0.487.0",
|
"lucide-react": "^0.487.0",
|
||||||
"markdown-navbar": "^1.4.3",
|
"markdown-navbar": "^1.4.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"pinyin-match": "^1.2.7",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "^7.55.0",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
|
"react-intersection-observer": "^9.16.0",
|
||||||
"react-markdown": "^8.0.7",
|
"react-markdown": "^8.0.7",
|
||||||
"react-medium-image-zoom": "^5.2.14",
|
"react-medium-image-zoom": "^5.2.14",
|
||||||
"react-resizable-panels": "^2.1.8",
|
"react-resizable-panels": "^2.1.8",
|
||||||
@@ -51,6 +54,7 @@
|
|||||||
"tailwind-merge": "^3.1.0",
|
"tailwind-merge": "^3.1.0",
|
||||||
"tailwindcss": "^4.1.3",
|
"tailwindcss": "^4.1.3",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"zod": "^3.24.2",
|
"zod": "^3.24.2",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
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 ProviderForm from '@/components/Form/modelForm/Form.tsx'
|
||||||
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
|
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
|
||||||
import Downloading from '@/components/Lottie/download.tsx'
|
import Downloading from '@/components/Lottie/download.tsx'
|
||||||
|
import Prompt from '@/pages/SettingPage/Prompt.tsx'
|
||||||
|
import AboutPage from '@/pages/SettingPage/about.tsx'
|
||||||
function App() {
|
function App() {
|
||||||
useTaskPolling(3000) // 每 3 秒轮询一次
|
useTaskPolling(3000) // 每 3 秒轮询一次
|
||||||
const steps = [
|
const steps = [
|
||||||
@@ -33,7 +35,10 @@ function App() {
|
|||||||
{/*<Route index element={<Navigate to="openai" replace />} />*/}
|
{/*<Route index element={<Navigate to="openai" replace />} />*/}
|
||||||
<Route path=":id" element={<ProviderForm />} />
|
<Route path=":id" element={<ProviderForm />} />
|
||||||
</Route>
|
</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 path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
@@ -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 *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 修改滚动条轨道颜色 */
|
/* 修改滚动条轨道颜色 */
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable'
|
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable'
|
||||||
|
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
NoteForm: React.ReactNode
|
NoteForm: React.ReactNode
|
||||||
@@ -47,7 +48,9 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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>
|
</aside>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
@@ -56,7 +59,9 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
|
|||||||
{/* 中间历史 */}
|
{/* 中间历史 */}
|
||||||
<ResizablePanel defaultSize={16} minSize={10} maxSize={30}>
|
<ResizablePanel defaultSize={16} minSize={10} maxSize={30}>
|
||||||
<aside className="flex h-full flex-col overflow-hidden border-r border-neutral-200 bg-white">
|
<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>
|
</aside>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const HomePage: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<HomeLayout
|
<HomeLayout
|
||||||
NoteForm={<NoteForm />}
|
NoteForm={<NoteForm />}
|
||||||
Preview={<MarkdownViewer status={status} content={content} />}
|
Preview={<MarkdownViewer status={status} />}
|
||||||
History={<History />}
|
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 ReactMarkdown from 'react-markdown'
|
||||||
import { Button } from '@/components/ui/button.tsx'
|
import { Button } from '@/components/ui/button.tsx'
|
||||||
import { Copy, Download, FileText, ArrowRight } from 'lucide-react'
|
import { Copy, Download, ArrowRight,Play } from 'lucide-react'
|
||||||
import { toast } from 'react-hot-toast' // 你可以换成自己的通知组件
|
import { toast } from 'react-hot-toast'
|
||||||
import Error from '@/components/Lottie/error.tsx'
|
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 { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||||
import { atomDark as codeStyle } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
import { atomDark as codeStyle } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||||
import Zoom from 'react-medium-image-zoom'
|
import Zoom from 'react-medium-image-zoom'
|
||||||
import 'react-medium-image-zoom/dist/styles.css'
|
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 gfm from 'remark-gfm'
|
||||||
import remarkMath from 'remark-math'
|
import remarkMath from 'remark-math'
|
||||||
|
|
||||||
import 'katex/dist/katex.min.css'
|
|
||||||
|
|
||||||
import rehypeKatex from 'rehype-katex'
|
import rehypeKatex from 'rehype-katex'
|
||||||
import 'katex/dist/katex.min.css'
|
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 {
|
interface MarkdownViewerProps {
|
||||||
content: string
|
content: string | VersionNote[]
|
||||||
status: 'idle' | 'loading' | 'success' | 'failed'
|
status: 'idle' | 'loading' | 'success' | 'failed'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,227 +44,396 @@ const steps = [
|
|||||||
{ label: '保存完成', key: 'SUCCESS' },
|
{ label: '保存完成', key: 'SUCCESS' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
|
const MarkdownViewer: FC<MarkdownViewerProps> = ({ status }) => {
|
||||||
const [copied, setCopied] = useState(false)
|
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 getCurrentTask = useTaskStore.getState().getCurrentTask
|
||||||
const currentTask = useTaskStore(state => state.getCurrentTask())
|
const currentTask = useTaskStore(state => state.getCurrentTask())
|
||||||
const taskStatus = currentTask?.status || 'PENDING'
|
const taskStatus = currentTask?.status || 'PENDING'
|
||||||
const retryTask = useTaskStore.getState().retryTask
|
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 () => {
|
const handleCopy = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(content)
|
await navigator.clipboard.writeText(selectedContent)
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
toast.success('已复制到剪贴板')
|
toast.success('已复制到剪贴板')
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(`复制失败${e}`)
|
toast.error('复制失败')
|
||||||
toast.error('复制失败', e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
const currentTask = getCurrentTask()
|
const task = getCurrentTask()
|
||||||
const currentTaskName = currentTask?.audioMeta.title
|
const name = task?.audioMeta.title || 'note'
|
||||||
|
const blob = new Blob([selectedContent], { type: 'text/markdown;charset=utf-8' })
|
||||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
|
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = URL.createObjectURL(blob)
|
link.href = URL.createObjectURL(blob)
|
||||||
link.download = `${currentTaskName}.md`
|
link.download = `${name}.md`
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full flex-col items-center justify-center space-y-4 text-neutral-500">
|
<div className="flex h-screen w-full flex-col items-center justify-center space-y-4 text-neutral-500">
|
||||||
<StepBar steps={steps} currentStep={taskStatus} />
|
<StepBar steps={steps} currentStep={taskStatus} />
|
||||||
<Loading className="h-5 w-5" />
|
<Loading className="h-5 w-5" />
|
||||||
<div className="text-center text-sm">
|
<div className="text-center text-sm">
|
||||||
<p className="text-lg font-bold">正在生成笔记,请稍候…</p>
|
<p className="text-lg font-bold">正在生成笔记,请稍候…</p>
|
||||||
<p className="mt-2 text-xs text-neutral-500">这可能需要几秒钟时间,取决于视频长度</p>
|
<p className="mt-2 text-xs text-neutral-500">这可能需要几秒钟时间,取决于视频长度</p>
|
||||||
|
</div>
|
||||||
</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 (
|
return (
|
||||||
<div className="flex h-full w-full flex-col">
|
<div className="flex h-screen w-full flex-col overflow-hidden">
|
||||||
{/* 顶部操作栏 */}
|
<MarkdownHeader
|
||||||
<div className="mb-4 flex items-center justify-between">
|
currentTask={currentTask}
|
||||||
<h2 className="flex items-center gap-2 text-xl font-semibold text-neutral-900">
|
isMultiVersion={isMultiVersion}
|
||||||
<FileText className="text-primary h-5 w-5" />
|
currentVerId={currentVerId}
|
||||||
笔记内容
|
setCurrentVerId={setCurrentVerId}
|
||||||
</h2>
|
modelName={modelName}
|
||||||
<div className="flex items-center gap-2">
|
style={style}
|
||||||
<Button onClick={handleCopy} variant="outline" size="sm">
|
noteStyles={noteStyles}
|
||||||
<Copy className="mr-1 h-4 w-4" />
|
onCopy={handleCopy}
|
||||||
{copied ? '已复制' : '复制'}
|
onDownload={handleDownload}
|
||||||
</Button>
|
createAt={createTime}
|
||||||
<Button onClick={handleDownload} variant="outline" size="sm">
|
showTranscribe={showTranscribe}
|
||||||
<Download className="mr-1 h-4 w-4" />
|
setShowTranscribe={setShowTranscribe}
|
||||||
导出 Markdown
|
/>
|
||||||
</Button>
|
|
||||||
|
{/* 中间内容区域:滚动容器 */}
|
||||||
|
<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>
|
</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 { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { Info, Loader2 } from 'lucide-react'
|
import { Info, Loader2 ,Plus} from 'lucide-react'
|
||||||
import { message, Alert } from 'antd'
|
import { message, Alert } from 'antd'
|
||||||
|
|
||||||
import { generateNote } from '@/services/note.ts'
|
import { generateNote } from '@/services/note.ts'
|
||||||
import { uploadFile } from '@/services/upload.ts'
|
import { uploadFile } from '@/services/upload.ts'
|
||||||
import { useTaskStore } from '@/store/taskStore'
|
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 {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select.tsx";
|
||||||
import {Input} from "@/components/ui/input.tsx";
|
import {Input} from "@/components/ui/input.tsx";
|
||||||
import {Textarea} from "@/components/ui/textarea.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 -------------------- */
|
/* -------------------- 校验 Schema -------------------- */
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
@@ -118,9 +99,10 @@ const CheckboxGroup = ({
|
|||||||
/* -------------------- 主组件 -------------------- */
|
/* -------------------- 主组件 -------------------- */
|
||||||
const NoteForm = () => {
|
const NoteForm = () => {
|
||||||
/* ---- 全局状态 ---- */
|
/* ---- 全局状态 ---- */
|
||||||
const { addPendingTask, currentTaskId, getCurrentTask } = useTaskStore()
|
const { addPendingTask, currentTaskId, setCurrentTask,getCurrentTask ,retryTask} = useTaskStore()
|
||||||
const { loadEnabledModels, modelList, showFeatureHint, setShowFeatureHint } = useModelStore()
|
const { loadEnabledModels, modelList, showFeatureHint, setShowFeatureHint } = useModelStore()
|
||||||
|
|
||||||
|
|
||||||
/* ---- 表单 ---- */
|
/* ---- 表单 ---- */
|
||||||
const form = useForm<NoteFormValues>({
|
const form = useForm<NoteFormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
@@ -134,21 +116,35 @@ const NoteForm = () => {
|
|||||||
format: [],
|
format: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const currentTask = getCurrentTask()
|
||||||
|
|
||||||
/* ---- 派生状态(只 watch 一次,提高性能) ---- */
|
/* ---- 派生状态(只 watch 一次,提高性能) ---- */
|
||||||
const platform = useWatch({ control: form.control, name: 'platform' }) as string
|
const platform = useWatch({ control: form.control, name: 'platform' }) as string
|
||||||
const videoUnderstandingEnabled = useWatch({ control: form.control, name: 'video_understanding' })
|
const videoUnderstandingEnabled = useWatch({ control: form.control, name: 'video_understanding' })
|
||||||
|
const editing = currentTask && currentTask.id
|
||||||
|
|
||||||
/* ---- 副作用 ---- */
|
/* ---- 副作用 ---- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEnabledModels()
|
loadEnabledModels()
|
||||||
|
|
||||||
return}, [])
|
return}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTask = getCurrentTask()
|
||||||
|
const { formData } = currentTask || {}
|
||||||
|
if (!currentTask) return
|
||||||
|
form.reset(
|
||||||
|
{
|
||||||
|
...formData,
|
||||||
|
extras: formData?.extras || '',
|
||||||
|
}
|
||||||
|
|
||||||
|
)
|
||||||
|
}, [currentTaskId])
|
||||||
|
|
||||||
/* ---- 帮助函数 ---- */
|
/* ---- 帮助函数 ---- */
|
||||||
const isGenerating = () =>
|
const isGenerating = () =>
|
||||||
!['SUCCESS', 'FAILED', undefined].includes(getCurrentTask()?.status)
|
!['SUCCESS', 'FAILED', undefined].includes(getCurrentTask()?.status)
|
||||||
|
const generating = isGenerating()
|
||||||
const handleFileUpload = async (file: File, cb: (url: string) => void) => {
|
const handleFileUpload = async (file: File, cb: (url: string) => void) => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
@@ -162,40 +158,83 @@ const NoteForm = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = async (values: NoteFormValues) => {
|
const onSubmit = async (values: NoteFormValues) => {
|
||||||
const payload:NoteFormValues = {
|
|
||||||
...values,
|
const payload:NoteFormValues = {
|
||||||
provider_id: modelList.find(m => m.model_name === values.model_name)!.provider_id,
|
...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('已提交任务')
|
message.success('已提交任务')
|
||||||
const { data } = await generateNote(payload)
|
const { data } = await generateNote(payload)
|
||||||
addPendingTask(data.task_id, values.platform, 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 (
|
return (
|
||||||
<ScrollArea className="sm:h-[400px] md:h-[800px]">
|
<div className="h-full w-full">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
{/* 顶部按钮 */}
|
{/* 顶部按钮 */}
|
||||||
<Button type="submit" className="w-full bg-primary" disabled={isGenerating()}>
|
<FormButton></FormButton>
|
||||||
{isGenerating() && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
{isGenerating() ? '正在生成…' : '生成笔记'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 视频链接 & 平台 */}
|
{/* 视频链接 & 平台 */}
|
||||||
<SectionHeader title="视频链接" tip="支持 B 站、YouTube 等平台" />
|
<SectionHeader title="视频链接" tip="支持 B 站、YouTube 等平台" />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{/* 平台选择 */}
|
{/* 平台选择 */}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="platform"
|
name="platform"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select disabled={!!editing} value={field.value} onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent >
|
||||||
<SelectItem value="bilibili">哔哩哔哩</SelectItem>
|
<SelectItem value="bilibili">哔哩哔哩</SelectItem>
|
||||||
<SelectItem value="youtube">YouTube</SelectItem>
|
<SelectItem value="youtube">YouTube</SelectItem>
|
||||||
<SelectItem value="douyin">抖音</SelectItem>
|
<SelectItem value="douyin">抖音</SelectItem>
|
||||||
@@ -214,65 +253,106 @@ const NoteForm = () => {
|
|||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
{platform === 'local' ? (
|
{platform === 'local' ? (
|
||||||
<>
|
<>
|
||||||
<Input placeholder="请输入本地视频路径" {...field} />
|
<Input disabled={!!editing} 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 placeholder="请输入视频网站链接" {...field} />
|
<Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} />
|
||||||
)}
|
)}
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 模型选择 */}
|
<FormField
|
||||||
<FormField
|
control={form.control}
|
||||||
control={form.control}
|
name="video_url"
|
||||||
name="model_name"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem className="flex-1">
|
||||||
<FormItem>
|
{platform === 'local' && (
|
||||||
<SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" />
|
<>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<div
|
||||||
<FormControl>
|
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"
|
||||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
onDragOver={e => { e.preventDefault(); e.stopPropagation() }}
|
||||||
</FormControl>
|
onDrop={e => {
|
||||||
<SelectContent>
|
e.preventDefault()
|
||||||
{modelList.map(m => (
|
const file = e.dataTransfer.files?.[0]
|
||||||
<SelectItem key={m.model_name} value={m.model_name}>
|
if (file) handleFileUpload(file, field.onChange)
|
||||||
{m.model_name}
|
}}
|
||||||
</SelectItem>
|
onClick={() => {
|
||||||
))}
|
const input = document.createElement('input')
|
||||||
</SelectContent>
|
input.type = 'file'
|
||||||
</Select>
|
input.accept = 'video/*'
|
||||||
<FormMessage />
|
input.onchange = e => {
|
||||||
</FormItem>
|
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="将视频截图发给多模态模型辅助分析" />
|
<SectionHeader title="视频理解" tip="将视频截图发给多模态模型辅助分析" />
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -293,11 +373,7 @@ const NoteForm = () => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Alert
|
|
||||||
type="info"
|
|
||||||
message="推荐多模态模型:qwen2.5-vl-72b-instruct / gpt-4o"
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{/* 采样间隔 */}
|
{/* 采样间隔 */}
|
||||||
<FormField
|
<FormField
|
||||||
@@ -346,29 +422,21 @@ const NoteForm = () => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Alert
|
||||||
|
closable
|
||||||
|
type="error"
|
||||||
|
message={
|
||||||
|
<div>
|
||||||
|
<strong>提示:</strong>
|
||||||
|
<p>视频理解功能必须使用多模态模型。</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
</div>
|
</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
|
<FormField
|
||||||
@@ -404,7 +472,7 @@ const NoteForm = () => {
|
|||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</ScrollArea>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { useTaskStore } from '@/store/taskStore'
|
import { useTaskStore } from '@/store/taskStore'
|
||||||
import { FC } from 'react'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||||
import { Badge } from '@/components/ui/badge.tsx'
|
import { Badge } from '@/components/ui/badge.tsx'
|
||||||
import { cn } from '@/lib/utils.ts'
|
import { cn } from '@/lib/utils.ts'
|
||||||
import { Trash } from 'lucide-react'
|
import { Trash } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button.tsx'
|
import { Button } from '@/components/ui/button.tsx'
|
||||||
|
import PinyinMatch from 'pinyin-match'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip.tsx'
|
} from '@/components/ui/tooltip.tsx'
|
||||||
|
import LazyImage from "@/components/LazyImage.tsx";
|
||||||
|
import {FC, useState ,useEffect } from 'react'
|
||||||
|
|
||||||
interface NoteHistoryProps {
|
interface NoteHistoryProps {
|
||||||
onSelect: (taskId: string) => void
|
onSelect: (taskId: string) => void
|
||||||
@@ -20,20 +24,59 @@ interface NoteHistoryProps {
|
|||||||
const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
|
const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
|
||||||
const tasks = useTaskStore(state => state.tasks)
|
const tasks = useTaskStore(state => state.tasks)
|
||||||
const removeTask = useTaskStore(state => state.removeTask)
|
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 (
|
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 className="mb-2">
|
||||||
</div>
|
<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 (
|
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">
|
<div className="flex flex-col gap-2 overflow-hidden">
|
||||||
{tasks.map(task => (
|
{filteredTasks.map(task => (
|
||||||
<div
|
<div
|
||||||
|
onClick={() => onSelect(task.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex cursor-pointer flex-col rounded-md border border-neutral-200 p-3',
|
'flex cursor-pointer flex-col rounded-md border border-neutral-200 p-3',
|
||||||
selectedId === task.id && 'border-primary bg-primary-light'
|
selectedId === task.id && 'border-primary bg-primary-light'
|
||||||
@@ -42,7 +85,7 @@ const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
|
|||||||
<div
|
<div
|
||||||
key={task.id}
|
key={task.id}
|
||||||
className={cn('flex items-center gap-4')}
|
className={cn('flex items-center gap-4')}
|
||||||
onClick={() => onSelect(task.id)}
|
|
||||||
>
|
>
|
||||||
{/* 封面图 */}
|
{/* 封面图 */}
|
||||||
{task.platform === 'local' ? (
|
{task.platform === 'local' ? (
|
||||||
@@ -54,15 +97,15 @@ const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
|
|||||||
className="h-10 w-12 rounded-md object-cover"
|
className="h-10 w-12 rounded-md object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<LazyImage
|
||||||
src={
|
|
||||||
task.audioMeta.cover_url
|
src={
|
||||||
? `/api/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
|
task.audioMeta.cover_url
|
||||||
: '/placeholder.png'
|
? `/api/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
|
||||||
}
|
: '/placeholder.png'
|
||||||
alt="封面"
|
}
|
||||||
className="h-10 w-12 rounded-md object-cover"
|
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'
|
import MenuBar, { IMenuProps } from '@/pages/SettingPage/components/menuBar.tsx'
|
||||||
|
|
||||||
const Menu = () => {
|
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',
|
// id: 'other',
|
||||||
// name: '其他配置',
|
// name: '其他配置',
|
||||||
// icon: <Wrench />,
|
// icon: <Wrench />,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
const Prompt = () => {
|
||||||
|
return <div className={'flex h-full w-full bg-white'}>prompt</div>
|
||||||
|
}
|
||||||
|
export default Prompt
|
||||||
@@ -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
|
menuItem: IMenuProps
|
||||||
}
|
}
|
||||||
|
|
||||||
const MenuBar: FC<IMenuItem> = ({ menuItem }) => {
|
const MenuBar: ({ menuItem }: { menuItem: any }) => JSX.Element = ({ menuItem }) => {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const isActive = location.pathname.startsWith(menuItem.path + '/')
|
const isActive =
|
||||||
|| location.pathname === menuItem.path
|
location.pathname.startsWith(menuItem.path + '/') || location.pathname === menuItem.path
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={menuItem.path} className="w-full">
|
<Link to={menuItem.path} className="w-full">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
import { delete_task, generateNote } from '@/services/note.ts'
|
import { delete_task, generateNote } from '@/services/note.ts'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
|
|
||||||
export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILD'
|
export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILD'
|
||||||
|
|
||||||
@@ -26,10 +28,17 @@ export interface Transcript {
|
|||||||
raw: any
|
raw: any
|
||||||
segments: Segment[]
|
segments: Segment[]
|
||||||
}
|
}
|
||||||
|
export interface Markdown {
|
||||||
|
ver_id: string
|
||||||
|
content: string
|
||||||
|
style: string
|
||||||
|
model_name: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string
|
id: string
|
||||||
markdown: string
|
markdown: string|Markdown [] //为了兼容之前的笔记
|
||||||
transcript: Transcript
|
transcript: Transcript
|
||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
audioMeta: AudioMeta
|
audioMeta: AudioMeta
|
||||||
@@ -64,6 +73,7 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
currentTaskId: null,
|
currentTaskId: null,
|
||||||
|
|
||||||
addPendingTask: (taskId: string, platform: string, formData: any) =>
|
addPendingTask: (taskId: string, platform: string, formData: any) =>
|
||||||
|
|
||||||
set(state => ({
|
set(state => ({
|
||||||
tasks: [
|
tasks: [
|
||||||
{
|
{
|
||||||
@@ -95,24 +105,82 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
updateTaskContent: (id, data) =>
|
updateTaskContent: (id, data) =>
|
||||||
set(state => ({
|
set(state => ({
|
||||||
tasks: state.tasks.map(task => (task.id === id ? { ...task, ...data } : task)),
|
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: () => {
|
getCurrentTask: () => {
|
||||||
const currentTaskId = get().currentTaskId
|
const currentTaskId = get().currentTaskId
|
||||||
return get().tasks.find(task => task.id === currentTaskId) || null
|
return get().tasks.find(task => task.id === currentTaskId) || null
|
||||||
},
|
},
|
||||||
retryTask: async (id: string) => {
|
retryTask: async (id: string, payload?: any) => {
|
||||||
const task = get().tasks.find(task => task.id === id).formData
|
const task = get().tasks.find(task => task.id === id)
|
||||||
|
if (!task) return
|
||||||
|
|
||||||
|
const newFormData = payload || task.formData
|
||||||
|
|
||||||
await generateNote({
|
await generateNote({
|
||||||
task_id: id,
|
task_id: id,
|
||||||
...task,
|
...newFormData,
|
||||||
})
|
})
|
||||||
|
|
||||||
set(state => ({
|
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 => {
|
removeTask: async id => {
|
||||||
const task = get().tasks.find(t => t.id === 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.downloaders.base import Downloader, DownloadQuality, QUALITY_MAP
|
||||||
from app.models.notes_model import AudioDownloadResult
|
from app.models.notes_model import AudioDownloadResult
|
||||||
from app.utils.path_helper import get_data_dir
|
from app.utils.path_helper import get_data_dir
|
||||||
|
from app.utils.url_parser import extract_video_id
|
||||||
|
|
||||||
|
|
||||||
class BilibiliDownloader(Downloader, ABC):
|
class BilibiliDownloader(Downloader, ABC):
|
||||||
@@ -69,10 +70,19 @@ class BilibiliDownloader(Downloader, ABC):
|
|||||||
"""
|
"""
|
||||||
下载视频,返回视频文件路径
|
下载视频,返回视频文件路径
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if output_dir is None:
|
if output_dir is None:
|
||||||
output_dir = get_data_dir()
|
output_dir = get_data_dir()
|
||||||
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
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")
|
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
|
|||||||
@@ -249,13 +249,21 @@ class DouyinDownloader(Downloader):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def download_video(self, video_url: str, output_dir: Union[str, None] = None) -> str:
|
def download_video(self, video_url: str, output_dir: Union[str, None] = None) -> str:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
if output_dir is None:
|
if output_dir is None:
|
||||||
output_dir = get_data_dir()
|
output_dir = get_data_dir()
|
||||||
if not output_dir:
|
if not output_dir:
|
||||||
output_dir = self.cache_data
|
output_dir = self.cache_data
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
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")
|
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
|
||||||
|
|
||||||
video_data = self.fetch_video_info(video_url)
|
video_data = self.fetch_video_info(video_url)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import yt_dlp
|
|||||||
from app.downloaders.base import Downloader, DownloadQuality
|
from app.downloaders.base import Downloader, DownloadQuality
|
||||||
from app.models.notes_model import AudioDownloadResult
|
from app.models.notes_model import AudioDownloadResult
|
||||||
from app.utils.path_helper import get_data_dir
|
from app.utils.path_helper import get_data_dir
|
||||||
|
from app.utils.url_parser import extract_video_id
|
||||||
|
|
||||||
|
|
||||||
class YoutubeDownloader(Downloader, ABC):
|
class YoutubeDownloader(Downloader, ABC):
|
||||||
@@ -67,12 +68,15 @@ class YoutubeDownloader(Downloader, ABC):
|
|||||||
"""
|
"""
|
||||||
if output_dir is None:
|
if output_dir is None:
|
||||||
output_dir = get_data_dir()
|
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)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
|
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'format': 'worst[ext=mp4]/worst',
|
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]',
|
||||||
'outtmpl': output_path,
|
'outtmpl': output_path,
|
||||||
'noplaylist': True,
|
'noplaylist': True,
|
||||||
'quiet': False,
|
'quiet': False,
|
||||||
|
|||||||
@@ -35,11 +35,10 @@ BASE_PROMPT = '''
|
|||||||
根据上面的分段转录内容,生成结构化的笔记,遵循以下原则:
|
根据上面的分段转录内容,生成结构化的笔记,遵循以下原则:
|
||||||
|
|
||||||
1. **完整信息**:记录尽可能多的相关细节,确保内容全面。
|
1. **完整信息**:记录尽可能多的相关细节,确保内容全面。
|
||||||
2. **清晰结构**:用合适的标题级别(`##`,`###`)整理内容,概述每个部分的要点。主标题用`#`来标识(如果额外重要的任务有格式需求可以不遵守)
|
2. **去除无关内容**:省略广告、填充词、问候语和不相关的言论。
|
||||||
3. **去除无关内容**:省略广告、填充词、问候语和不相关的言论。
|
3. **保留关键细节**:保留重要事实、示例、结论和建议。(如果额外重要的任务有格式需求可以不遵守)
|
||||||
4. **保留关键细节**:保留重要事实、示例、结论和建议。(如果额外重要的任务有格式需求可以不遵守)
|
4. **可读布局**:必要时使用项目符号,并保持段落简短,增强可读性。(如果额外重要的任务有格式需求可以不遵守)
|
||||||
5. **可读布局**:必要时使用项目符号,并保持段落简短,增强可读性。(如果额外重要的任务有格式需求可以不遵守)
|
5. 视频中提及的数学公式必须保留,并以 LaTeX 语法形式呈现,适合 Markdown 渲染。
|
||||||
6. 视频中提及的数学公式必须保留,并以 LaTeX 语法形式呈现,适合 Markdown 渲染。
|
|
||||||
|
|
||||||
|
|
||||||
请始终遵循此规则。
|
请始终遵循此规则。
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ def get_format_function(format_type):
|
|||||||
def get_style_format(style):
|
def get_style_format(style):
|
||||||
style_map = {
|
style_map = {
|
||||||
'minimal': '1. **精简信息**: 仅记录最重要的内容,简洁明了。',
|
'minimal': '1. **精简信息**: 仅记录最重要的内容,简洁明了。',
|
||||||
'detailed': '2. **详细记录**: 包含完整的时间戳和每个部分的详细讨论。',
|
'detailed': '2. **详细记录**: 包含完整的内容和每个部分的详细讨论。需要尽可能多的记录视频内容,最好详细的笔记',
|
||||||
'academic': '3. **学术风格**: 适合学术报告,正式且结构化。',
|
'academic': '3. **学术风格**: 适合学术报告,正式且结构化。',
|
||||||
'xiaohongshu': '''4. **小红书风格**:
|
'xiaohongshu': '''4. **小红书风格**:
|
||||||
### 擅长使用下面的爆款关键词:
|
### 擅长使用下面的爆款关键词:
|
||||||
|
|||||||
@@ -217,17 +217,25 @@ def get_task_status(task_id: str):
|
|||||||
@router.get("/image_proxy")
|
@router.get("/image_proxy")
|
||||||
async def image_proxy(request: Request, url: str):
|
async def image_proxy(request: Request, url: str):
|
||||||
headers = {
|
headers = {
|
||||||
"Referer": "https://www.bilibili.com/", # 模拟B站来源
|
"Referer": "https://www.bilibili.com/",
|
||||||
"User-Agent": request.headers.get("User-Agent", ""),
|
"User-Agent": request.headers.get("User-Agent", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
resp = await client.get(url, headers=headers)
|
resp = await client.get(url, headers=headers)
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise HTTPException(status_code=resp.status_code, detail="图片获取失败")
|
raise HTTPException(status_code=resp.status_code, detail="图片获取失败")
|
||||||
|
|
||||||
content_type = resp.headers.get("Content-Type", "image/jpeg")
|
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:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@@ -47,26 +47,26 @@ def add_provider(data: ProviderRequest):
|
|||||||
@router.get("/get_all_providers")
|
@router.get("/get_all_providers")
|
||||||
def get_all_providers():
|
def get_all_providers():
|
||||||
try:
|
try:
|
||||||
res = ProviderService.get_all_providers()
|
res = ProviderService.get_all_providers_safe()
|
||||||
return R.success(data=res)
|
return R.success(data=res)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return R.error(msg=e)
|
return R.error(msg=e)
|
||||||
|
|
||||||
@router.get("/get_provider_by_id/{id}")
|
# @router.get("/get_provider_by_id/{id}")
|
||||||
def get_provider_by_id(id: str):
|
# def get_provider_by_id(id: str):
|
||||||
try:
|
# try:
|
||||||
res = ProviderService.get_provider_by_id(id)
|
# res = ProviderService.get_provider_by_id(id)
|
||||||
return R.success(data=res)
|
# return R.success(data=res)
|
||||||
except Exception as e:
|
# except Exception as e:
|
||||||
return R.error(msg=e)
|
# return R.error(msg=e)
|
||||||
|
#
|
||||||
@router.get("/get_provider_by_name/{name}")
|
# @router.get("/get_provider_by_name/{name}")
|
||||||
def get_provider_by_name(name: str):
|
# def get_provider_by_name(name: str):
|
||||||
try:
|
# try:
|
||||||
res = ProviderService.get_provider_by_name(name)
|
# res = ProviderService.get_provider_by_name(name)
|
||||||
return R.success(data=res)
|
# return R.success(data=res)
|
||||||
except Exception as e:
|
# except Exception as e:
|
||||||
return R.error(msg=e)
|
# return R.error(msg=e)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/update_provider")
|
@router.post("/update_provider")
|
||||||
|
|||||||
@@ -45,7 +45,16 @@ class ModelService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"获取所有模型失败: {e}")
|
print(f"获取所有模型失败: {e}")
|
||||||
return []
|
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
|
@staticmethod
|
||||||
def _format_models(raw_models: list) -> list:
|
def _format_models(raw_models: list) -> list:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ class NoteGenerator:
|
|||||||
replacement = f""
|
replacement = f""
|
||||||
new_markdown = new_markdown.replace(marker, replacement, 1)
|
new_markdown = new_markdown.replace(marker, replacement, 1)
|
||||||
|
|
||||||
|
|
||||||
return new_markdown
|
return new_markdown
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"截图生成失败:{e}")
|
logger.error(f"截图生成失败:{e}")
|
||||||
@@ -201,16 +200,23 @@ class NoteGenerator:
|
|||||||
# -------- 1. 下载音频 --------
|
# -------- 1. 下载音频 --------
|
||||||
try:
|
try:
|
||||||
self.update_task_status(task_id, TaskStatus.DOWNLOADING)
|
self.update_task_status(task_id, TaskStatus.DOWNLOADING)
|
||||||
|
|
||||||
|
# 加载音频缓存(如果存在)
|
||||||
|
audio = None
|
||||||
if os.path.exists(audio_cache_path):
|
if os.path.exists(audio_cache_path):
|
||||||
logger.info(f"检测到已有音频缓存,直接读取,task_id={task_id}")
|
logger.info(f"检测到已有音频缓存,直接读取,task_id={task_id}")
|
||||||
with open(audio_cache_path, "r", encoding="utf-8") as f:
|
with open(audio_cache_path, "r", encoding="utf-8") as f:
|
||||||
audio_data = json.load(f)
|
audio_data = json.load(f)
|
||||||
audio = AudioDownloadResult(**audio_data)
|
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)
|
video_path = downloader.download_video(video_url)
|
||||||
self.video_path = video_path
|
self.video_path = video_path
|
||||||
logger.info(f"成功下载视频文件: {video_path}")
|
logger.info(f"成功下载视频文件: {video_path}")
|
||||||
|
|
||||||
video_img_urls = VideoReader(
|
video_img_urls = VideoReader(
|
||||||
video_path=video_path,
|
video_path=video_path,
|
||||||
grid_size=tuple(grid_size),
|
grid_size=tuple(grid_size),
|
||||||
@@ -219,13 +225,17 @@ class NoteGenerator:
|
|||||||
unit_height=720,
|
unit_height=720,
|
||||||
save_quality=90,
|
save_quality=90,
|
||||||
).run()
|
).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,
|
video_url=video_url,
|
||||||
quality=quality,
|
quality=quality,
|
||||||
output_dir=path,
|
output_dir=path,
|
||||||
need_video=screenshot
|
need_video='screenshot' in _format, # 注意这里只为了截图需要
|
||||||
)
|
)
|
||||||
with open(audio_cache_path, "w", encoding="utf-8") as f:
|
with open(audio_cache_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(asdict(audio), f, ensure_ascii=False, indent=2)
|
json.dump(asdict(audio), f, ensure_ascii=False, indent=2)
|
||||||
@@ -266,27 +276,27 @@ class NoteGenerator:
|
|||||||
# -------- 3. 总结内容 --------
|
# -------- 3. 总结内容 --------
|
||||||
try:
|
try:
|
||||||
self.update_task_status(task_id, TaskStatus.SUMMARIZING)
|
self.update_task_status(task_id, TaskStatus.SUMMARIZING)
|
||||||
if os.path.exists(markdown_cache_path):
|
# if os.path.exists(markdown_cache_path):
|
||||||
logger.info(f"检测到已有总结缓存,直接读取,task_id={task_id}")
|
# logger.info(f"检测到已有总结缓存,直接读取,task_id={task_id}")
|
||||||
with open(markdown_cache_path, "r", encoding="utf-8") as f:
|
# with open(markdown_cache_path, "r", encoding="utf-8") as f:
|
||||||
markdown = f.read()
|
# markdown = f.read()
|
||||||
else:
|
# else:
|
||||||
source = GPTSource(
|
source = GPTSource(
|
||||||
title=audio.title,
|
title=audio.title,
|
||||||
segment=transcript.segments,
|
segment=transcript.segments,
|
||||||
tags=audio.raw_info.get('tags'),
|
tags=audio.raw_info.get('tags'),
|
||||||
screenshot=screenshot,
|
screenshot=screenshot,
|
||||||
video_img_urls=video_img_urls,
|
video_img_urls=video_img_urls,
|
||||||
link=link,
|
link=link,
|
||||||
_format=_format,
|
_format=_format,
|
||||||
style=style,
|
style=style,
|
||||||
extras=extras
|
extras=extras
|
||||||
)
|
)
|
||||||
|
|
||||||
markdown: str = gpt.summarize(source)
|
markdown: str = gpt.summarize(source)
|
||||||
with open(markdown_cache_path, "w", encoding="utf-8") as f:
|
with open(markdown_cache_path, "w", encoding="utf-8") as f:
|
||||||
f.write(markdown)
|
f.write(markdown)
|
||||||
logger.info(f"GPT总结并缓存成功,task_id={task_id}")
|
logger.info(f"GPT总结并缓存成功,task_id={task_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ 总结内容失败,task_id={task_id},错误信息:{e}")
|
logger.error(f"❌ 总结内容失败,task_id={task_id},错误信息:{e}")
|
||||||
self.update_task_status(task_id, TaskStatus.FAILED, message=f"总结内容失败:{e}")
|
self.update_task_status(task_id, TaskStatus.FAILED, message=f"总结内容失败:{e}")
|
||||||
@@ -313,10 +323,11 @@ class NoteGenerator:
|
|||||||
# -------- 6. 完成 --------
|
# -------- 6. 完成 --------
|
||||||
self.update_task_status(task_id, TaskStatus.SUCCESS)
|
self.update_task_status(task_id, TaskStatus.SUCCESS)
|
||||||
logger.info(f"✅ 笔记生成成功,task_id={task_id}")
|
logger.info(f"✅ 笔记生成成功,task_id={task_id}")
|
||||||
if platform != 'local':
|
# TODO :改为前端一键清除缓存
|
||||||
transcription_finished.send({
|
# if platform != 'local':
|
||||||
"file_path": audio.file_path,
|
# transcription_finished.send({
|
||||||
})
|
# "file_path": audio.file_path,
|
||||||
|
# })
|
||||||
return NoteResult(
|
return NoteResult(
|
||||||
markdown=markdown,
|
markdown=markdown,
|
||||||
transcript=transcript,
|
transcript=transcript,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.models.model_config import ModelConfig
|
|||||||
|
|
||||||
|
|
||||||
class ProviderService:
|
class ProviderService:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def serialize_provider(row: tuple) -> dict:
|
def serialize_provider(row: tuple) -> dict:
|
||||||
if not row:
|
if not row:
|
||||||
@@ -28,7 +29,25 @@ class ProviderService:
|
|||||||
"enabled": row[6],
|
"enabled": row[6],
|
||||||
"created_at": row[7],
|
"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
|
@staticmethod
|
||||||
def add_provider( name: str, api_key: str, base_url: str, logo: str, type_: str, enabled: int = 1):
|
def add_provider( name: str, api_key: str, base_url: str, logo: str, type_: str, enabled: int = 1):
|
||||||
try:
|
try:
|
||||||
@@ -42,7 +61,10 @@ class ProviderService:
|
|||||||
def get_all_providers():
|
def get_all_providers():
|
||||||
rows = get_all_providers()
|
rows = get_all_providers()
|
||||||
return [ProviderService.serialize_provider(row) for row in rows] if rows else []
|
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
|
@staticmethod
|
||||||
def get_provider_by_name(name: str):
|
def get_provider_by_name(name: str):
|
||||||
row = get_provider_by_name(name)
|
row = get_provider_by_name(name)
|
||||||
|
|||||||
Reference in New Issue
Block a user