Merge pull request #72 from JefferyHcool/feature/regenerate

Feature/regenerate: 新增多版本笔记功能,并做了向下兼容。
This commit is contained in:
Jianwu Huang
2025-05-04 11:03:39 +08:00
committed by GitHub
29 changed files with 1499 additions and 407 deletions

View File

@@ -21,7 +21,7 @@
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.1.3",
"@uiw/react-markdown-preview": "^5.1.3",
@@ -29,16 +29,19 @@
"axios": "^1.8.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fuse.js": "^7.1.0",
"github-markdown-css": "^5.8.1",
"katex": "^0.16.22",
"lottie-react": "^2.4.1",
"lucide-react": "^0.487.0",
"markdown-navbar": "^1.4.3",
"next-themes": "^0.4.6",
"pinyin-match": "^1.2.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.55.0",
"react-hot-toast": "^2.5.2",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^8.0.7",
"react-medium-image-zoom": "^5.2.14",
"react-resizable-panels": "^2.1.8",
@@ -51,6 +54,7 @@
"tailwind-merge": "^3.1.0",
"tailwindcss": "^4.1.3",
"tw-animate-css": "^1.2.5",
"uuid": "^11.1.0",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View File

@@ -11,6 +11,8 @@ import Transcriber from '@/pages/SettingPage/transcriber.tsx'
import ProviderForm from '@/components/Form/modelForm/Form.tsx'
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
import Downloading from '@/components/Lottie/download.tsx'
import Prompt from '@/pages/SettingPage/Prompt.tsx'
import AboutPage from '@/pages/SettingPage/about.tsx'
function App() {
useTaskPolling(3000) // 每 3 秒轮询一次
const steps = [
@@ -33,7 +35,10 @@ function App() {
{/*<Route index element={<Navigate to="openai" replace />} />*/}
<Route path=":id" element={<ProviderForm />} />
</Route>
<Route path="transcriber" elment={<Transcriber />}></Route>
{/*<Route path="transcriber" elment={<Transcriber />}></Route>*/}
<Route path="prompt" element={<Prompt />}></Route>
<Route path="about" element={<AboutPage />}></Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />

View File

@@ -0,0 +1,34 @@
// components/LazyImage.tsx
import { useInView } from 'react-intersection-observer'
import { FC, useState } from 'react'
import clsx from 'clsx'
interface LazyImageProps {
src: string
alt?: string
className?: string
placeholder?: string
}
const LazyImage: FC<LazyImageProps> = ({ src, alt, className, placeholder = '.src/assets/placeholder.png' }) => {
const { ref, inView } = useInView({ triggerOnce: true, threshold: 0.1 })
const [loaded, setLoaded] = useState(false)
return (
<div ref={ref} className={clsx('overflow-hidden', className)}>
{inView ? (
<img
src={src}
alt={alt}
loading="lazy"
onLoad={() => setLoaded(true)}
className={clsx('transition-opacity duration-300', loaded ? 'opacity-100' : 'opacity-0') + ' h-10 w-14 rounded-md object-cover'}
/>
) : (
<img src={placeholder} alt="loading" className="opacity-30" />
)}
</div>
)
}
export default LazyImage

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,19 @@
/* -------------------- 常量 -------------------- */
export const noteFormats = [
{ label: '目录', value: 'toc' },
{ label: '原片跳转', value: 'link' },
{ label: '原片截图', value: 'screenshot' },
{ label: 'AI总结', value: 'summary' },
] as const
export const noteStyles = [
{ label: '精简', value: 'minimal' },
{ label: '详细', value: 'detailed' },
{ label: '教程', value: 'tutorial' },
{ label: '学术', value: 'academic' },
{ label: '小红书', value: 'xiaohongshu' },
{ label: '生活向', value: 'life_journal' },
{ label: '任务导向', value: 'task_oriented' },
{ label: '商业风格', value: 'business' },
{ label: '会议纪要', value: 'meeting_minutes' },
] as const

View File

@@ -4,6 +4,7 @@
@custom-variant dark (&:is(.dark *));
html, body, #root {
height: 100%;
overflow: hidden;
}
/* 修改滚动条轨道颜色 */

View File

@@ -10,6 +10,7 @@ import {
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable'
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
interface IProps {
NoteForm: React.ReactNode
@@ -47,7 +48,9 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
</TooltipProvider>
</div>
</header>
<div className="flex-1 overflow-auto p-4">{NoteForm}</div>
<ScrollArea className="flex-1 overflow-auto">
<div className=' p-4' >{NoteForm}</div>
</ScrollArea>
</aside>
</ResizablePanel>
@@ -56,7 +59,9 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
{/* 中间历史 */}
<ResizablePanel defaultSize={16} minSize={10} maxSize={30}>
<aside className="flex h-full flex-col overflow-hidden border-r border-neutral-200 bg-white">
<div className="flex-1 overflow-auto p-4">{History}</div>
<ScrollArea className="flex-1 overflow-auto">
<div className="">{History}</div>
</ScrollArea>
</aside>
</ResizablePanel>

View File

@@ -36,7 +36,7 @@ export const HomePage: FC = () => {
return (
<HomeLayout
NoteForm={<NoteForm />}
Preview={<MarkdownViewer status={status} content={content} />}
Preview={<MarkdownViewer status={status} />}
History={<History />}
/>
)

View File

@@ -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>
)
}

View File

@@ -1,30 +1,38 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import { Button } from '@/components/ui/button.tsx'
import { Copy, Download, FileText, ArrowRight } from 'lucide-react'
import { toast } from 'react-hot-toast' // 你可以换成自己的通知组件
import { Copy, Download, ArrowRight,Play } from 'lucide-react'
import { toast } from 'react-hot-toast'
import Error from '@/components/Lottie/error.tsx'
import Loading from '@/components/Lottie/Loading.tsx'
import Idle from '@/components/Lottie/Idle.tsx'
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { atomDark as codeStyle } from 'react-syntax-highlighter/dist/esm/styles/prism'
import Zoom from 'react-medium-image-zoom'
import 'react-medium-image-zoom/dist/styles.css'
import 'github-markdown-css/github-markdown-light.css'
import { FC } from 'react'
import Loading from '@/components/Lottie/Loading.tsx'
import Idle from '@/components/Lottie/Idle.tsx'
import { useTaskStore } from '@/store/taskStore'
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
import gfm from 'remark-gfm'
import remarkMath from 'remark-math'
import 'katex/dist/katex.min.css'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
import 'github-markdown-css/github-markdown-light.css'
import { FC } from 'react'
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
import { useTaskStore } from '@/store/taskStore'
import { noteStyles } from '@/constant/note.ts'
import { MarkdownHeader } from '@/pages/HomePage/components/MarkdownHeader.tsx'
import TranscriptViewer from "@/pages/HomePage/components/transcriptViewer.tsx";
interface VersionNote {
ver_id: string
content: string
style: string
model_name: string
created_at?: string
}
interface MarkdownViewerProps {
content: string
content: string | VersionNote[]
status: 'idle' | 'loading' | 'success' | 'failed'
}
@@ -36,227 +44,396 @@ const steps = [
{ label: '保存完成', key: 'SUCCESS' },
]
const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
const MarkdownViewer: FC<MarkdownViewerProps> = ({ status }) => {
const [copied, setCopied] = useState(false)
const [currentVerId, setCurrentVerId] = useState<string>('')
const [selectedContent, setSelectedContent] = useState<string>('')
const [modelName, setModelName] = useState<string>('')
const [style, setStyle] = useState<string>('')
const [createTime, setCreateTime] = useState<string>('')
const getCurrentTask = useTaskStore.getState().getCurrentTask
const currentTask = useTaskStore(state => state.getCurrentTask())
const taskStatus = currentTask?.status || 'PENDING'
const retryTask = useTaskStore.getState().retryTask
let firstHeadingRendered = false
const isMultiVersion = Array.isArray(currentTask?.markdown)
const [showTranscribe, setShowTranscribe]=useState(false)
// 多版本内容处理
useEffect(() => {
if (!currentTask) return;
if (!isMultiVersion) {
setCurrentVerId('') // 清空旧版本 ID
setModelName(currentTask.formData.model_name)
setStyle(currentTask.formData.style)
setCreateTime(currentTask.createdAt)
setSelectedContent(currentTask?.markdown)
} else {
const latestVerId = currentTask.markdown[currentTask.markdown.length - 1]?.ver_id
setCurrentVerId(latestVerId) // 重置为最新版本
}
}, [currentTask?.id,taskStatus])
useEffect(() => {
if (!currentTask || !isMultiVersion) return;
const currentVer = currentTask.markdown.find(v => v.ver_id === currentVerId)
if (currentVer) {
setModelName(currentVer.model_name)
setStyle(currentVer.style)
setCreateTime(currentVer.created_at || '')
setSelectedContent(currentVer.content)
}
}, [currentVerId, currentTask?.id])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content)
await navigator.clipboard.writeText(selectedContent)
setCopied(true)
toast.success('已复制到剪贴板')
setTimeout(() => setCopied(false), 2000)
} catch (e) {
toast.error(`复制失败${e}`)
toast.error('复制失败', e)
toast.error('复制失败')
}
}
const handleDownload = () => {
const currentTask = getCurrentTask()
const currentTaskName = currentTask?.audioMeta.title
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
const task = getCurrentTask()
const name = task?.audioMeta.title || 'note'
const blob = new Blob([selectedContent], { type: 'text/markdown;charset=utf-8' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `${currentTaskName}.md`
link.download = `${name}.md`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
if (status === 'loading') {
return (
<div className="flex h-screen w-full flex-col items-center justify-center space-y-4 text-neutral-500">
<StepBar steps={steps} currentStep={taskStatus} />
<Loading className="h-5 w-5" />
<div className="text-center text-sm">
<p className="text-lg font-bold"></p>
<p className="mt-2 text-xs text-neutral-500"></p>
<div className="flex h-screen w-full flex-col items-center justify-center space-y-4 text-neutral-500">
<StepBar steps={steps} currentStep={taskStatus} />
<Loading className="h-5 w-5" />
<div className="text-center text-sm">
<p className="text-lg font-bold"></p>
<p className="mt-2 text-xs text-neutral-500"></p>
</div>
</div>
</div>
)
} else if (status === 'idle') {
return (
<div className="flex h-screen w-full flex-col items-center justify-center space-y-3 text-neutral-500">
<Idle></Idle>
<div className="text-center">
<p className="text-lg font-bold"></p>
<p className="mt-2 text-xs text-neutral-500">YouTube </p>
</div>
</div>
)
} else if (status === 'failed') {
return (
<div className="flex h-screen w-full flex-col items-center justify-center gap-4 space-y-3">
<Error /> {/* 你可以换成 Failed 动画 */}
<div className="text-center">
<p className="text-lg font-bold text-red-500"></p>
<p className="mt-2 mb-2 text-xs text-red-400"></p>
<Button
onClick={() => {
retryTask(currentTask.id)
}}
size="lg"
>
</Button>
</div>
</div>
)
}
if (status === 'idle') {
return (
<div className="flex h-screen w-full flex-col items-center justify-center space-y-3 text-neutral-500">
<Idle />
<div className="text-center">
<p className="text-lg font-bold"></p>
<p className="mt-2 text-xs text-neutral-500">YouTube </p>
</div>
</div>
)
}
if (status === 'failed' && !isMultiVersion) {
return (
<div className="flex h-screen w-full flex-col items-center justify-center gap-4 space-y-3">
<Error />
<div className="text-center">
<p className="text-lg font-bold text-red-500"></p>
<p className="mt-2 mb-2 text-xs text-red-400"></p>
<Button onClick={() => retryTask(currentTask.id)} size="lg"></Button>
</div>
</div>
)
}
return (
<div className="flex h-full w-full flex-col">
{/* 顶部操作栏 */}
<div className="mb-4 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-xl font-semibold text-neutral-900">
<FileText className="text-primary h-5 w-5" />
</h2>
<div className="flex items-center gap-2">
<Button onClick={handleCopy} variant="outline" size="sm">
<Copy className="mr-1 h-4 w-4" />
{copied ? '已复制' : '复制'}
</Button>
<Button onClick={handleDownload} variant="outline" size="sm">
<Download className="mr-1 h-4 w-4" />
Markdown
</Button>
<div className="flex h-screen w-full flex-col overflow-hidden">
<MarkdownHeader
currentTask={currentTask}
isMultiVersion={isMultiVersion}
currentVerId={currentVerId}
setCurrentVerId={setCurrentVerId}
modelName={modelName}
style={style}
noteStyles={noteStyles}
onCopy={handleCopy}
onDownload={handleDownload}
createAt={createTime}
showTranscribe={showTranscribe}
setShowTranscribe={setShowTranscribe}
/>
{/* 中间内容区域:滚动容器 */}
<div className="flex-1 flex overflow-hidden bg-white py-2">
{selectedContent && selectedContent !== 'loading' && selectedContent !== 'empty' ? (
<>
<ScrollArea className="w-full ">
<div className={"w-full px-2 markdown-body"}>
<ReactMarkdown
remarkPlugins={[gfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
// Headings with improved styling and anchor links
h1: ({ children, ...props }) => (
<h1
className="scroll-m-20 text-3xl font-extrabold tracking-tight text-primary lg:text-4xl my-6"
{...props}
>
{children}
</h1>
),
h2: ({ children, ...props }) => (
<h2
className="scroll-m-20 border-b pb-2 text-2xl font-semibold tracking-tight text-primary first:mt-0 mt-10 mb-4"
{...props}
>
{children}
</h2>
),
h3: ({ children, ...props }) => (
<h3
className="scroll-m-20 text-xl font-semibold tracking-tight text-primary mt-8 mb-4"
{...props}
>
{children}
</h3>
),
h4: ({ children, ...props }) => (
<h4
className="scroll-m-20 text-lg font-semibold tracking-tight text-primary mt-6 mb-2"
{...props}
>
{children}
</h4>
),
// Paragraphs with better line height
p: ({ children, ...props }) => (
<p className="leading-7 [&:not(:first-child)]:mt-6" {...props}>
{children}
</p>
),
// Enhanced links with special handling for "原片" links
a: ({ href, children, ...props }) => {
const isOriginLink = typeof children[0] === 'string' && (children[0] as string).startsWith('原片 @')
if (isOriginLink) {
const timeMatch = (children[0] as string).match(/原片 @ (\d{2}:\d{2})/)
const timeText = timeMatch ? timeMatch[1] : '原片'
return (
<span className="origin-link inline-flex my-2">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors"
{...props}
>
<Play className="h-3.5 w-3.5" />
<span>{timeText}</span>
</a>
</span>
)
}
// Default link styling with external indicator
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary underline underline-offset-4 hover:text-primary/80 inline-flex items-center gap-0.5"
{...props}
>
{children}
{href?.startsWith('http') && <ExternalLink className="h-3 w-3 inline-block ml-0.5" />}
</a>
)
},
// Enhanced image with zoom capability
img: ({ node, ...props }) => (
<div className="my-8 flex justify-center">
<Zoom>
<img
{...props}
className="rounded-lg shadow-md max-w-full cursor-zoom-in object-cover transition-all hover:shadow-lg"
style={{ maxHeight: '500px' }}
/>
</Zoom>
</div>
),
// Better strong/bold text
strong: ({ children, ...props }) => (
<strong className="font-bold text-primary" {...props}>
{children}
</strong>
),
// Enhanced list items with support for "fake headings"
li: ({ children, ...props }) => {
const rawText = String(children)
const isFakeHeading = /^(\*\*.+\*\*)$/.test(rawText.trim())
if (isFakeHeading) {
return (
<div className="text-lg font-bold my-4 text-primary">
{children}
</div>
)
}
return (
<li className="my-1" {...props}>
{children}
</li>
)
},
// Enhanced unordered lists
ul: ({ children, ...props }) => (
<ul className="my-6 ml-6 list-disc [&>li]:mt-2" {...props}>
{children}
</ul>
),
// Enhanced ordered lists
ol: ({ children, ...props }) => (
<ol className="my-6 ml-6 list-decimal [&>li]:mt-2" {...props}>
{children}
</ol>
),
// Enhanced blockquotes
blockquote: ({ children, ...props }) => (
<blockquote
className="mt-6 border-l-4 border-primary/20 pl-4 italic text-muted-foreground"
{...props}
>
{children}
</blockquote>
),
// Enhanced code blocks with syntax highlighting and copy button
code: ({ inline, className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
const codeContent = String(children).replace(/\n$/, '')
if (!inline && match) {
return (
<div className="group relative my-6 overflow-hidden rounded-lg border bg-muted shadow-sm">
<div className="flex items-center justify-between bg-muted px-4 py-1.5 text-sm font-medium text-muted-foreground">
<div>{match[1].toUpperCase()}</div>
<button
onClick={() => {
navigator.clipboard.writeText(codeContent)
toast.success('代码已复制')
}}
className="flex items-center gap-1 rounded-md bg-background/80 px-2 py-1 text-xs font-medium hover:bg-background transition-colors"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
<SyntaxHighlighter
style={codeStyle}
language={match[1]}
PreTag="div"
className="!m-0 !bg-muted !p-0"
customStyle={{
margin: 0,
padding: '1rem',
background: 'transparent',
fontSize: '0.9rem',
}}
{...props}
>
{codeContent}
</SyntaxHighlighter>
</div>
)
}
// Inline code styling
return (
<code
className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm"
{...props}
>
{children}
</code>
)
},
// Enhanced tables
table: ({ children, ...props }) => (
<div className="my-6 w-full overflow-y-auto">
<table className="w-full border-collapse text-sm" {...props}>
{children}
</table>
</div>
),
// Table headers
th: ({ children, ...props }) => (
<th
className="border border-muted-foreground/20 px-4 py-2 text-left font-medium [&[align=center]]:text-center [&[align=right]]:text-right"
{...props}
>
{children}
</th>
),
// Table cells
td: ({ children, ...props }) => (
<td
className="border border-muted-foreground/20 px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right"
{...props}
>
{children}
</td>
),
// Horizontal rule
hr: ({ ...props }) => (
<hr className="my-8 border-muted-foreground/20" {...props} />
),
}}
>
{selectedContent}
</ReactMarkdown>
</div>
</ScrollArea>
{
showTranscribe && (
<div className={'ml-2 w-2/4'}>
<TranscriptViewer/>
</div>
)
}
</>
) : (
<div className="flex h-full w-full items-center justify-center">
<div className="w-[300px] flex-col justify-items-center">
<div className="bg-primary-light mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<ArrowRight className="text-primary h-8 w-8" />
</div>
<p className="mb-2 text-neutral-600">"生成笔记"</p>
<p className="text-xs text-neutral-500">YouTube等视频网站</p>
</div>
</div>
)}
</div>
</div>
{/* 滚动容器 */}
<div className="overflow-y-auto">
{(content && content != 'loading') || content != 'empty' ? (
<div className="markdown-body flex-1 bg-white">
{' '}
<ReactMarkdown
remarkPlugins={[gfm,remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
img: ({ node, ...props }) => (
<Zoom>
<img
{...props}
className="rounded-lg shadow-md max-w-full cursor-pointer mx-auto my-4 max-h-[300px] "
alt={props.alt || ''}
/>
</Zoom>
),
strong({ node, children, ...props }){
return <strong className="text-lg font-bold my-4 text-blue-600" {...props}>{children}</strong>
},
li({ node, children, ...props }) {
const rawText = String(children)
// 检测是否是“加粗的编号开头项”,比如 "**2. 算法摄影的兴起**"
const isFakeHeading = /^(\*\*.+\*\*)$/.test(rawText.trim())
if (isFakeHeading) {
return (
<p className="text-lg font-bold my-4 text-gray-800 text-left">
{children}
</p>
)
}
return <li {...props}>{children}</li>
},
h1({ node, children, ...props }) {
return (
<h1
className="text-3xl text-center font-bold my-6 text-blue-600"
{...props}
>
{children}
</h1>
)
},
h2({ node, children, ...props }) {
return (
<h2
className="text-2xl font-bold my-4 text-blue-600"
{...props}
>
{children}
</h2>
)
},
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
const codeContent = String(children).replace(/\n$/, '')
if (!inline && match) {
return (
<div className="group relative">
<SyntaxHighlighter
style={codeStyle}
language={match[1]}
PreTag="div"
{...props}
>
{codeContent}
</SyntaxHighlighter>
<button
onClick={() => {
console.log('点击负责')
navigator.clipboard.writeText(codeContent)
toast.success('代码已复制')
}}
className="absolute top-2 right-2 hidden items-center gap-1 rounded border border-gray-300 bg-white/70 px-2 py-1 text-xs shadow-sm transition group-hover:flex hover:bg-white"
>
<Copy className="h-3 w-3" />
</button>
</div>
)
}
return (
<code className="rounded bg-gray-100 px-1 py-0.5 text-sm" {...props}>
{children}
</code>
)
},
}}
>
{content}
</ReactMarkdown>
</div>
) : (
<div className="flex h-screen w-full items-center justify-center">
<div className="w-[300px] flex-col justify-items-center">
<div className="bg-primary-light mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<ArrowRight className="text-primary h-8 w-8" />
</div>
<p className="mb-2 text-neutral-600">"生成笔记"</p>
<p className="text-xs text-neutral-500">YouTube等视频网站</p>
</div>
</div>
)}
</div>
{/*<div className="markdown-body flex-1 overflow-y-auto bg-white">*/}
{/* {content ? (*/}
{/* */}
{/* ) : (*/}
{/* <>*/}
{/* <div className="w-16 h-16 bg-primary-light rounded-full flex items-center justify-center mb-4">*/}
{/* <ArrowRight className="h-8 w-8 text-primary" />*/}
{/* </div>*/}
{/* <p className="text-neutral-600 mb-2">输入视频链接并点击"生成笔记"按钮</p>*/}
{/* <p className="text-xs text-neutral-500">支持哔哩哔哩、YouTube、腾讯视频和爱奇艺</p>*/}
{/* </>*/}
{/* )}*/}
{/*</div>*/}
</div>
)
}

View File

@@ -7,9 +7,8 @@ import { useForm, useWatch } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Info, Loader2 } from 'lucide-react'
import { Info, Loader2 ,Plus} from 'lucide-react'
import { message, Alert } from 'antd'
import { generateNote } from '@/services/note.ts'
import { uploadFile } from '@/services/upload.ts'
import { useTaskStore } from '@/store/taskStore'
@@ -21,26 +20,8 @@ import {Button} from "@/components/ui/button.tsx";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Textarea} from "@/components/ui/textarea.tsx";
import {noteStyles,noteFormats} from "@/constant/note.ts";
/* -------------------- 常量 -------------------- */
const noteFormats = [
{ label: '目录', value: 'toc' },
{ label: '原片跳转', value: 'link' },
{ label: '原片截图', value: 'screenshot' },
{ label: 'AI总结', value: 'summary' },
] as const
const noteStyles = [
{ label: '精简', value: 'minimal' },
{ label: '详细', value: 'detailed' },
{ label: '教程', value: 'tutorial' },
{ label: '学术', value: 'academic' },
{ label: '小红书', value: 'xiaohongshu' },
{ label: '生活向', value: 'life_journal' },
{ label: '任务导向', value: 'task_oriented' },
{ label: '商业风格', value: 'business' },
{ label: '会议纪要', value: 'meeting_minutes' },
] as const
/* -------------------- 校验 Schema -------------------- */
const formSchema = z.object({
@@ -118,9 +99,10 @@ const CheckboxGroup = ({
/* -------------------- 主组件 -------------------- */
const NoteForm = () => {
/* ---- 全局状态 ---- */
const { addPendingTask, currentTaskId, getCurrentTask } = useTaskStore()
const { addPendingTask, currentTaskId, setCurrentTask,getCurrentTask ,retryTask} = useTaskStore()
const { loadEnabledModels, modelList, showFeatureHint, setShowFeatureHint } = useModelStore()
/* ---- 表单 ---- */
const form = useForm<NoteFormValues>({
resolver: zodResolver(formSchema),
@@ -134,21 +116,35 @@ const NoteForm = () => {
format: [],
},
})
const currentTask = getCurrentTask()
/* ---- 派生状态(只 watch 一次,提高性能) ---- */
const platform = useWatch({ control: form.control, name: 'platform' }) as string
const videoUnderstandingEnabled = useWatch({ control: form.control, name: 'video_understanding' })
const editing = currentTask && currentTask.id
/* ---- 副作用 ---- */
/* ---- 副作用 ---- */
useEffect(() => {
loadEnabledModels()
return}, [])
useEffect(() => {
const currentTask = getCurrentTask()
const { formData } = currentTask || {}
if (!currentTask) return
form.reset(
{
...formData,
extras: formData?.extras || '',
}
)
}, [currentTaskId])
/* ---- 帮助函数 ---- */
const isGenerating = () =>
!['SUCCESS', 'FAILED', undefined].includes(getCurrentTask()?.status)
const generating = isGenerating()
const handleFileUpload = async (file: File, cb: (url: string) => void) => {
const formData = new FormData()
formData.append('file', file)
@@ -162,40 +158,83 @@ const NoteForm = () => {
}
const onSubmit = async (values: NoteFormValues) => {
const payload:NoteFormValues = {
...values,
provider_id: modelList.find(m => m.model_name === values.model_name)!.provider_id,
const payload:NoteFormValues = {
...values,
provider_id: modelList.find(m => m.model_name === values.model_name)!.provider_id,
task_id: currentTaskId || '',
}
if (currentTaskId){
retryTask(currentTaskId,payload)
return
}
message.success('已提交任务')
const { data } = await generateNote(payload)
addPendingTask(data.task_id, values.platform, payload)
}
const handleCreateNew = () => {
// 🔁 这里清空当前任务状态
// 比如调用 resetCurrentTask() 或者 navigate 到一个新页面
setCurrentTask(null)
}
const FormButton = () => {
const label = generating
? '正在生成…'
: editing
? '重新生成'
: '生成笔记'
return (
<div className="flex gap-2">
<Button type="submit" className={!editing?'w-full':'w-2/3'+" bg-primary"} disabled={generating}>
{generating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{label}
</Button>
{editing && (
<Button
type="button"
variant="outline"
className="w-1/3"
onClick={handleCreateNew}
>
<Plus className="mr-2 h-4 w-4" />
</Button>
)}
</div>
)
}
/* -------------------- 渲染 -------------------- */
return (
<ScrollArea className="sm:h-[400px] md:h-[800px]">
<div className="h-full w-full">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* 顶部按钮 */}
<Button type="submit" className="w-full bg-primary" disabled={isGenerating()}>
{isGenerating() && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isGenerating() ? '正在生成…' : '生成笔记'}
</Button>
<FormButton></FormButton>
{/* 视频链接 & 平台 */}
<SectionHeader title="视频链接" tip="支持 B 站、YouTube 等平台" />
<div className="flex gap-2">
{/* 平台选择 */}
<FormField
control={form.control}
name="platform"
render={({ field }) => (
<FormItem>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select disabled={!!editing} value={field.value} onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
</FormControl>
<SelectContent>
<SelectContent >
<SelectItem value="bilibili"></SelectItem>
<SelectItem value="youtube">YouTube</SelectItem>
<SelectItem value="douyin"></SelectItem>
@@ -214,65 +253,106 @@ const NoteForm = () => {
<FormItem className="flex-1">
{platform === 'local' ? (
<>
<Input placeholder="请输入本地视频路径" {...field} />
<div
className="hover:border-primary mt-2 flex h-40 cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-gray-300 transition-colors"
onDragOver={e => { e.preventDefault(); e.stopPropagation() }}
onDrop={e => {
e.preventDefault()
const file = e.dataTransfer.files?.[0]
if (file) handleFileUpload(file, field.onChange)
}}
onClick={() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'video/*'
input.onchange = e => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) handleFileUpload(file, field.onChange)
}
input.click()
}}
>
<p className="text-center text-sm text-gray-500">
<br />
<span className="text-xs text-gray-400"></span>
</p>
</div>
<Input disabled={!!editing} placeholder="请输入本地视频路径" {...field} />
</>
) : (
<Input placeholder="请输入视频网站链接" {...field} />
<Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} />
)}
<FormMessage />
</FormItem>
)}
/>
</div>
{/* 模型选择 */}
<FormField
control={form.control}
name="model_name"
render={({ field }) => (
<FormItem>
<SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" />
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
</FormControl>
<SelectContent>
{modelList.map(m => (
<SelectItem key={m.model_name} value={m.model_name}>
{m.model_name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="video_url"
render={({ field }) => (
<FormItem className="flex-1">
{platform === 'local' && (
<>
<div
className="hover:border-primary mt-2 flex h-40 cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-gray-300 transition-colors"
onDragOver={e => { e.preventDefault(); e.stopPropagation() }}
onDrop={e => {
e.preventDefault()
const file = e.dataTransfer.files?.[0]
if (file) handleFileUpload(file, field.onChange)
}}
onClick={() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'video/*'
input.onchange = e => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) handleFileUpload(file, field.onChange)
}
input.click()
}}
>
<p className="text-center text-sm text-gray-500">
<br />
<span className="text-xs text-gray-400"></span>
</p>
</div>
</>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-2">
{/* 模型选择 */}
<FormField
className="w-full"
control={form.control}
name="model_name"
render={({ field }) => (
<FormItem>
<SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" />
<Select value={field.value} onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full min-w-0 truncate" ><SelectValue /></SelectTrigger>
</FormControl>
<SelectContent>
{modelList.map(m => (
<SelectItem key={m.id} value={m.model_name}>
{m.model_name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* 笔记风格 */}
<FormField
className="w-full"
control={form.control}
name="style"
render={({ field }) => (
<FormItem>
<SectionHeader title="笔记风格" tip="选择生成笔记的呈现风格" />
<Select value={field.value} onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full min-w-0 truncate" ><SelectValue /></SelectTrigger>
</FormControl>
<SelectContent>
{noteStyles.map(({ label, value }) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* 视频理解 */}
<SectionHeader title="视频理解" tip="将视频截图发给多模态模型辅助分析" />
<div className="flex flex-col gap-2">
@@ -293,11 +373,7 @@ const NoteForm = () => {
</FormItem>
)}
/>
<Alert
type="info"
message="推荐多模态模型qwen2.5-vl-72b-instruct / gpt-4o"
className="text-sm"
/>
<div className="grid grid-cols-2 gap-4">
{/* 采样间隔 */}
<FormField
@@ -346,29 +422,21 @@ const NoteForm = () => {
)}
/>
</div>
<Alert
closable
type="error"
message={
<div>
<strong></strong>
<p>使</p>
</div>
}
className="text-sm"
/>
</div>
{/* 笔记风格 */}
<FormField
control={form.control}
name="style"
render={({ field }) => (
<FormItem>
<SectionHeader title="笔记风格" tip="选择生成笔记的呈现风格" />
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
</FormControl>
<SelectContent>
{noteStyles.map(({ label, value }) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* 笔记格式 */}
<FormField
@@ -404,7 +472,7 @@ const NoteForm = () => {
/>
</form>
</Form>
</ScrollArea>
</div>
)
}

View File

@@ -1,16 +1,20 @@
import { useTaskStore } from '@/store/taskStore'
import { FC } from 'react'
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
import { Badge } from '@/components/ui/badge.tsx'
import { cn } from '@/lib/utils.ts'
import { Trash } from 'lucide-react'
import { Button } from '@/components/ui/button.tsx'
import PinyinMatch from 'pinyin-match'
import Fuse from 'fuse.js'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip.tsx'
import LazyImage from "@/components/LazyImage.tsx";
import {FC, useState ,useEffect } from 'react'
interface NoteHistoryProps {
onSelect: (taskId: string) => void
@@ -20,20 +24,59 @@ interface NoteHistoryProps {
const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
const tasks = useTaskStore(state => state.tasks)
const removeTask = useTaskStore(state => state.removeTask)
const [rawSearch, setRawSearch] = useState('')
const [search, setSearch] = useState('')
const fuse = new Fuse(tasks, {
keys: ['audioMeta.title'],
threshold: 0.4 // 匹配精度(越低越严格)
})
useEffect(() => {
const timer = setTimeout(() => {
if (rawSearch === '') return
setSearch(rawSearch)
}, 300) // 300ms 防抖
if (tasks.length === 0) {
return () => clearTimeout(timer)
}, [rawSearch])
const filteredTasks = search.trim()
? fuse.search(search).map(result => result.item)
: tasks
if (filteredTasks.length === 0) {
return (
<div className="rounded-md border border-neutral-200 bg-neutral-50 py-6 text-center">
<p className="text-sm text-neutral-500"></p>
</div>
<>
<div className="mb-2">
<input
type="text"
placeholder="搜索笔记标题..."
className="w-full rounded border border-neutral-300 px-3 py-1 text-sm outline-none focus:border-primary"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="rounded-md border border-neutral-200 bg-neutral-50 py-6 text-center">
<p className="text-sm text-neutral-500"></p>
</div>
</>
)
}
return (
<>
<div className="mb-2">
<input
type="text"
placeholder="搜索笔记标题..."
className="w-full rounded border border-neutral-300 px-3 py-1 text-sm outline-none focus:border-primary"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2 overflow-hidden">
{tasks.map(task => (
{filteredTasks.map(task => (
<div
onClick={() => onSelect(task.id)}
className={cn(
'flex cursor-pointer flex-col rounded-md border border-neutral-200 p-3',
selectedId === task.id && 'border-primary bg-primary-light'
@@ -42,7 +85,7 @@ const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
<div
key={task.id}
className={cn('flex items-center gap-4')}
onClick={() => onSelect(task.id)}
>
{/* 封面图 */}
{task.platform === 'local' ? (
@@ -54,15 +97,15 @@ const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
className="h-10 w-12 rounded-md object-cover"
/>
) : (
<img
src={
task.audioMeta.cover_url
? `/api/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
: '/placeholder.png'
}
alt="封面"
className="h-10 w-12 rounded-md object-cover"
/>
<LazyImage
src={
task.audioMeta.cover_url
? `/api/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
: '/placeholder.png'
}
alt="封面"
/>
)}
{/* 标题 + 状态 */}

View File

@@ -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

View File

@@ -1,4 +1,11 @@
import { BotMessageSquare, Captions, HardDriveDownload, Wrench } from 'lucide-react'
import {
BotMessageSquare,
SquareChevronRight,
Captions,
HardDriveDownload,
Wrench,
Info,
} from 'lucide-react'
import MenuBar, { IMenuProps } from '@/pages/SettingPage/components/menuBar.tsx'
const Menu = () => {
@@ -25,6 +32,18 @@ const Menu = () => {
// },
// //其他配置
// {
// id: 'prompt',
// name: '提示词设置',
// icon: <SquareChevronRight />,
// path: '/settings/prompt',
// },
{
id: 'about',
name: '关于',
icon: <Info />,
path: '/settings/about',
},
// {
// id: 'other',
// name: '其他配置',
// icon: <Wrench />,

View File

@@ -0,0 +1,4 @@
const Prompt = () => {
return <div className={'flex h-full w-full bg-white'}>prompt</div>
}
export default Prompt

View File

@@ -0,0 +1,226 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Github, Star, ExternalLink, Download } from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
export default function AboutPage() {
const images = [
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504102850.png',
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504103028.png',
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504103304.png',
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504103625.png',
]
return (
<ScrollArea className={'h-full overflow-y-auto bg-white'}>
<div className="container mx-auto px-4 py-12">
{/* Hero Section */}
<div className="mb-16 flex flex-col items-center justify-center text-center">
<div className="mb-4 flex items-center gap-4">
<img
src="/public/icon.svg"
alt="BiliNote Logo"
width={50}
height={50}
className="rounded-lg"
/>
<h1 className="text-4xl font-bold">BiliNote v1.4.0</h1>
</div>
<p className="text-muted-foreground mb-6 text-xl italic">
AI AI
</p>
<div className="mb-8 flex flex-wrap justify-center gap-2">
<Badge variant="secondary">MIT License</Badge>
<Badge variant="secondary">React</Badge>
<Badge variant="secondary">FastAPI</Badge>
<Badge variant="secondary">Docker Compose</Badge>
<Badge variant="secondary">Active</Badge>
</div>
<div className="flex flex-wrap justify-center gap-4">
<Button asChild>
<a href="https://www.bilinote.app" target="_blank">
<ExternalLink className="mr-2 h-4 w-4" />
BiliNote
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://github.com/JefferyHcool/BiliNote" target="_blank">
<Github className="mr-2 h-4 w-4" />
GitHub
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://github.com/JefferyHcool/BiliNote/releases" target="_blank">
<Download className="mr-2 h-4 w-4" />
</a>
</Button>
</div>
</div>
{/* Project Introduction */}
<section className="mb-16">
<h2 className="mb-6 text-center text-3xl font-bold"> </h2>
<div className="mx-auto max-w-3xl text-center">
<p className="text-lg">
BiliNote AI YouTube
Markdown
</p>
</div>
</section>
{/* Features Section */}
<section className="mb-16">
<h2 className="mb-8 text-center text-3xl font-bold">🔧 </h2>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{[
{ title: '多平台支持', desc: '支持 Bilibili、YouTube、本地视频、抖音等多个平台' },
{ title: '笔记格式选择', desc: '支持返回多种笔记格式,满足不同需求' },
{ title: '笔记风格选择', desc: '支持多种笔记风格,个性化定制' },
{ title: '多模态视频理解', desc: '结合视觉和音频内容,全面理解视频' },
{ title: '自定义 GPT 配置', desc: '支持自行配置 GPT 大模型' },
{ title: '本地音频转写', desc: '支持 Fast-Whisper 等本地模型音频转写' },
{ title: '结构化笔记', desc: '自动生成结构化 Markdown 笔记' },
{ title: '智能截图', desc: '可选插入自动截取的关键画面' },
{ title: '内容跳转', desc: '支持关联原视频的内容跳转链接' },
].map((feature, index) => (
<Card key={index} className="h-full">
<CardContent className="pt-2">
<h3 className="mb-2 text-xl font-semibold">{feature.title}</h3>
<p className="text-muted-foreground">{feature.desc}</p>
</CardContent>
</Card>
))}
</div>
</section>
{/* Screenshots Section */}
<section className="mb-16">
<h2 className="mb-8 text-center text-3xl font-bold">📸 </h2>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{images.map(num => (
<div key={num} className="overflow-hidden rounded-lg border shadow-sm">
<img
src={num}
alt={`BiliNote Screenshot ${num}`}
width={600}
height={400}
className="w-full object-cover transition-transform hover:scale-105"
/>
</div>
))}
</div>
</section>
{/* Quick Start Section */}
<section className="mb-16">
<h2 className="mb-8 text-center text-3xl font-bold">🚀 </h2>
<Tabs defaultValue="manual" className="mx-auto max-w-3xl">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="manual"></TabsTrigger>
<TabsTrigger value="docker">Docker </TabsTrigger>
</TabsList>
<TabsContent value="manual" className="mt-6 space-y-6">
<div>
<h3 className="mb-3 text-xl font-semibold">1. </h3>
<div className="bg-muted rounded-md p-4 font-mono text-sm">
git clone https://github.com/JefferyHcool/BiliNote.git
<br />
cd BiliNote
<br />
mv .env.example .env
</div>
</div>
<div>
<h3 className="mb-3 text-xl font-semibold">2. FastAPI</h3>
<div className="bg-muted rounded-md p-4 font-mono text-sm">
cd backend
<br />
pip install -r requirements.txt
<br />
python main.py
</div>
</div>
<div>
<h3 className="mb-3 text-xl font-semibold">3. Vite + React</h3>
<div className="bg-muted rounded-md p-4 font-mono text-sm">
cd BiliNote_frontend
<br />
pnpm install
<br />
pnpm dev
</div>
</div>
<p>
访<code className="bg-muted rounded px-2 py-1">http://localhost:5173</code>
</p>
</TabsContent>
<TabsContent value="docker" className="mt-6 space-y-6">
<div>
<h3 className="mb-3 text-xl font-semibold">1. </h3>
<div className="bg-muted rounded-md p-4 font-mono text-sm">
git clone https://github.com/JefferyHcool/BiliNote.git
<br />
cd BiliNote
<br />
mv .env.example .env
</div>
</div>
<div>
<h3 className="mb-3 text-xl font-semibold">2. Docker Compose</h3>
<div className="bg-muted rounded-md p-4 font-mono text-sm">
docker compose up --build
</div>
</div>
<p>
<br />
http://localhost:${'{FRONTEND_PORT}'}
<br />
http://localhost:${'{BACKEND_PORT}'}
<br />
<span className="text-muted-foreground text-sm">
.env
</span>
</p>
</TabsContent>
</Tabs>
</section>
{/* Community Section */}
<section className="mb-16">
<h2 className="mb-8 text-center text-3xl font-bold"></h2>
<div className="mx-auto max-w-3xl">
<div className="flex flex-col items-center justify-center gap-8">
<div className="text-center">
<h3 className="mb-3 text-xl font-semibold">BiliNote QQ </h3>
<p className="text-lg font-medium">785367111</p>
</div>
<div className="text-center">
<h3 className="mb-3 text-xl font-semibold">BiliNote </h3>
<div className="bg-muted mx-auto flex h-52 w-52 items-center justify-center rounded-md">
<img src={'https://common-1304618721.cos.ap-chengdu.myqcloud.com/wechat.png'} />
</div>
</div>
</div>
</div>
</section>
{/* License Section */}
<section className="mb-8 text-center">
<h2 className="mb-4 text-3xl font-bold">📜 License</h2>
<p>MIT License</p>
</section>
{/* Footer */}
<footer className="border-t pt-8 text-center">
<p className="mb-4">💬 PR issueStar </p>
</footer>
</div>
</ScrollArea>
)
}

View File

@@ -13,10 +13,10 @@ interface IMenuItem {
menuItem: IMenuProps
}
const MenuBar: FC<IMenuItem> = ({ menuItem }) => {
const MenuBar: ({ menuItem }: { menuItem: any }) => JSX.Element = ({ menuItem }) => {
const location = useLocation()
const isActive = location.pathname.startsWith(menuItem.path + '/')
|| location.pathname === menuItem.path
const isActive =
location.pathname.startsWith(menuItem.path + '/') || location.pathname === menuItem.path
return (
<Link to={menuItem.path} className="w-full">

View File

@@ -1,6 +1,8 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { delete_task, generateNote } from '@/services/note.ts'
import { v4 as uuidv4 } from 'uuid'
export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILD'
@@ -26,10 +28,17 @@ export interface Transcript {
raw: any
segments: Segment[]
}
export interface Markdown {
ver_id: string
content: string
style: string
model_name: string
created_at: string
}
export interface Task {
id: string
markdown: string
markdown: string|Markdown [] //为了兼容之前的笔记
transcript: Transcript
status: TaskStatus
audioMeta: AudioMeta
@@ -64,6 +73,7 @@ export const useTaskStore = create<TaskStore>()(
currentTaskId: null,
addPendingTask: (taskId: string, platform: string, formData: any) =>
set(state => ({
tasks: [
{
@@ -95,24 +105,82 @@ export const useTaskStore = create<TaskStore>()(
})),
updateTaskContent: (id, data) =>
set(state => ({
tasks: state.tasks.map(task => (task.id === id ? { ...task, ...data } : task)),
})),
set(state => ({
tasks: state.tasks.map(task => {
if (task.id !== id) return task
if (task.status === 'SUCCESS' && data.status === 'SUCCESS') return task
// 如果是 markdown 字符串,封装为版本
if (typeof data.markdown === 'string') {
const prev = task.markdown
const newVersion: Markdown = {
ver_id: `${task.id}-${uuidv4()}`,
content: data.markdown,
style: task.formData.style || '',
model_name: task.formData.model_name || '',
created_at: new Date().toISOString(),
}
let updatedMarkdown: Markdown[]
if (Array.isArray(prev)) {
updatedMarkdown = [newVersion, ...prev]
} else {
updatedMarkdown = [
newVersion,
...(typeof prev === 'string' && prev
? [{
ver_id: `${task.id}-${uuidv4()}`,
content: prev,
style: task.formData.style || '',
model_name: task.formData.model_name || '',
created_at: new Date().toISOString(),
}]
: []),
]
}
return {
...task,
...data,
markdown: updatedMarkdown,
}
}
return { ...task, ...data }
}),
})),
getCurrentTask: () => {
const currentTaskId = get().currentTaskId
return get().tasks.find(task => task.id === currentTaskId) || null
},
retryTask: async (id: string) => {
const task = get().tasks.find(task => task.id === id).formData
retryTask: async (id: string, payload?: any) => {
const task = get().tasks.find(task => task.id === id)
if (!task) return
const newFormData = payload || task.formData
await generateNote({
task_id: id,
...task,
...newFormData,
})
set(state => ({
tasks: state.tasks.map(task => (task.id === id ? { ...task, status: 'PENDING' } : task)),
tasks: state.tasks.map(t =>
t.id === id
? {
...t,
formData: newFormData, // ✅ 显式更新 formData
status: 'PENDING',
}
: t
),
}))
},
removeTask: async id => {
const task = get().tasks.find(t => t.id === id)

View File

@@ -7,6 +7,7 @@ import yt_dlp
from app.downloaders.base import Downloader, DownloadQuality, QUALITY_MAP
from app.models.notes_model import AudioDownloadResult
from app.utils.path_helper import get_data_dir
from app.utils.url_parser import extract_video_id
class BilibiliDownloader(Downloader, ABC):
@@ -69,10 +70,19 @@ class BilibiliDownloader(Downloader, ABC):
"""
下载视频,返回视频文件路径
"""
if output_dir is None:
output_dir = get_data_dir()
os.makedirs(output_dir, exist_ok=True)
print("video_url",video_url)
video_id=extract_video_id(video_url, "bilibili")
video_path = os.path.join(output_dir, f"{video_id}.mp4")
if os.path.exists(video_path):
return video_path
# 检查是否已经存在
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
ydl_opts = {

View File

@@ -249,13 +249,21 @@ class DouyinDownloader(Downloader):
)
def download_video(self, video_url: str, output_dir: Union[str, None] = None) -> str:
try:
if output_dir is None:
output_dir = get_data_dir()
if not output_dir:
output_dir = self.cache_data
os.makedirs(output_dir, exist_ok=True)
video_id = self.extract_video_id(video_url)
video_path = os.path.join(output_dir, f"{video_id}.mp4")
if os.path.exists(video_path):
return video_path
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
video_data = self.fetch_video_info(video_url)

View File

@@ -7,6 +7,7 @@ import yt_dlp
from app.downloaders.base import Downloader, DownloadQuality
from app.models.notes_model import AudioDownloadResult
from app.utils.path_helper import get_data_dir
from app.utils.url_parser import extract_video_id
class YoutubeDownloader(Downloader, ABC):
@@ -67,12 +68,15 @@ class YoutubeDownloader(Downloader, ABC):
"""
if output_dir is None:
output_dir = get_data_dir()
video_id = extract_video_id(video_url, "youtube")
video_path = os.path.join(output_dir, f"{video_id}.mp4")
if os.path.exists(video_path):
return video_path
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
ydl_opts = {
'format': 'worst[ext=mp4]/worst',
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]',
'outtmpl': output_path,
'noplaylist': True,
'quiet': False,

View File

@@ -35,11 +35,10 @@ BASE_PROMPT = '''
根据上面的分段转录内容,生成结构化的笔记,遵循以下原则:
1. **完整信息**:记录尽可能多的相关细节,确保内容全面。
2. **清晰结构**:用合适的标题级别(`##``###`)整理内容,概述每个部分的要点。主标题用`#`来标识(如果额外重要的任务有格式需求可以不遵守)
3. **去除无关内容**:省略广告、填充词、问候语和不相关的言论。
4. **保留关键细节**:保留重要事实、示例、结论和建议。(如果额外重要的任务有格式需求可以不遵守)
5. **可读布局**:必要时使用项目符号,并保持段落简短,增强可读性。(如果额外重要的任务有格式需求可以不遵守)
6. 视频中提及的数学公式必须保留,并以 LaTeX 语法形式呈现,适合 Markdown 渲染。
2. **去除无关内容**:省略广告、填充词、问候语和不相关的言论。
3. **保留关键细节**:保留重要事实、示例、结论和建议。(如果额外重要的任务有格式需求可以不遵守)
4. **可读布局**:必要时使用项目符号,并保持段落简短,增强可读性。(如果额外重要的任务有格式需求可以不遵守)
5. 视频中提及的数学公式必须保留,并以 LaTeX 语法形式呈现,适合 Markdown 渲染。
请始终遵循此规则。

View File

@@ -58,7 +58,7 @@ def get_format_function(format_type):
def get_style_format(style):
style_map = {
'minimal': '1. **精简信息**: 仅记录最重要的内容,简洁明了。',
'detailed': '2. **详细记录**: 包含完整的时间戳和每个部分的详细讨论。',
'detailed': '2. **详细记录**: 包含完整的内容和每个部分的详细讨论。需要尽可能多的记录视频内容,最好详细的笔记',
'academic': '3. **学术风格**: 适合学术报告,正式且结构化。',
'xiaohongshu': '''4. **小红书风格**:
### 擅长使用下面的爆款关键词:

View File

@@ -217,17 +217,25 @@ def get_task_status(task_id: str):
@router.get("/image_proxy")
async def image_proxy(request: Request, url: str):
headers = {
"Referer": "https://www.bilibili.com/", # 模拟B站来源
"Referer": "https://www.bilibili.com/",
"User-Agent": request.headers.get("User-Agent", ""),
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url, headers=headers)
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail="图片获取失败")
content_type = resp.headers.get("Content-Type", "image/jpeg")
return StreamingResponse(resp.aiter_bytes(), media_type=content_type)
return StreamingResponse(
resp.aiter_bytes(),
media_type=content_type,
headers={
"Cache-Control": "public, max-age=86400", # ✅ 缓存一天
"Content-Type": content_type,
}
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -47,26 +47,26 @@ def add_provider(data: ProviderRequest):
@router.get("/get_all_providers")
def get_all_providers():
try:
res = ProviderService.get_all_providers()
res = ProviderService.get_all_providers_safe()
return R.success(data=res)
except Exception as e:
return R.error(msg=e)
@router.get("/get_provider_by_id/{id}")
def get_provider_by_id(id: str):
try:
res = ProviderService.get_provider_by_id(id)
return R.success(data=res)
except Exception as e:
return R.error(msg=e)
@router.get("/get_provider_by_name/{name}")
def get_provider_by_name(name: str):
try:
res = ProviderService.get_provider_by_name(name)
return R.success(data=res)
except Exception as e:
return R.error(msg=e)
# @router.get("/get_provider_by_id/{id}")
# def get_provider_by_id(id: str):
# try:
# res = ProviderService.get_provider_by_id(id)
# return R.success(data=res)
# except Exception as e:
# return R.error(msg=e)
#
# @router.get("/get_provider_by_name/{name}")
# def get_provider_by_name(name: str):
# try:
# res = ProviderService.get_provider_by_name(name)
# return R.success(data=res)
# except Exception as e:
# return R.error(msg=e)
@router.post("/update_provider")

View File

@@ -45,7 +45,16 @@ class ModelService:
except Exception as e:
print(f"获取所有模型失败: {e}")
return []
@staticmethod
def get_all_models_safe(verbose: bool = False):
try:
raw_models = get_all_models()
if verbose:
print(f"所有模型列表: {raw_models}")
return ModelService._format_models(raw_models)
except Exception as e:
print(f"获取所有模型失败: {e}")
return []
@staticmethod
def _format_models(raw_models: list) -> list:
"""

View File

@@ -140,7 +140,6 @@ class NoteGenerator:
replacement = f"![]({image_url})"
new_markdown = new_markdown.replace(marker, replacement, 1)
return new_markdown
except Exception as e:
logger.error(f"截图生成失败:{e}")
@@ -201,16 +200,23 @@ class NoteGenerator:
# -------- 1. 下载音频 --------
try:
self.update_task_status(task_id, TaskStatus.DOWNLOADING)
# 加载音频缓存(如果存在)
audio = None
if os.path.exists(audio_cache_path):
logger.info(f"检测到已有音频缓存直接读取task_id={task_id}")
with open(audio_cache_path, "r", encoding="utf-8") as f:
audio_data = json.load(f)
audio = AudioDownloadResult(**audio_data)
else:
if 'screenshot' in _format or video_understanding:
# 需要视频的情况(截图 or 视频理解)
need_video = 'screenshot' in _format or video_understanding
if need_video:
try:
video_path = downloader.download_video(video_url)
self.video_path = video_path
logger.info(f"成功下载视频文件: {video_path}")
video_img_urls = VideoReader(
video_path=video_path,
grid_size=tuple(grid_size),
@@ -219,13 +225,17 @@ class NoteGenerator:
unit_height=720,
save_quality=90,
).run()
except Exception as e:
logger.error(f"❌ 下载视频失败task_id={task_id},错误信息:{e}")
self.update_task_status(task_id, TaskStatus.FAILED, message=f"下载音频失败:{e}")
screenshot = 'screenshot' in _format
audio: AudioDownloadResult = downloader.download(
# 没有音频缓存就下载音频(可能同时也带上视频)
if audio is None:
audio = downloader.download(
video_url=video_url,
quality=quality,
output_dir=path,
need_video=screenshot
need_video='screenshot' in _format, # 注意这里只为了截图需要
)
with open(audio_cache_path, "w", encoding="utf-8") as f:
json.dump(asdict(audio), f, ensure_ascii=False, indent=2)
@@ -266,27 +276,27 @@ class NoteGenerator:
# -------- 3. 总结内容 --------
try:
self.update_task_status(task_id, TaskStatus.SUMMARIZING)
if os.path.exists(markdown_cache_path):
logger.info(f"检测到已有总结缓存直接读取task_id={task_id}")
with open(markdown_cache_path, "r", encoding="utf-8") as f:
markdown = f.read()
else:
source = GPTSource(
title=audio.title,
segment=transcript.segments,
tags=audio.raw_info.get('tags'),
screenshot=screenshot,
video_img_urls=video_img_urls,
link=link,
_format=_format,
style=style,
extras=extras
)
# if os.path.exists(markdown_cache_path):
# logger.info(f"检测到已有总结缓存直接读取task_id={task_id}")
# with open(markdown_cache_path, "r", encoding="utf-8") as f:
# markdown = f.read()
# else:
source = GPTSource(
title=audio.title,
segment=transcript.segments,
tags=audio.raw_info.get('tags'),
screenshot=screenshot,
video_img_urls=video_img_urls,
link=link,
_format=_format,
style=style,
extras=extras
)
markdown: str = gpt.summarize(source)
with open(markdown_cache_path, "w", encoding="utf-8") as f:
f.write(markdown)
logger.info(f"GPT总结并缓存成功task_id={task_id}")
markdown: str = gpt.summarize(source)
with open(markdown_cache_path, "w", encoding="utf-8") as f:
f.write(markdown)
logger.info(f"GPT总结并缓存成功task_id={task_id}")
except Exception as e:
logger.error(f"❌ 总结内容失败task_id={task_id},错误信息:{e}")
self.update_task_status(task_id, TaskStatus.FAILED, message=f"总结内容失败:{e}")
@@ -313,10 +323,11 @@ class NoteGenerator:
# -------- 6. 完成 --------
self.update_task_status(task_id, TaskStatus.SUCCESS)
logger.info(f"✅ 笔记生成成功task_id={task_id}")
if platform != 'local':
transcription_finished.send({
"file_path": audio.file_path,
})
# TODO :改为前端一键清除缓存
# if platform != 'local':
# transcription_finished.send({
# "file_path": audio.file_path,
# })
return NoteResult(
markdown=markdown,
transcript=transcript,

View File

@@ -14,6 +14,7 @@ from app.models.model_config import ModelConfig
class ProviderService:
@staticmethod
def serialize_provider(row: tuple) -> dict:
if not row:
@@ -28,7 +29,25 @@ class ProviderService:
"enabled": row[6],
"created_at": row[7],
}
@staticmethod
def serialize_provider_safe(row: tuple) -> dict:
if not row:
return None
return {
"id": row[0],
"name": row[1],
"logo": row[2],
"type": row[3],
"api_key": ProviderService.mask_key(row[4]),
"base_url": row[5],
"enabled": row[6],
"created_at": row[7],
}
@staticmethod
def mask_key(key: str) -> str:
if not key or len(key) < 8:
return '*' * len(key)
return key[:4] + '*' * (len(key) - 8) + key[-4:]
@staticmethod
def add_provider( name: str, api_key: str, base_url: str, logo: str, type_: str, enabled: int = 1):
try:
@@ -42,7 +61,10 @@ class ProviderService:
def get_all_providers():
rows = get_all_providers()
return [ProviderService.serialize_provider(row) for row in rows] if rows else []
@staticmethod
def get_all_providers_safe():
rows = get_all_providers()
return [ProviderService.serialize_provider(row) for row in rows] if rows else []
@staticmethod
def get_provider_by_name(name: str):
row = get_provider_by_name(name)