Merge pull request #42 from JefferyHcool/dev

feat: 新增模型管理和供应商配置功能
This commit is contained in:
Jianwu Huang
2025-04-26 23:42:19 +08:00
committed by GitHub
113 changed files with 12764 additions and 1290 deletions

View File

@@ -1,3 +1,11 @@
###
# @Author: 思诺特 jefferyhcool@gmail.com
# @Date: 2025-04-14 08:49:59
# @LastEditors: 思诺特 jefferyhcool@gmail.com
# @LastEditTime: 2025-04-26 19:56:50
# @FilePath: \BiliNote\.env.example
# @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
###
# 通用端口配置
BACKEND_PORT=8001
FRONTEND_PORT=3015
@@ -13,17 +21,6 @@ STATIC=/static
OUT_DIR=./static/screenshots
IMAGE_BASE_URL=/static/screenshots
DATA_DIR=data
# AI 相关配置
OPENAI_API_KEY=
OPENAI_API_BASE_URL=
OPENAI_MODEL=
DEEP_SEEK_API_KEY=
DEEP_SEEK_API_BASE_URL=
DEEP_SEEK_MODEL=
QWEN_API_KEY=
QWEN_API_BASE_URL=
QWEN_MODEL=
MODEl_PROVIDER= #如果不是openai 请修改 deepseek/qwen
# FFMPEG 配置
FFMPEG_BIN_PATH=

3
.gitignore vendored
View File

@@ -315,4 +315,5 @@ cython_debug/
/backend/logs/
/backend/note_results
/backend/models
/backend/.idea
/backend/.idea/*
/backend/bili_note.db

View File

@@ -1,3 +0,0 @@
# 前端专用
VITE_API_BASE_URL=http://127.0.0.1:8000
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8000/static/screenshots

View File

@@ -0,0 +1,8 @@
dist
build
node_modules
*.svg
*.lock
*.png
public
coverage

View File

@@ -0,0 +1,11 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@@ -18,4 +18,4 @@
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
}

View File

@@ -19,10 +19,7 @@ export default tseslint.config(
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
}
)

View File

@@ -11,15 +11,21 @@
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@lobehub/icons": "^1.97.1",
"@lobehub/icons-static-svg": "^1.45.0",
"@lottiefiles/dotlottie-react": "^0.13.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-scroll-area": "^1.2.3",
"@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-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.1.3",
"@uiw/react-markdown-preview": "^5.1.3",
"antd": "^5.24.8",
"axios": "^1.8.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -33,6 +39,7 @@
"react-hook-form": "^7.55.0",
"react-hot-toast": "^2.5.2",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.5.1",
"react-syntax-highlighter": "^15.6.1",
"remark-gfm": "1.0.0",
"sonner": "^2.0.3",
@@ -54,6 +61,8 @@
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.2.0"

View File

@@ -1,6 +1,6 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

View File

@@ -1,16 +1,47 @@
import './App.css'
import {HomePage} from "./pages/Home.tsx";
import {useTaskPolling} from "@/hooks/useTaskPolling.ts";
import { HomePage } from './pages/HomePage/Home.tsx'
import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
import SettingPage from './pages/SettingPage/index.tsx'
import { BrowserRouter, Navigate, Routes } from 'react-router-dom'
import { Route } from 'react-router-dom'
import Index from '@/pages/Index.tsx'
import NotFoundPage from '@/pages/NotFoundPage' //
import Model from '@/pages/SettingPage/Model.tsx'
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'
function App() {
useTaskPolling(3000) // 每 3 秒轮询一次
return (
<>
<HomePage></HomePage>
</>
)
useTaskPolling(3000) // 每 3 秒轮询一次
const steps = [
{ label: '解析链接', key: 'PARSING', icon: <Downloading /> },
{ label: '下载音频', key: 'DOWNLOADING' },
{ label: '转写文字', key: 'TRANSCRIBING' },
{ label: '总结内容', key: 'SUMMARIZING' },
{ label: '保存完成', key: 'SUCCESS' },
]
return (
<>
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />}>
<Route index element={<HomePage />} />
<Route path="settings" element={<SettingPage />}>
<Route index element={<Navigate to="model" replace />} />
<Route path="model" element={<Model />}>
<Route path="new" element={<ProviderForm isCreate />} />
{/*<Route index element={<Navigate to="openai" replace />} />*/}
<Route path=":id" element={<ProviderForm />} />
</Route>
<Route path="transcriber" elment={<Transcriber />}></Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</BrowserRouter>
</>
)
}
export default App

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
.card {
transition: all 0.2s ease-in-out;
}
.card:hover {
background-color: #f7f7f7;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
import { FC } from 'react'
import Lottie from 'lottie-react'
import Animation from '@/assets/Lottie/404.json'
const NotFound: FC = () => {
return (
<div className="flex items-center justify-center">
<Lottie animationData={Animation} loop autoplay />
</div>
)
}
export default NotFound

View File

@@ -3,16 +3,11 @@ import Lottie from 'lottie-react'
import loadingJson from '@/assets/Lottie/idle.json'
const Idle: FC = () => {
return (
<div className="flex justify-center items-center ">
<Lottie
animationData={loadingJson}
loop
autoplay
style={{ width: 350, height: 350 }}
/>
</div>
)
return (
<div className="flex items-center justify-center">
<Lottie animationData={loadingJson} loop autoplay style={{ width: 350, height: 350 }} />
</div>
)
}
export default Idle

View File

@@ -3,16 +3,11 @@ import Lottie from 'lottie-react'
import loadingJson from '@/assets/Lottie/loading.json'
const Loading: FC = () => {
return (
<div className="flex justify-center items-center ">
<Lottie
animationData={loadingJson}
loop
autoplay
style={{ width: 150, height: 150 }}
/>
</div>
)
return (
<div className="flex items-center justify-center">
<Lottie animationData={loadingJson} loop autoplay style={{ width: 150, height: 150 }} />
</div>
)
}
export default Loading

View File

@@ -0,0 +1,40 @@
import { FC, useRef, useEffect } from 'react'
import Lottie, { LottieRefCurrentProps } from 'lottie-react'
import download from '@/assets/Lottie/download.json'
interface LoadingProps {
play?: boolean // 是否播放
color?: string // 控制主色,比如 "#00BFFF"
}
const Downloading: FC<LoadingProps> = ({ play = true, color = '#00BFFF' }) => {
const lottieRef = useRef<LottieRefCurrentProps>(null)
useEffect(() => {
if (!lottieRef.current) return
if (play) {
lottieRef.current.play()
} else {
lottieRef.current.pause()
}
}, [play])
return (
<div className="flex items-center justify-center">
<Lottie
lottieRef={lottieRef}
animationData={download}
loop
autoplay={play}
style={{
width: 150,
height: 150,
filter: `drop-shadow(0 0 4px ${color}) saturate(2) brightness(1.2)`,
}}
/>
</div>
)
}
export default Downloading

View File

@@ -0,0 +1,21 @@
import { FC } from 'react'
import Lottie from 'lottie-react'
import error from '@/assets/Lottie/error.json'
const Error: FC = () => {
return (
<div className="flex items-center justify-center">
<Lottie
animationData={error}
loop
autoplay
style={{
width: 450,
height: 450,
}}
/>
</div>
)
}
export default Error

View File

@@ -0,0 +1,64 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
success:
'text-success bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-success/90',
warning:
'text-[#303133] bg-[#FEF0F0] [&>svg]:text-current *:data-[slot=alert-description]:text-warning/90',
},
},
defaultVariants: {
variant: 'default',
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
{...props}
/>
)
}
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,26 +1,24 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
}
)
@@ -30,17 +28,10 @@ function Badge({
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span'
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -1,36 +1,33 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
)
@@ -41,11 +38,11 @@ function Button({
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : 'button'
return (
<Comp

View File

@@ -1,13 +1,13 @@
import * as React from "react"
import * as React from 'react'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<"div">) {
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className
)}
{...props}
@@ -15,12 +15,12 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...props}
@@ -28,65 +28,48 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }

View File

@@ -1,18 +1,15 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from 'lucide-react'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import {
Controller,
FormProvider,
@@ -9,10 +9,10 @@ import {
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
} from 'react-hook-form'
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
const Form = FormProvider
@@ -23,9 +23,7 @@ type FormFieldContextValue<
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
@@ -48,7 +46,7 @@ const useFormField = () => {
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
throw new Error('useFormField should be used within <FormField>')
}
const { id } = itemContext
@@ -67,35 +65,26 @@ type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
<div data-slot="form-item" className={cn('grid gap-2', className)} {...props} />
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
@@ -109,33 +98,29 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
const body = error ? String(error?.message ?? '') : props.children
if (!body) {
return null
@@ -145,7 +130,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}

View File

@@ -1,16 +1,16 @@
import * as React from "react"
import * as React from 'react'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
{...props}

View File

@@ -1,19 +1,16 @@
"use client"
'use client'
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
function ScrollArea({
className,
@@ -11,7 +11,7 @@ function ScrollArea({
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
@@ -28,7 +28,7 @@ function ScrollArea({
function ScrollBar({
className,
orientation = "vertical",
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
@@ -36,11 +36,9 @@ function ScrollBar({
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
className
)}
{...props}

View File

@@ -1,34 +1,28 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
size?: 'sm' | 'default'
}) {
return (
<SelectPrimitive.Trigger
@@ -51,7 +45,7 @@ function SelectTrigger({
function SelectContent({
className,
children,
position = "popper",
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
@@ -59,9 +53,9 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
@@ -70,9 +64,9 @@ function SelectContent({
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)}
>
{children}
@@ -83,14 +77,11 @@ function SelectContent({
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
)
@@ -127,7 +118,7 @@ function SelectSeparator({
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
)
@@ -140,10 +131,7 @@ function SelectScrollUpButton({
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUpIcon className="size-4" />
@@ -158,10 +146,7 @@ function SelectScrollDownButton({
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDownIcon className="size-4" />

View File

@@ -1,18 +1,18 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
import { useTheme } from 'next-themes'
import { Toaster as Sonner, ToasterProps } from 'sonner'
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
const { theme = 'system' } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
theme={theme as ToasterProps['theme']}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
} as React.CSSProperties
}
{...props}

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
function TooltipProvider({
delayDuration = 0,
@@ -16,9 +16,7 @@ function TooltipProvider({
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
@@ -26,9 +24,7 @@ function Tooltip({
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
@@ -44,7 +40,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className
)}
{...props}

View File

@@ -1,46 +1,59 @@
// hooks/useTaskPolling.ts
import { useEffect } from "react"
import { useTaskStore } from "@/store/taskStore"
import {get_task_status} from "@/services/note.ts";
import { useEffect, useRef } from 'react'
import { useTaskStore } from '@/store/taskStore'
import { get_task_status } from '@/services/note.ts'
import toast from 'react-hot-toast'
export const useTaskPolling = (interval = 3000) => {
const tasks = useTaskStore(state => state.tasks)
const updateTaskContent = useTaskStore(state => state.updateTaskContent)
const removeTask=useTaskStore(state=>state.removeTask)
useEffect(() => {
const timer = setInterval(async () => {
const pendingTasks = tasks.filter(
(task) => task.status === "PENDING" || task.status === "running"
)
const tasks = useTaskStore(state => state.tasks)
const updateTaskContent = useTaskStore(state => state.updateTaskContent)
const updateTaskStatus = useTaskStore(state => state.updateTaskStatus)
const removeTask = useTaskStore(state => state.removeTask)
for (const task of pendingTasks) {
try {
console.log(task)
const res = await get_task_status(task.id)
const {status}=res.data
const tasksRef = useRef(tasks)
if (status && status !== task.status) {
if (status === "SUCCESS") {
const { markdown, transcript, audio_meta } = res.data.result
// 每次 tasks 更新,把最新的 tasks 同步进去
useEffect(() => {
tasksRef.current = tasks
}, [tasks])
updateTaskContent(task.id, {
status,
markdown,
transcript,
audioMeta: audio_meta,
})
} else {
updateTaskStatus(task.id, status)
}
}
} catch (e) {
console.error("❌ 任务轮询失败:", e)
removeTask(task.id)
useEffect(() => {
const timer = setInterval(async () => {
const pendingTasks = tasksRef.current.filter(
task => task.status != 'SUCCESS' && task.status != 'FAILED'
)
}
for (const task of pendingTasks) {
try {
console.log('🔄 正在轮询任务:', task.id)
const res = await get_task_status(task.id)
const { status } = res.data
if (status && status !== task.status) {
if (status === 'SUCCESS') {
const { markdown, transcript, audio_meta } = res.data.result
toast.success('笔记生成成功')
updateTaskContent(task.id, {
status,
markdown,
transcript,
audioMeta: audio_meta,
})
} else if (status === 'FAILED') {
updateTaskContent(task.id, { status })
console.warn(`⚠️ 任务 ${task.id} 失败`)
} else {
updateTaskContent(task.id, { status })
}
}, interval)
}
} catch (e) {
console.error('❌ 任务轮询失败:', e)
toast.error(`生成失败 ${e.message || e}`)
updateTaskContent(task.id, { status: 'FAILED' })
// removeTask(task.id)
}
}
}, interval)
return () => clearInterval(timer)
}, [interval, tasks])
return () => clearInterval(timer)
}, [interval])
}

View File

@@ -1,5 +1,5 @@
@import "tailwindcss";
@import "tw-animate-css";
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@@ -11,7 +11,7 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #3C77FB;
--primary: #3c77fb;
--primary-light: #e0eeff;
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
@@ -46,8 +46,8 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: #3C77FB;
--primary-light:#e0eeff;
--primary: #3c77fb;
--primary-light: #e0eeff;
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);

View File

@@ -12,7 +12,7 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #3C77FB;
--primary: #3c77fb;
--primary-light: #e0eeff;
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
@@ -47,8 +47,8 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: #3C77FB;
--primary-light:#e0eeff;
--primary: #3c77fb;
--primary-light: #e0eeff;
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);

View File

@@ -1,43 +1,71 @@
import type { FC, ReactNode } from 'react'
import { Button } from "@/components/ui/button.tsx"
import React, { FC } from 'react'
import { SlidersHorizontal } from 'lucide-react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip.tsx'
interface HomeLayoutProps {
form: ReactNode
preview: ReactNode
import { useState } from 'react'
import { Link } from 'react-router-dom'
interface IProps {
NoteForm: React.ReactNode
Preview: React.ReactNode
}
const HomeLayout: FC<IProps> = ({ NoteForm, Preview }) => {
const [, setShowSettings] = useState(false)
const HomeLayout: FC<HomeLayoutProps> = ({ form, preview }) => {
return (
<div className="min-h-screen flex flex-col bg-white">
<div className="flex flex-1">
{/* 左侧部分Header + 表单 */}
<aside className="w-[400px] bg-white border-r border-neutral-200 flex flex-col">
{/* Header */}
<header className="h-16 flex items-center px-6 gap-2">
<div className="w-10 h-10 rounded-2xl overflow-hidden flex justify-center items-center">
<img src="/icon.svg" alt="logo" className="w-full h-full object-contain" />
</div>
<div className="text-2xl font-bold text-gray-800">BiliNote</div>
</header>
{/* 表单内容 */}
<div className="flex-1 p-4 overflow-auto">
{form}
</div>
</aside>
{/* 右侧预览区域 */}
<main className="flex-1 h-screen p-6 bg-white overflow-hidden">
{preview}
</main>
return (
<div className="flex min-h-screen flex-col bg-white">
<div className="flex flex-1">
{/* 左侧部分Header + 表单 */}
<aside className="flex w-[400px] flex-col border-r border-neutral-200 bg-white">
{/* Header */}
<header className="flex h-16 items-center justify-between px-6">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-2xl">
<img src="/icon.svg" alt="logo" className="h-full w-full object-contain" />
</div>
<div className="text-2xl font-bold text-gray-800">BiliNote</div>
</div>
<div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger onClick={() => setShowSettings(true)}>
<Link to={'/settings'}>
<SlidersHorizontal className="text-muted-foreground hover:text-primary cursor-pointer" />
</Link>
</TooltipTrigger>
<TooltipContent>
<span></span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</header>
{/* 页脚 */}
{/*<footer className="h-12 bg-white shadow-inner flex items-center justify-center text-sm text-neutral-600">*/}
{/* © 2025 BiliNote. All rights reserved.*/}
{/*</footer>*/}
</div>
)
{/* 表单内容 */}
<div className="flex-1 overflow-auto p-4">
{/*<NoteForm />*/}
{NoteForm}
</div>
</aside>
{/* 右侧预览区域 */}
<main className="h-screen flex-1 overflow-hidden bg-white p-6">
{/*<Outlet />*/}
{Preview}
</main>
</div>
{/* 页脚 */}
{/*<footer className="h-12 bg-white shadow-inner flex items-center justify-center text-sm text-neutral-600">*/}
{/* © 2025 BiliNote. All rights reserved.*/}
{/*</footer>*/}
</div>
)
}
export default HomeLayout

View File

@@ -1,32 +1,32 @@
import type { ReactNode, FC } from "react"
import type { ReactNode, FC } from 'react'
// import "@/global.css"
import { Toaster } from 'react-hot-toast'
interface RootLayoutProps {
children: ReactNode
children: ReactNode
}
export const metadata = {
title: "BiliNote - 视频笔记生成器",
description: "通过视频链接结合大模型自动生成对应的笔记",
title: 'BiliNote - 视频笔记生成器',
description: '通过视频链接结合大模型自动生成对应的笔记',
}
const RootLayout: FC<RootLayoutProps> = ({ children }) => {
return (
<div className="min-h-screen bg-neutral-100 text-neutral-900 font-sans">
<Toaster
position="top-center" // 顶部居中显示
toastOptions={{
style: {
borderRadius: '8px',
background: '#333',
color: '#fff',
},
}}
/>
{children}
</div>
)
return (
<div className="min-h-screen bg-neutral-100 font-sans text-neutral-900">
<Toaster
position="top-center" // 顶部居中显示
toastOptions={{
style: {
borderRadius: '8px',
background: '#333',
color: '#fff',
},
}}
/>
{children}
</div>
)
}
export default RootLayout

View File

@@ -0,0 +1,63 @@
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip.tsx'
import { Link, Outlet } from 'react-router-dom'
import { SlidersHorizontal } from 'lucide-react'
import React from 'react'
interface ISettingLayoutProps {
Menu: React.ReactNode
}
const SettingLayout = ({ Menu }: ISettingLayoutProps) => {
return (
<div
className="h-full w-full"
style={{
backgroundColor: 'var(--color-muted)',
}}
>
<div className="flex flex-1">
{/* 左侧部分Header + 表单 */}
<aside className="flex w-[300px] flex-col border-r border-neutral-200 bg-white">
{/* Header */}
<header className="flex h-16 items-center justify-between px-6">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-2xl">
<img src="/icon.svg" alt="logo" className="h-full w-full object-contain" />
</div>
<div className="text-2xl font-bold text-gray-800">BiliNote</div>
</div>
<div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Link to={'/'}>
<SlidersHorizontal className="text-muted-foreground hover:text-primary cursor-pointer" />
</Link>
</TooltipTrigger>
<TooltipContent>
<span></span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</header>
{/* 表单内容 */}
<div className="flex-1 overflow-auto p-4">
{/*<NoteForm />*/}
{Menu}
</div>
</aside>
{/* 右侧预览区域 */}
<main className="h-screen flex-1 overflow-hidden">
<Outlet />
</main>
</div>
</div>
)
}
export default SettingLayout

View File

@@ -1,5 +1,5 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))

View File

@@ -2,12 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import RootLayout from "./layouts/RootLayout.tsx";
import RootLayout from './layouts/RootLayout.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RootLayout>
<App />
</RootLayout>
</StrictMode>,
<StrictMode>
<RootLayout>
<App />
</RootLayout>
</StrictMode>
)

View File

@@ -1,42 +0,0 @@
import React,{FC,useEffect,useState} from "react";
import HomeLayout from "@/layouts/HomeLayout.tsx";
import NoteForm from '@/pages/components/NoteForm'
import MarkdownViewer from '@/pages/components/MarkdownViewer'
import NoteFormWrapper from "@/pages/components/NoteFormWrapper.tsx";
import {get_task_status} from "@/services/note.ts";
import {useTaskStore} from "@/store/taskStore";
type ViewStatus = 'idle' | 'loading' | 'success'
export const HomePage:FC =()=>{
const tasks = useTaskStore((state) => state.tasks)
const currentTaskId = useTaskStore((state) => state.currentTaskId)
const currentTask = tasks.find((t) => t.id === currentTaskId)
const [status, setStatus] = useState<ViewStatus>('idle')
const content = currentTask?.markdown || ''
useEffect(() => {
if (!currentTask) {
setStatus('idle')
} else if (currentTask.status === 'PENDING') {
setStatus('loading')
} else if (currentTask.status === 'SUCCESS') {
setStatus('success')
}
}, [currentTask])
// useEffect( () => {
// get_task_status('d4e87938-c066-48a0-bbd5-9bec40d53354').then(res=>{
// console.log('res1',res)
// setContent(res.data.result.markdown)
// })
// }, [tasks]);
return (
<HomeLayout
form={<NoteForm/>}
preview={<MarkdownViewer status={status} content={content} />}
/>
)
}

View File

@@ -0,0 +1,41 @@
import { FC, useEffect, useState } from 'react'
import HomeLayout from '@/layouts/HomeLayout.tsx'
import NoteForm from '@/pages/HomePage/components/NoteForm.tsx'
import MarkdownViewer from '@/pages/HomePage/components/MarkdownViewer.tsx'
import { useTaskStore } from '@/store/taskStore'
type ViewStatus = 'idle' | 'loading' | 'success' | 'failed'
export const HomePage: FC = () => {
const tasks = useTaskStore(state => state.tasks)
const currentTaskId = useTaskStore(state => state.currentTaskId)
const currentTask = tasks.find(t => t.id === currentTaskId)
const [status, setStatus] = useState<ViewStatus>('idle')
const content = currentTask?.markdown || ''
useEffect(() => {
if (!currentTask) {
setStatus('idle')
} else if (currentTask.status === 'PENDING') {
setStatus('loading')
} else if (currentTask.status === 'SUCCESS') {
setStatus('success')
} else if (currentTask.status === 'FAILED') {
setStatus('failed')
}
}, [currentTask])
// useEffect( () => {
// get_task_status('d4e87938-c066-48a0-bbd5-9bec40d53354').then(res=>{
// console.log('res1',res)
// setContent(res.data.result.markdown)
// })
// }, [tasks]);
return (
<HomeLayout
NoteForm={<NoteForm />}
Preview={<MarkdownViewer status={status} content={content} />}
/>
)
}

View File

@@ -0,0 +1,197 @@
import { useState } 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 'sonner' // 你可以换成自己的通知组件
import Error from '@/components/Lottie/error.tsx'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { solarizedlight as codeStyle } from 'react-syntax-highlighter/dist/cjs/styles/prism'
import 'github-markdown-css/github-markdown-light.css'
import { FC } from 'react'
import Loading from '@/components/Lottie/Loading.tsx'
import Idle from '@/components/Lottie/Idle.tsx'
import { useTaskStore } from '@/store/taskStore'
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
interface MarkdownViewerProps {
content: string
status: 'idle' | 'loading' | 'success' | 'failed'
}
const steps = [
{ label: '解析链接', key: 'PARSING' },
{ label: '下载音频', key: 'DOWNLOADING' },
{ label: '转写文字', key: 'TRANSCRIBING' },
{ label: '总结内容', key: 'SUMMARIZING' },
{ label: '保存完成', key: 'SUCCESS' },
]
const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
const [copied, setCopied] = useState(false)
const getCurrentTask = useTaskStore.getState().getCurrentTask
const currentTask = useTaskStore(state => state.getCurrentTask())
const taskStatus = currentTask?.status || 'PENDING'
const retryTask = useTaskStore.getState().retryTask
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content)
setCopied(true)
toast.success('已复制到剪贴板')
setTimeout(() => setCopied(false), 2000)
} catch (e) {
toast.error(`复制失败${e}`)
toast.error('复制失败', e)
}
}
const handleDownload = () => {
const currentTask = getCurrentTask()
const currentTaskName = currentTask?.audioMeta.title
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `${currentTaskName}.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>
</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>
)
}
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>
</div>
{/* 滚动容器 */}
<div className="overflow-y-auto">
{(content && content != 'loading') || content != 'empty' ? (
<div className="markdown-body flex-1 bg-white">
{' '}
<ReactMarkdown
components={{
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={() => {
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>
)
}
export default MarkdownViewer

View File

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

View File

@@ -0,0 +1,15 @@
import { useForm } from 'react-hook-form'
import { Form } from '@/components/ui/form.tsx'
import NoteForm from './NoteForm.tsx'
const NoteFormWrapper = () => {
const form = useForm()
return (
<Form {...form}>
<NoteForm />
</Form>
)
}
export default NoteFormWrapper

View File

@@ -0,0 +1,106 @@
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 {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip.tsx'
interface NoteHistoryProps {
onSelect: (taskId: string) => void
selectedId: string | null
}
const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
const tasks = useTaskStore(state => state.tasks)
const removeTask = useTaskStore(state => state.removeTask)
if (tasks.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>
)
}
return (
<ScrollArea className="h-auto max-h-[20vh] sm:max-h-[10vh]">
<div className="flex flex-col space-y-2">
{tasks.map(task => (
<div
key={task.id}
className={cn(
'flex cursor-pointer items-center gap-4 rounded-md border p-3 transition hover:bg-neutral-50',
selectedId === task.id && 'border-primary bg-primary-light'
)}
onClick={() => onSelect(task.id)}
>
{/* 封面图 */}
<img
src={
task.audioMeta.cover_url
? `/api/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
: '/placeholder.png'
}
alt="封面"
className="h-10 w-16 rounded-md object-cover"
/>
{/* 标题 + 状态 */}
<div className="flex w-full min-w-0 items-center justify-between gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="max-w-[120px] flex-1 truncate font-medium">
{task.audioMeta.title || '未命名笔记'}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{task.audioMeta.title || '未命名笔记'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="shrink-0">
{task.status === 'SUCCESS' && <Badge variant="default"></Badge>}
{task.status === 'PENDING' && <Badge variant="outline"></Badge>}
{task.status === 'FAILED' && <Badge variant="destructive"></Badge>}
</div>
</div>
{/* 删除按钮 */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
onClick={e => {
e.stopPropagation()
removeTask(task.id)
}}
className="shrink-0"
>
<Trash className="text-muted-foreground h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
))}
</div>
</ScrollArea>
)
}
export default NoteHistory

View File

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

View File

@@ -0,0 +1,10 @@
import { Outlet } from 'react-router-dom'
const Index = () => {
return (
<>
<Outlet />
</>
)
}
export default Index

View File

@@ -0,0 +1,25 @@
// src/pages/NotFoundPage.tsx
import NotFound from '@/components/Lottie/404.tsx'
import { Button } from '@/components/ui/button.tsx'
import { useNavigate } from 'react-router-dom'
const NotFoundPage = () => {
const navigate = useNavigate()
return (
<div className="flex min-h-screen w-full flex-col items-center justify-center text-gray-500">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold"></h1>
<p className="mb-4 text-lg"></p>
<Button onClick={() => navigate('/')} className="hover:underline">
</Button>
</div>
<div>
<NotFound />
</div>
</div>
)
}
export default NotFoundPage

View File

@@ -0,0 +1,49 @@
import { BotMessageSquare, Captions, HardDriveDownload, Wrench } from 'lucide-react'
import MenuBar, { IMenuProps } from '@/pages/SettingPage/components/menuBar.tsx'
const Menu = () => {
const menuList: IMenuProps[] = [
{
id: 'model',
name: 'AI 模型设置',
icon: <BotMessageSquare />,
path: '/settings/model',
},
// TODO :下一版本升级优化
// {
// id: ' transcriber',
// name: '音频转译配置',
// icon: <Captions />,
// path: '/settings/transcriber',
// },
// //下载配置
// {
// id: 'download',
// name: '下载配置',
// icon: <HardDriveDownload />,
// path: '/settings/download',
// },
// //其他配置
// {
// id: 'other',
// name: '其他配置',
// icon: <Wrench />,
// path: '/settings/other',
// },
]
return (
<div className="flex h-full flex-col">
<div className={'flex w-full flex-col gap-2'}>
<div className="text-2xl font-medium"></div>
<div className="text-sm font-light text-gray-800"></div>
</div>
<div className="mt-6 flex-1">
{menuList &&
menuList.map(item => {
return <MenuBar key={item.id} menuItem={item} />
})}
</div>
</div>
)
}
export default Menu

View File

@@ -0,0 +1,16 @@
import Provider from '@/components/Form/modelForm/Provider.tsx'
import { Outlet } from 'react-router-dom'
const Model = () => {
return (
<div className={'flex h-full bg-white'}>
<div className={'flex-1/5 border-r border-neutral-200 p-2'}>
<Provider></Provider>
</div>
<div className={'flex-4/5'}>
<Outlet />
</div>
</div>
)
}
export default Model

View File

@@ -0,0 +1,7 @@
.menuBar {
cursor: pointer;
transition: all 0.2s ease-in-out;
}
.menuBar:hover {
background-color: #f7f7f7;
}

View File

@@ -0,0 +1,37 @@
import styles from './index.module.css'
import { FC, JSX } from 'react'
import { Link, useLocation } from 'react-router-dom'
export interface IMenuProps {
id: string
name: string
icon: JSX.Element
path: string
}
interface IMenuItem {
menuItem: IMenuProps
}
const MenuBar: FC<IMenuItem> = ({ menuItem }) => {
const location = useLocation()
const isActive = location.pathname.startsWith(menuItem.path + '/')
|| location.pathname === menuItem.path
return (
<Link to={menuItem.path} className="w-full">
<div
className={
styles.menuBar +
' flex h-12 w-full items-center gap-1 rounded px-2' +
(isActive ? ' bg-[#F0F0F0] font-semibold text-blue-600' : '')
}
>
<div className="h-6 w-6">{menuItem.icon}</div>
<div className="text-[16px]">{menuItem.name}</div>
</div>
</Link>
)
}
export default MenuBar

View File

@@ -0,0 +1,17 @@
import SettingLayout from '@/layouts/SettingLayout.tsx'
import Menu from '@/pages/SettingPage/Menu'
import { useProviderStore } from '@/store/providerStore'
import { useEffect } from 'react'
const SettingPage = () => {
const fetchProviderList = useProviderStore(state => state.fetchProviderList)
useEffect(() => {
fetchProviderList()
}, [])
return (
<div className="h-full w-full">
<SettingLayout Menu={<Menu />} />
</div>
)
}
export default SettingPage

View File

@@ -0,0 +1,8 @@
const Transcriber = () => {
return (
<div className="flex h-screen w-full flex-col items-center justify-center">
<h1 className="text-center text-4xl font-bold">Transcriber is under development</h1>
</div>
)
}
export default Transcriber

View File

@@ -1,168 +0,0 @@
import { useState } from "react"
import ReactMarkdown from "react-markdown"
import { Button } from "@/components/ui/button"
import { Copy, Download, FileText,ArrowRight } from "lucide-react"
import { toast } from "sonner" // 你可以换成自己的通知组件
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { solarizedlight as codeStyle } from 'react-syntax-highlighter/dist/cjs/styles/prism'
import 'github-markdown-css/github-markdown-light.css'
import {FC} from 'react'
import Loading from "@/components/Lottie/Loading.tsx";
import Idle from "@/components/Lottie/Idle.tsx";
import {useTaskStore} from "@/store/taskStore";
interface MarkdownViewerProps {
content: string
status: 'idle' | 'loading' | 'success'
}
const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
const [copied, setCopied] = useState(false)
const getCurrentTask =useTaskStore.getState().getCurrentTask
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content)
setCopied(true)
toast.success("已复制到剪贴板")
setTimeout(() => setCopied(false), 2000)
} catch (e) {
toast.error(`复制失败${e}`)
toast.error("复制失败",e)
}
}
const handleDownload = () => {
const currentTask=getCurrentTask()
const currentTaskName=currentTask?.audioMeta.title
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" })
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = `${currentTaskName}.md`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
if (status === 'loading') {
return (
<div className="w-full h-screen flex flex-col justify-center items-center text-neutral-500 space-y-4">
<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>
)
}
else if (status === 'idle'){
return (
<div className="w-full h-screen flex flex-col justify-center items-center text-neutral-500 space-y-3">
<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>
)
}
return (
<div className="w-full h-full flex flex-col">
{/* 顶部操作栏 */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900 flex items-center gap-2">
<FileText className="w-5 h-5 text-primary" />
</h2>
<div className="flex items-center gap-2">
<Button onClick={handleCopy} variant="outline" size="sm">
<Copy className="w-4 h-4 mr-1" />
{copied ? "已复制" : "复制"}
</Button>
<Button onClick={handleDownload} variant="outline" size="sm">
<Download className="w-4 h-4 mr-1" />
Markdown
</Button>
</div>
</div>
{/* 滚动容器 */}
<div className='overflow-y-auto'>
{
content && content!='loading' || content!='empty'?(
<div className="markdown-body flex-1 bg-white"> <ReactMarkdown
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
const codeContent = String(children).replace(/\n$/, '')
if (!inline && match) {
return (
<div className="relative group">
<SyntaxHighlighter
style={codeStyle}
language={match[1]}
PreTag="div"
{...props}
>
{codeContent}
</SyntaxHighlighter>
<button
onClick={() => {
navigator.clipboard.writeText(codeContent)
toast.success("代码已复制")
}}
className="absolute top-2 right-2 hidden group-hover:flex items-center gap-1 text-xs px-2 py-1 bg-white/70 border border-gray-300 rounded hover:bg-white shadow-sm transition"
>
<Copy className="w-3 h-3" />
</button>
</div>
)
}
return (
<code className="bg-gray-100 px-1 py-0.5 rounded text-sm" {...props}>
{children}
</code>
)
}
}}
>
{content}
</ReactMarkdown></div>
):(
<div className='w-full h-screen flex justify-center items-center'>
<div className='w-[300px] flex-col justify-items-center '>
<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>
)
}
</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>
)
}
export default MarkdownViewer

View File

@@ -1,287 +0,0 @@
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { Info,Clock } from "lucide-react"
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip.tsx";
import {generateNote} from "@/services/note.ts";
import {useTaskStore} from "@/store/taskStore";
import { useState } from "react"
import NoteHistory from "@/pages/components/NoteHistory.tsx";
// ✅ 定义表单 schema
const formSchema = z.object({
video_url: z.string().url("请输入正确的视频链接"),
platform: z.string().nonempty("请选择平台"),
quality: z.enum(["fast", "medium", "slow"], {
required_error: "请选择音频质量",
}),
screenshot: z.boolean().optional(),
link:z.boolean().optional(),
})
type NoteFormValues = z.infer<typeof formSchema>
const NoteForm = () => {
const [selectedTaskId] = useState<string | null>(null)
const tasks = useTaskStore((state) => state.tasks)
const setCurrentTask=useTaskStore((state)=>state.setCurrentTask)
const currentTaskId=useTaskStore(state=>state.currentTaskId )
tasks.find((t) => t.id === selectedTaskId);
const form = useForm<NoteFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
video_url: "",
platform: "bilibili",
quality: "medium", // 默认中等质量
screenshot: false,
},
})
const isGenerating = false
const onSubmit = async (data: NoteFormValues) => {
console.log("🎯 提交内容:", data)
await generateNote({
video_url: data.video_url,
platform: data.platform,
quality: data.quality,
screenshot:data.screenshot,
link:data.link
});
}
return (
<div className="flex flex-col h-full">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-2">
<div className="flex items-center justify-between my-3">
<h2 className="block "></h2>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-neutral-400 hover:text-primary cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p className="text-xs ">YouTube等平台</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex gap-2">
{/* 平台选择 */}
<FormField
control={form.control}
name="platform"
render={({ field }) => (
<FormItem>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="w-32">
<SelectValue placeholder="选择平台" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bilibili"></SelectItem>
<SelectItem value="youtube">Youtube</SelectItem>
{/*<SelectItem value="local">本地视频</SelectItem>*/}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* 视频地址 */}
<FormField
control={form.control}
name="video_url"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
placeholder="视频链接"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/*<p className="text-xs text-neutral-500">*/}
{/* 支持哔哩哔哩视频链接,例如:*/}
{/* https://www.bilibili.com/video/BV1vc25YQE9X/*/}
{/*</p>*/}
<FormField
control={form.control}
name="quality"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between my-3">
<h2 className="block "></h2>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-neutral-400 hover:text-primary cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p className="text-xs max-w-[200px]"></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择质量" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fast"></SelectItem>
<SelectItem value="medium"></SelectItem>
<SelectItem value="slow"></SelectItem>
</SelectContent>
</Select>
{/*<FormDescription className="text-xs text-neutral-500">*/}
{/* 质量越高,下载体积越大,速度越慢*/}
{/*</FormDescription>*/}
<FormMessage />
</FormItem>
)}
/>
</div>
{/* 是否需要原片位置 */}
<FormField
control={form.control}
name="link"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
{/* Tooltip 部分 */}
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
id="link"
/>
</FormControl>
<FormLabel
htmlFor="link"
className="text-sm font-medium leading-none"
>
</FormLabel>
</FormItem>
)}
/>
{/* 是否需要下载 */}
<FormField
control={form.control}
name="screenshot"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
{/* Tooltip 部分 */}
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
id="screenshot"
/>
</FormControl>
<FormLabel
htmlFor="screenshot"
className="text-sm font-medium leading-none"
>
</FormLabel>
</FormItem>
)}
/>
{/* 提交按钮 */}
<Button
type="submit"
className="w-full bg-primary cursor-pointer"
>
{isGenerating ? "正在生成…" : "生成笔记"}
</Button>
</form>
</Form>
{/*生成历史 */}
<div className="flex items-center gap-2 my-4">
<Clock className="h-4 w-4 text-neutral-500" />
<h2 className="text-base font-medium text-neutral-900"></h2>
</div>
<div className="flex-1 min-h-0 overflow-auto">
<NoteHistory onSelect={setCurrentTask} selectedId={currentTaskId} />
</div>
{/* 添加一些额外的说明或功能介绍 */}
<div className="mt-6 p-4 bg-primary-light rounded-lg">
<h3 className="font-medium text-primary mb-2"></h3>
<ul className="text-sm space-y-2 text-neutral-600">
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span></span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span>YouTube等</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span>Markdown格式</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span></span>
</li>
</ul>
</div>
</div>
)
}
export default NoteForm

View File

@@ -1,15 +0,0 @@
import { useForm } from "react-hook-form"
import { Form } from "@/components/ui/form"
import NoteForm from "./NoteForm"
const NoteFormWrapper = () => {
const form = useForm()
return (
<Form {...form}>
<NoteForm />
</Form>
)
}
export default NoteFormWrapper

View File

@@ -1,100 +0,0 @@
import { useTaskStore } from "@/store/taskStore"
import { FC } from "react"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { Trash ,Clock} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
interface NoteHistoryProps {
onSelect: (taskId: string) => void
selectedId: string | null
}
const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
const tasks = useTaskStore((state) => state.tasks)
const removeTask = useTaskStore((state) => state.removeTask)
if (tasks.length === 0) {
return (
<div className="text-center py-6 bg-neutral-50 rounded-md border border-neutral-200">
<p className="text-sm text-neutral-500"></p>
</div>
)
}
return (
<ScrollArea className="h-auto max-h-[20vh] sm:max-h-[10vh]">
<div className="flex flex-col space-y-2">
{tasks.map((task) => (
<div
key={task.id}
className={cn(
"flex items-center gap-4 p-3 cursor-pointer transition hover:bg-neutral-50 rounded-md border",
selectedId === task.id && "border-primary bg-primary-light"
)}
onClick={() => onSelect(task.id)}
>
{/* 封面图 */}
<img
src={task.audioMeta.cover_url
? `/api/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
: "/placeholder.png"}
alt="封面"
className="w-16 h-10 object-cover rounded-md"
/>
{/* 标题 + 状态 */}
<div className="flex items-center justify-between gap-2 min-w-0 w-full">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="font-medium max-w-[120px] truncate flex-1">{task.audioMeta.title || "未命名笔记"}</div>
</TooltipTrigger>
<TooltipContent>
<p>{task.audioMeta.title || "未命名笔记"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="shrink-0">
{task.status === "SUCCESS" && <Badge variant="default"></Badge>}
{task.status === "PENDING" && <Badge variant="outline"></Badge>}
{task.status === "FAILED" && <Badge variant="destructive"></Badge>}
</div>
</div>
{/* 删除按钮 */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
removeTask(task.id)
}}
className="shrink-0"
>
<Trash className="w-4 h-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
))}
</div>
</ScrollArea>
)
}
export default NoteHistory

View File

@@ -0,0 +1,31 @@
import request from '@/utils/request.ts'
export const getProviderList = async () => {
return await request.get('/get_all_providers')
}
export const getProviderById = async (id: string) => {
return await request.get(`/get_provider_by_id/${id}`)
}
export const updateProviderById = async (data: any) => {
return await request.post('/update_provider', data)
}
export const addProvider = async (data: any) => {
return await request.post('/add_provider', data)
}
export const testConnection = async (data: any) => {
return await request.post('/connect_test', data)
}
export const fetchModels = async (providerId: any) => {
return await request.get('/model_list/' + providerId)
}
export async function addModel(data: { provider_id: string; model_name: string }) {
return request.post('/models', data)
}
export const fetchEnableModels = async () => {
return await request.get('/model_list')
}

View File

@@ -1,97 +1,82 @@
import request from "@/utils/request"
import request from '@/utils/request'
import toast from 'react-hot-toast'
import {useTaskStore} from "@/store/taskStore";
import request from "@/utils/request"
interface GenerateNotePayload {
video_url: string
platform: "bilibili" | "youtube"
quality: "fast" | "medium" | "slow"
}
import { useTaskStore } from '@/store/taskStore'
import request from '@/utils/request'
export const generateNote = async (data: {
video_url: string;
link: undefined | boolean;
screenshot: undefined | boolean;
platform: string;
quality: string
video_url: string
platform: string
quality: string
model_name: string
provider_id: string
task_id?: string
format: Array<string>
style: string
extras?: string
}) => {
try {
const response = await request.post("/generate_note", data)
try {
const response = await request.post('/generate_note', data)
if (response.data.code!=0){
if (response.data.msg){
toast.error(response.data.msg)
}
return null
}
toast.success("笔记生成任务已提交!")
const taskId = response.data.data.task_id
console.log('res',response)
// 成功提示
useTaskStore.getState().addPendingTask(taskId, data.platform)
return response.data
} catch (e: any) {
console.error("❌ 请求出错", e)
// 错误提示
toast.error(
"笔记生成失败,请稍后重试"
)
throw e // 抛出错误以便调用方处理
if (response.data.code != 0) {
if (response.data.msg) {
toast.error(response.data.msg)
}
return null
}
toast.success('笔记生成任务已提交!')
console.log('res', response)
// 成功提示
return response.data
} catch (e: any) {
console.error('❌ 请求出错', e)
// 错误提示
toast.error('笔记生成失败,请稍后重试')
throw e // 抛出错误以便调用方处理
}
}
export const delete_task = async ({video_id, platform}) => {
try {
const data={
video_id,platform
}
const res = await request.post("/delete_task",
data
)
if (res.data.code === 0) {
toast.success("任务已成功删除")
return res.data
} else {
toast.error(res.data.message || "删除失败")
throw new Error(res.data.message || "删除失败")
}
} catch (e) {
toast.error("请求异常,删除任务失败")
console.error("❌ 删除任务失败:", e)
throw e
export const delete_task = async ({ video_id, platform }) => {
try {
const data = {
video_id,
platform,
}
const res = await request.post('/delete_task', data)
if (res.data.code === 0) {
toast.success('任务已成功删除')
return res.data
} else {
toast.error(res.data.message || '删除失败')
throw new Error(res.data.message || '删除失败')
}
} catch (e) {
toast.error('请求异常,删除任务失败')
console.error('❌ 删除任务失败:', e)
throw e
}
}
export const get_task_status = async (task_id: string) => {
try {
const response = await request.get('/task_status/' + task_id)
export const get_task_status=async (task_id:string)=>{
try {
const response = await request.get("/task_status/"+task_id)
if (response.data.code==0 && response.data.status=='SUCCESS') {
// toast.success("笔记生成成功")
}
console.log('res',response)
// 成功提示
return response.data
if (response.data.code == 0 && response.data.status == 'SUCCESS') {
// toast.success("笔记生成成功")
}
catch (e){
console.error("❌ 请求出错", e)
console.log('res', response)
// 成功提示
// 错误提示
toast.error(
"笔记生成失败,请稍后重试"
)
return response.data
} catch (e) {
console.error('❌ 请求出错', e)
throw e // 抛出错误以便调用方处理
}
}
// 错误提示
toast.error('笔记生成失败,请稍后重试')
throw e // 抛出错误以便调用方处理
}
}

View File

@@ -0,0 +1,25 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface SystemState {
showFeatureHint: boolean // ✅ 是否显示功能提示
setShowFeatureHint: (value: boolean) => void
// 后续如果有其他全局状态,可以继续加
sidebarCollapsed: boolean // ✅ 侧边栏是否收起
setSidebarCollapsed: (value: boolean) => void
}
// 暂不启用
export const useSystemStore = create<SystemState>()(
persist(
set => ({
showFeatureHint: true,
setShowFeatureHint: value => set({ showFeatureHint: value }),
sidebarCollapsed: false,
setSidebarCollapsed: value => set({ sidebarCollapsed: value }),
}),
{
name: 'system-store', // 本地存储的 key
}
)
)

View File

@@ -0,0 +1,101 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { fetchModels, addModel, fetchEnableModels } from '@/services/model.ts'
interface IModel {
id: string
created: number
object: string
owned_by: string
permission: string
root: string
}
interface ModelStore {
models: IModel[]
modelList: []
loading: boolean
selectedModel: string
loadModels: (providerId: string) => Promise<void>
loadEnabledModels: () => Promise<void>
addNewModel: (providerId: string, modelId: string) => Promise<void>
setSelectedModel: (modelId: string) => void
clearModels: () => void
}
export const useModelStore = create<ModelStore>()(
devtools(set => ({
models: [],
loading: false,
selectedModel: '',
modelList: [],
loadEnabledModels: async () => {
try {
set({ loading: true })
const res = await fetchEnableModels()
if (res.data.code === 0 && res.data.data.length > 0) {
set({ modelList: res.data.data })
} else {
set({ modelList: [] })
console.error('模型列表加载失败')
}
} catch (error) {
set({ modelList: [] })
console.error('加载模型出错', error)
}
},
// 加载模型列表
loadModels: async (providerId: string) => {
try {
set({ loading: true })
const res = await fetchModels(providerId)
if (res.data.code === 0 && res.data.data.models.data.length > 0) {
set({ models: res.data.data.models.data })
} else {
set({ models: [] })
console.error('模型列表加载失败')
}
} catch (error) {
set({ models: [] })
console.error('加载模型出错', error)
} finally {
set({ loading: false })
}
},
// 新增模型
addNewModel: async (providerId: string, modelId: string) => {
try {
const res = await addModel({ provider_id: providerId, model_name: modelId })
if (res.code === 0) {
console.log('新增模型成功:', modelId)
// ✅ 新增成功以后,前端直接追加一条到 models 列表
set(state => ({
models: [
...state.models,
{
id: modelId,
created: Date.now(),
object: 'model',
owned_by: '',
permission: '',
root: '',
},
],
}))
} else {
console.error('新增模型失败')
}
} catch (error) {
console.error('添加模型出错', error)
}
},
// 设置选中的模型
setSelectedModel: modelId => set({ selectedModel: modelId }),
// 清空
clearModels: () => set({ models: [], selectedModel: '' }),
}))
)

View File

@@ -0,0 +1,125 @@
import { create } from 'zustand'
import { IProvider } from '@/types'
import {
addProvider,
getProviderById,
getProviderList,
updateProviderById,
} from '@/services/model.ts'
interface ProviderStore {
provider: IProvider[]
setProvider: (provider: IProvider) => void
setAllProviders: (providers: IProvider[]) => void
getProviderById: (id: number) => IProvider | undefined
getProviderList: () => IProvider[]
fetchProviderList: () => Promise<void>
loadProviderById: (id: string) => Promise<void>
addNewProvider: (provider: IProvider) => Promise<void>
updateProvider: (provider: IProvider) => Promise<void>
}
export const useProviderStore = create<ProviderStore>((set, get) => ({
provider: [],
// 添加或更新一个 provider
setProvider: newProvider =>
set(state => {
const exists = state.provider.find(p => p.id === newProvider.id)
if (exists) {
return {
provider: state.provider.map(p => (p.id === newProvider.id ? newProvider : p)),
}
} else {
return { provider: [...state.provider, newProvider] }
}
}),
// 设置整个 provider 列表
setAllProviders: providers => set({ provider: providers }),
loadProviderById: async (id: string) => {
const res = await getProviderById(id)
if (res.data.code === 0) {
const item = res.data.data
console.log('Provider ', item)
return {
id: item.id,
name: item.name,
logo: item.logo,
apiKey: item.api_key,
baseUrl: item.base_url,
type: item.type,
enabled: item.enabled,
}
} else {
console.log('Provider not found')
}
},
addNewProvider: async (provider: IProvider) => {
const payload = {
...provider,
api_key: provider.apiKey,
base_url: provider.baseUrl,
}
try {
const res = await addProvider(payload)
if (res.data.code === 0) {
const item = res.data.data
console.log('Provider ', item)
await get().fetchProviderList()
}
} catch (error) {
console.error('Error fetching provider:', error)
}
},
// 按 id 获取单个 provider
getProviderById: id => get().provider.find(p => p.id === id),
updateProvider: async (provider: IProvider) => {
try {
const data = {
...provider,
api_key: provider.apiKey,
base_url: provider.baseUrl,
}
const res = await updateProviderById(data)
if (res.data.code === 0) {
const item = res.data.data
console.log('Provider ', item)
await get().fetchProviderList()
}
} catch (error) {
console.error('Error fetching provider:', error)
}
},
getProviderList: () => get().provider,
fetchProviderList: async () => {
try {
const res = await getProviderList()
if (res.data.code === 0) {
set({
provider: res.data.data.map(
(item: {
id: string
name: string
logo: string
api_key: string
base_url: string
}) => {
return {
id: item.id,
name: item.name,
logo: item.logo,
apiKey: item.api_key,
baseUrl: item.base_url,
type: item.type,
enabled: item.enabled,
}
}
),
})
}
} catch (error) {
console.error('Error fetching provider list:', error)
}
},
}))

View File

@@ -1,124 +1,142 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import {delete_task} from "@/services/note.ts";
import { delete_task, generateNote } from '@/services/note.ts'
export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILD'
export interface AudioMeta {
cover_url: string
duration: number
file_path: string
platform: string
raw_info: any
title: string
video_id: string
cover_url: string
duration: number
file_path: string
platform: string
raw_info: any
title: string
video_id: string
}
export interface Segment {
start: number
end: number
text: string
start: number
end: number
text: string
}
export interface Transcript {
full_text: string
language: string
raw: any
segments: Segment[]
full_text: string
language: string
raw: any
segments: Segment[]
}
export interface Task {
id: string
markdown: string
transcript: Transcript
status: TaskStatus
audioMeta: AudioMeta
createdAt: string
id: string
markdown: string
transcript: Transcript
status: TaskStatus
audioMeta: AudioMeta
createdAt: string
formData: {
video_url: string
link: undefined | boolean
screenshot: undefined | boolean
platform: string
quality: string
model_name: string
provider_id: string
}
}
interface TaskStore {
tasks: Task[]
currentTaskId: string | null
platform:string|null
addPendingTask: (taskId: string, platform: string) => void
updateTaskContent: (id: string, data: Partial<Omit<Task, "id" | "createdAt">>) => void
removeTask: (id: string) => void
clearTasks: () => void
setCurrentTask: (taskId: string | null) => void
getCurrentTask: () => Task | null
tasks: Task[]
currentTaskId: string | null
addPendingTask: (taskId: string, platform: string) => void
updateTaskContent: (id: string, data: Partial<Omit<Task, 'id' | 'createdAt'>>) => void
removeTask: (id: string) => void
clearTasks: () => void
setCurrentTask: (taskId: string | null) => void
getCurrentTask: () => Task | null
retryTask: (id: string) => void
}
export const useTaskStore = create<TaskStore>()(
persist(
(set,get) => ({
tasks: [],
currentTaskId: null,
persist(
(set, get) => ({
tasks: [],
currentTaskId: null,
addPendingTask: (taskId: string,platform: string) =>
set((state) => ({
tasks: [
{
id: taskId,
status: "PENDING",
markdown: "",
platform:platform,
transcript: {
full_text: "",
language: "",
raw: null,
segments: [],
},
createdAt: new Date().toISOString(),
audioMeta: {
cover_url: "",
duration: 0,
file_path: "",
platform: '',
raw_info: null,
title: "",
video_id: "",
},
},
...state.tasks,
],
currentTaskId: taskId, // 默认设置为当前任务
})),
updateTaskContent: (id, data) =>
set((state) => ({
tasks: state.tasks.map((task) =>
task.id === id ? { ...task, ...data } : task
),
})),
getCurrentTask: () => {
const currentTaskId = get().currentTaskId
return get().tasks.find((task) => task.id === currentTaskId) || null
addPendingTask: (taskId: string, platform: string, formData: any) =>
set(state => ({
tasks: [
{
formData: formData,
id: taskId,
status: 'PENDING',
markdown: '',
platform: platform,
transcript: {
full_text: '',
language: '',
raw: null,
segments: [],
},
createdAt: new Date().toISOString(),
audioMeta: {
cover_url: '',
duration: 0,
file_path: '',
platform: '',
raw_info: null,
title: '',
video_id: '',
},
},
removeTask: async (id) => {
const task = get().tasks.find((t) => t.id === id)
...state.tasks,
],
currentTaskId: taskId, // 默认设置为当前任务
})),
// 更新 Zustand 状态
set((state) => ({
tasks: state.tasks.filter((task) => task.id !== id),
currentTaskId: state.currentTaskId === id ? null : state.currentTaskId,
}))
updateTaskContent: (id, data) =>
set(state => ({
tasks: state.tasks.map(task => (task.id === id ? { ...task, ...data } : task)),
})),
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
await generateNote({
task_id: id,
...task,
})
set(state => ({
tasks: state.tasks.map(task => (task.id === id ? { ...task, status: 'PENDING' } : task)),
}))
},
// 调用后端删除接口(如果找到了任务)
if (task) {
await delete_task({
video_id: task.audioMeta.video_id,
platform: task.platform,
})
}
},
removeTask: async id => {
const task = get().tasks.find(t => t.id === id)
// 更新 Zustand 状态
set(state => ({
tasks: state.tasks.filter(task => task.id !== id),
currentTaskId: state.currentTaskId === id ? null : state.currentTaskId,
}))
clearTasks: () => set({ tasks: [], currentTaskId: null }),
setCurrentTask: (taskId) => set({ currentTaskId: taskId }),
}),
{
name: 'task-storage',
// 调用后端删除接口(如果找到了任务)
if (task) {
await delete_task({
video_id: task.audioMeta.video_id,
platform: task.platform,
})
}
)
},
clearTasks: () => set({ tasks: [], currentTaskId: null }),
setCurrentTask: taskId => set({ currentTaskId: taskId }),
}),
{
name: 'task-storage',
}
)
)

View File

@@ -0,0 +1,9 @@
export interface IProvider {
id: string
name: string
logo: string
type: string
apiKey: string
baseUrl: string
enabled: number
}

View File

@@ -0,0 +1,9 @@
// 解析URL
export function parseUrl(url: string): { protocol: string, host: string, path: string } {
const urlObj = new URL(url);
return {
protocol: urlObj.protocol,
host: urlObj.host,
path: urlObj.pathname
};
}

View File

@@ -1,8 +1,8 @@
import axios from "axios"
import axios from 'axios'
const request = axios.create({
baseURL: "/api", // 默认请求路径前缀
timeout: 10000,
baseURL: '/api', // 默认请求路径前缀
timeout: 10000,
})
export default request
export default request

View File

@@ -1,11 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./index.html',
'./src/**/*.{vue,js,ts,js,jsx,tsx}'
],
theme: {
extend: {},
},
plugins: [],
};
content: ['./index.html', './src/**/*.{vue,js,ts,js,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -24,9 +24,7 @@
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
"@/*": ["./src/*"]
}
},
"include": ["src"]

View File

@@ -1,9 +1,6 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
"compilerOptions": {
"baseUrl": ".",
"paths": {

View File

@@ -1,7 +1,7 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import path from 'path'
import tailwindcss from '@tailwindcss/vite'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
@@ -13,7 +13,7 @@ export default defineConfig(({ mode }) => {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
'@': path.resolve(__dirname, './src'),
},
},
server: {
@@ -28,4 +28,4 @@ export default defineConfig(({ mode }) => {
},
},
}
})
})

View File

@@ -3,7 +3,7 @@
<p align="center">
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
</p>
<h1 align="center" > BiliNote v1.0.1</h1>
<h1 align="center" > BiliNote v1.1.0</h1>
</div>
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
@@ -119,6 +119,7 @@ docker compose up --build
## ⚙️ 环境变量配置
> ⚠️ v.1.1.0 以后无需通过环境变量配置 AI
后端 `.env` 示例:
@@ -131,15 +132,29 @@ OPENAI_API_KEY=sk-xxxxxx
DEEP_SEEK_API_KEY=xxx
QWEN_API_KEY=xxx
```
## Changelog
### v1.1.0
- #### Added
- 新增 AI 笔记风格选择
- 新增 AI 笔记返回格式选择
- 添加 AI 自定义笔记备注 Prompt
- 添加任务失败重试
- 添加全局设置页,可在设置页进行模型设置
- #### Optimize
- 优化前端样式,优化用户体验
- 增加生成中间产物,可用于失败后加快生成速度
- #### Fix
- 修复视频截图视频过早删除错误
## 🧠 TODO
- [ ] 支持抖音及快手等视频平台
- [ ] 支持前端设置切换 AI 模型切换、语音转文字模型
- [ ] AI 摘要风格自定义(学术风、口语风、重点提取等)
- [x] 支持前端设置切换 AI 模型切换、语音转文字模型
- [x] AI 摘要风格自定义(学术风、口语风、重点提取等)
- [ ] 笔记导出为 PDF / Word / Notion
- [ ] 加入更多模型支持
- [ ] 加入更多音频转文本模型支持
- [x] 加入更多模型支持
- [x] 加入更多音频转文本模型支持
### Contact and Join-联系和加入社区
- BiliNote 交流QQ群785367111

View File

@@ -1,8 +1,10 @@
from fastapi import FastAPI
from .routers import note
from .routers import note, provider,model
def create_app() -> FastAPI:
app = FastAPI(title="BiliNote")
app.include_router(note.router, prefix="/api")
app.include_router(provider.router, prefix="/api")
app.include_router(model.router,prefix="/api")
return app

View File

@@ -0,0 +1,42 @@
[
{
"id": "openai",
"name": "OpenAI",
"type": "built-in",
"logo": "OpenAI",
"api_key": "",
"base_url": "https://api.openai.com/v1"
},
{
"id": "deepseek",
"name": "DeepSeek",
"type": "built-in",
"logo": "DeepSeek",
"api_key": "",
"base_url": "https://api.deepseek.com"
},
{
"id": "qwen",
"name": "Qwen",
"type": "built-in",
"logo": "Qwen",
"api_key": "",
"base_url": "https://qwen.aliyun.com/api"
},
{
"id": "doubao",
"name": "豆包 (Doubao)",
"type": "built-in",
"logo": "Doubao",
"api_key": "",
"base_url": "https://open.doubao.com/api"
},
{
"id": "Claude",
"name": "Claude",
"type": "built-in",
"logo": "Claude",
"api_key": "",
"base_url": "https://"
}
]

View File

@@ -0,0 +1,58 @@
from app.db.sqlite_client import get_connection
def init_model_table():
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id INTEGER NOT NULL,
model_name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
conn.close()
# 插入模型
def insert_model(provider_id: int, model_name: str):
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO models (provider_id, model_name)
VALUES (?, ?)
""", (provider_id, model_name))
conn.commit()
conn.close()
# 根据provider查模型
def get_models_by_provider(provider_id: int):
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, model_name FROM models
WHERE provider_id = ?
""", (provider_id,))
rows = cursor.fetchall()
conn.close()
return [{"id": row[0], "model_name": row[1]} for row in rows]
# 删除某个模型
def delete_model(model_id: int):
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
DELETE FROM models WHERE id = ?
""", (model_id,))
conn.commit()
conn.close()
def get_all_models():
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, provider_id, model_name FROM models
""")
rows = cursor.fetchall()
conn.close()
return [{"id": row[0], "provider_id": row[1], "model_name": row[2]} for row in rows]

View File

@@ -0,0 +1,220 @@
import json
import os
from app.db.sqlite_client import get_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
def seed_default_providers():
conn = get_connection()
if conn is None:
logger.error("Failed to connect to database.")
return
cursor = conn.cursor()
# 检查已有数据
cursor.execute("SELECT COUNT(*) FROM providers")
count = cursor.fetchone()[0]
if count > 0:
logger.info("Providers already exist, skipping seed.")
conn.close()
return
json_path = os.path.join(os.path.dirname(__file__), 'builtin_providers.json')
try:
with open(json_path, 'r', encoding='utf-8') as f:
providers = json.load(f)
except Exception as e:
logger.error(f"Failed to read builtin_providers.json: {e}")
conn.close()
return
try:
for p in providers:
cursor.execute("""
INSERT INTO providers (id, name, api_key, base_url, logo, type, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
p['id'],
p['name'],
p['api_key'],
p['base_url'],
p['logo'],
p['type'],
p.get('enabled', 1)
))
conn.commit()
logger.info("Default providers seeded successfully.")
except Exception as e:
logger.error(f"Failed to seed default providers: {e}")
finally:
conn.close()
def init_provider_table():
conn = get_connection()
if conn is None:
logger.error("Failed to connect to the database.")
return
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
logo TEXT NOT NULL,
type TEXT NOT NULL,
api_key TEXT NOT NULL,
base_url TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
try:
conn.commit()
conn.close()
logger.info("provider table created successfully.")
seed_default_providers()
except Exception as e:
logger.error(f"Failed to create provider table: {e}")
def insert_provider(id: str, name: str, api_key: str, base_url: str, logo: str, type_: str,enabled:int=1):
conn = get_connection()
if conn is None:
logger.error("Failed to connect to the database.")
return
cursor = conn.cursor()
cursor.execute("""
INSERT INTO providers (id, name, api_key, base_url, logo, type, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (id, name, api_key, base_url, logo, type_, enabled))
try:
conn.commit()
conn.close()
logger.info(f"Provider inserted successfully. id: {id}, name: {name}, type: {type_}")
return id
except Exception as e:
logger.error(f"Failed to insert provider: {e}")
return None
def get_enabled_providers():
conn = get_connection()
if conn is None:
logger.error("Failed to connect to the database.")
return
cursor = conn.cursor()
cursor.execute("SELECT * FROM providers WHERE enabled = 1")
try:
rows = cursor.fetchall()
conn.close()
if rows is None:
logger.info("No providers found")
return None
logger.info(f"Providers found: {rows}")
return rows
except Exception as e:
logger.error(f"Failed to get enabled providers: {e}")
def get_provider_by_name(name: str):
conn = get_connection()
if conn is None:
logger.error("Failed to connect to the database.")
return
cursor = conn.cursor()
cursor.execute("SELECT * FROM providers WHERE name = ?", (name,))
try:
row = cursor.fetchone()
conn.close()
if row is None:
logger.info(f"Provider not found: {name}")
return None
logger.info(f"Provider found: {row}")
return row
except Exception as e:
logger.error(f"Failed to get provider by name: {e}")
def get_provider_by_id(id: int):
conn = get_connection()
if conn is None:
logger.error("Failed to connect to the database.")
return
cursor = conn.cursor()
cursor.execute("SELECT * FROM providers WHERE id = ?", (id,))
try:
row = cursor.fetchone()
conn.close()
if row is None:
logger.info(f"Provider not found: {id}")
return None
logger.info(f"Provider found: {row}")
return row
except Exception as e:
logger.error(f"Failed to get provider by id: {e}")
def get_all_providers():
conn = get_connection()
if conn is None:
logger.error("Failed to connect to the database.")
return
cursor = conn.cursor()
cursor.execute("SELECT * FROM providers")
try:
rows = cursor.fetchall()
conn.close()
if rows is None:
logger.info("No providers found")
return None
logger.info(f"Providers found: {rows}")
return rows
except Exception as e:
logger.error(f"Failed to get all providers: {e}")
def update_provider(id: str, **kwargs):
conn = get_connection()
if conn is None:
logger.error("Failed to connect to the database.")
return
fields = []
values = []
for key, value in kwargs.items():
fields.append(f"{key} = ?")
values.append(value)
if not fields:
logger.warning("No fields provided for update.")
return
sql = f"""
UPDATE providers
SET {', '.join(fields)}
WHERE id = ?
"""
values.append(id) # id 最后加
cursor = conn.cursor()
try:
cursor.execute(sql, values)
conn.commit()
conn.close()
logger.info(f"Provider updated successfully. id: {id}, updated_fields: {fields}")
except Exception as e:
logger.error(f"Failed to update provider: {e}")
def delete_provider(id: int):
conn = get_connection()
if conn is None:
logger.error("Failed to connect to the database.")
return
cursor = conn.cursor()
cursor.execute("DELETE FROM providers WHERE id = ?", (id,))
try:
conn.commit()
conn.close()
logger.info(f"Provider deleted successfully. id: {id}")
except Exception as e:
logger.error(f"Failed to delete provider: {e}")

View File

@@ -1,4 +1,4 @@
import sqlite3
def get_connection():
return sqlite3.connect("note_tasks.db")
return sqlite3.connect("bili_note.db")

View File

@@ -31,6 +31,13 @@ class BilibiliDownloader(Downloader, ABC):
ydl_opts = {
'format': 'bestaudio[ext=m4a]/bestaudio/best',
'outtmpl': output_path,
'postprocessors': [
{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '64',
}
],
'noplaylist': True,
'quiet': False,
}
@@ -41,7 +48,7 @@ class BilibiliDownloader(Downloader, ABC):
title = info.get("title")
duration = info.get("duration", 0)
cover_url = info.get("thumbnail")
audio_path = os.path.join(output_dir, f"{video_id}.m4a")
audio_path = os.path.join(output_dir, f"{video_id}.mp3")
return AudioDownloadResult(
file_path=audio_path,
@@ -69,7 +76,7 @@ class BilibiliDownloader(Downloader, ABC):
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
ydl_opts = {
'format': 'bv*+ba/bestvideo+bestaudio/best',
'format': 'bv*[ext=mp4]/bestvideo+bestaudio/best',
'outtmpl': output_path,
'noplaylist': True,
'quiet': False,

View File

@@ -27,6 +27,13 @@ class DouyinDownloader(Downloader, ABC):
ydl_opts = {
'format': 'bestaudio[ext=m4a]/bestaudio/best',
'outtmpl': output_path,
'postprocessors': [
{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '64',
}
],
'noplaylist': True,
'quiet': False,
}
@@ -37,7 +44,7 @@ class DouyinDownloader(Downloader, ABC):
title = info.get("title")
duration = info.get("duration", 0)
cover_url = info.get("thumbnail")
audio_path = os.path.join(output_dir, f"{video_id}.m4a")
audio_path = os.path.join(output_dir, f"{video_id}.mp3")
return AudioDownloadResult(
file_path=audio_path,

View File

@@ -42,7 +42,7 @@ class YoutubeDownloader(Downloader, ABC):
title = info.get("title")
duration = info.get("duration", 0)
cover_url = info.get("thumbnail")
audio_path = os.path.join(output_dir, f"{video_id}.m4a")
audio_path = os.path.join(output_dir, f"{video_id}.mp3")
return AudioDownloadResult(
file_path=audio_path,

View File

@@ -0,0 +1,28 @@
import enum
class TaskStatus(str, enum.Enum):
PENDING = "PENDING"
PARSING = "PARSING"
DOWNLOADING = "DOWNLOADING"
TRANSCRIBING = "TRANSCRIBING"
SUMMARIZING = "SUMMARIZING"
FORMATTING = "FORMATTING"
SAVING = "SAVING"
SUCCESS = "SUCCESS"
FAILED = "FAILED"
@classmethod
def description(cls, status):
desc_map = {
cls.PENDING: "排队中",
cls.PARSING: "解析链接",
cls.DOWNLOADING: "下载中",
cls.TRANSCRIBING: "转录中",
cls.SUMMARIZING: "总结中",
cls.FORMATTING: "格式化中",
cls.SAVING: "保存中",
cls.SUCCESS: "完成",
cls.FAILED: "失败",
}
return desc_map.get(status, "未知状态")

View File

@@ -10,4 +10,8 @@ class GPT(ABC):
:param source:
:return:
'''
pass
def create_messages(self, segments:list,**kwargs)->list:
pass
def list_models(self):
pass

View File

@@ -0,0 +1,13 @@
from openai import OpenAI
from app.gpt.base import GPT
from app.gpt.provider.OpenAI_compatible_provider import OpenAICompatibleProvider
from app.gpt.universal_gpt import UniversalGPT
from app.models.model_config import ModelConfig
class GPTFactory:
@staticmethod
def from_config(config: ModelConfig) -> GPT:
client = OpenAICompatibleProvider(api_key=config.api_key, base_url=config.base_url).get_client
return UniversalGPT(client=client, model=config.model_name)

View File

@@ -2,6 +2,7 @@ from typing import List
from app.gpt.base import GPT
from openai import OpenAI
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT, LINK
from app.gpt.provider.OpenAI_compatible_provider import OpenAICompatibleProvider
from app.gpt.utils import fix_markdown
from app.models.gpt_model import GPTSource
from app.models.transcriber_model import TranscriptSegment
@@ -15,7 +16,7 @@ class OpenaiGPT(GPT):
self.base_url = getenv("OPENAI_API_BASE_URL")
self.model=getenv('OPENAI_MODEL')
print(self.model)
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
self.client = OpenAICompatibleProvider(api_key=self.api_key, base_url=self.base_url)
self.screenshot = False
self.link=False
@@ -49,17 +50,20 @@ class OpenaiGPT(GPT):
print(content)
return [{"role": "user", "content": content + AI_SUM}]
def list_models(self):
return self.client.list_models()
def summarize(self, source: GPTSource) -> str:
self.screenshot = source.screenshot
self.link = source.link
source.segment = self.ensure_segments_type(source.segment)
messages = self.create_messages(source.segment, source.title,source.tags)
response = self.client.chat.completions.create(
response = self.client.chat(
model=self.model,
messages=messages,
temperature=0.7
)
return response.choices[0].message.content.strip()
if __name__ == '__main__':
gpt = OpenaiGPT()
print(gpt.list_models())

View File

@@ -0,0 +1,100 @@
from app.gpt.prompt import BASE_PROMPT
note_formats = [
{'label': '目录', 'value': 'toc'},
{'label': '原片跳转', 'value': 'link'},
{'label': '原片截图', 'value': 'screenshot'},
{'label': 'AI总结', 'value': 'summary'}
]
note_styles = [
{'label': '精简', 'value': 'minimal'},
{'label': '详细', 'value': 'detailed'},
{'label': '学术', 'value': 'academic'},
{"label": '教程',"value": 'tutorial', },
{'label': '小红书', 'value': 'xiaohongshu'},
{'label': '生活向', 'value': 'life_journal'},
{'label': '任务导向', 'value': 'task_oriented'},
{'label': '商业风格', 'value': 'business'},
{'label': '会议纪要', 'value': 'meeting_minutes'}
]
# 生成 BASE_PROMPT 函数
def generate_base_prompt(title, segment_text, tags, _format=None, style=None, extras=None):
# 生成 Base Prompt 开头部分
prompt = BASE_PROMPT.format(
video_title=title,
segment_text=segment_text,
tags=tags
)
# 添加用户选择的格式
if _format:
prompt += "\n" + "\n".join([get_format_function(f) for f in _format])
# 根据用户选择的笔记风格添加描述
if style:
prompt += "\n" + get_style_format(style)
# 添加额外内容
if extras:
prompt += f"\n{extras}"
return prompt
# 获取格式函数
def get_format_function(format_type):
format_map = {
'toc': get_toc_format,
'link': get_link_format,
'screenshot': get_screenshot_format,
'summary': get_summary_format
}
return format_map.get(format_type, lambda: '')()
# 风格描述的处理
def get_style_format(style):
style_map = {
'minimal': '1. **精简信息**: 仅记录最重要的内容,简洁明了。',
'detailed': '2. **详细记录**: 包含完整的时间戳和每个部分的详细讨论。',
'academic': '3. **学术风格**: 适合学术报告,正式且结构化。',
'xiaohongshu': '4. **小红书风格**: 适合社交平台分享,亲切、口语化。',
'life_journal': '5. **生活向**: 记录个人生活感悟,情感化表达。',
'task_oriented': '6. **任务导向**: 强调任务、目标,适合工作和待办事项。',
'business': '7. **商业风格**: 适合商业报告、会议纪要,正式且精准。',
'meeting_minutes': '8. **会议纪要**: 适合商业报告、会议纪要,正式且精准。',
"tutorial":"9.**教程笔记**:尽可能详细的记录教程,特别是关键点和一些重要的结论步骤"
}
return style_map.get(style, '')
# 格式化输出内容
def get_toc_format():
return '''
9. **目录**: 自动生成一个基于 `##` 级标题的目录。不需要插入原片跳转
'''
def get_link_format():
return '''
10. **原片跳转**: 为每个主要章节添加时间戳,使用格式 `*Content-[mm:ss]`。
重要:**始终**在章节标题前加上 `*Content` 前缀,例如:`AI 的发展史 *Content-[01:23]`。一定是标题在前 插入标记在后
'''
def get_screenshot_format():
return '''
11. **原片截图**: 如果某个部分涉及**视觉演示**或任何能帮助理解的内容,插入截图提示:
- 格式:`*Screenshot-[mm:ss]`
至少插入 1-3张截图
'''
def get_summary_format():
return '''
12. **AI总结**: 在笔记末尾加入简短的AI生成总结,并且二级标题 就是 AI 总结 例如 ## AI 总结。
'''

View File

@@ -0,0 +1,22 @@
from typing import Optional, Union
from openai import OpenAI
class OpenAICompatibleProvider:
def __init__(self, api_key: str, base_url: str, model: Union[str, None]=None):
self.client = OpenAI(api_key=api_key, base_url=base_url)
self.model = model
@property
def get_client(self):
return self.client
@staticmethod
def test_connection(api_key: str, base_url: str) -> bool:
try:
client = OpenAI(api_key=api_key, base_url=base_url)
client.models.list()
return True
except Exception as e:
print(f"Error connecting to OpenAI API: {e}")
return False

View File

@@ -2,6 +2,7 @@ from typing import List
from app.gpt.base import GPT
from openai import OpenAI
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT
from app.gpt.provider.OpenAI_compatible_provider import OpenAICompatibleProvider
from app.gpt.utils import fix_markdown
from app.models.gpt_model import GPTSource
from app.models.transcriber_model import TranscriptSegment
@@ -15,7 +16,7 @@ class QwenGPT(GPT):
self.base_url = getenv("QWEN_API_BASE_URL")
self.model=getenv('QWEN_MODEL')
print(self.model)
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
self.client = OpenAICompatibleProvider(api_key=self.api_key, base_url=self.base_url)
self.screenshot = False
def _format_time(self, seconds: float) -> str:
@@ -44,7 +45,8 @@ class QwenGPT(GPT):
content += SCREENSHOT
print(content)
return [{"role": "user", "content": content + AI_SUM}]
def list_models(self):
return self.client.list_models()
def summarize(self, source: GPTSource) -> str:
self.screenshot = source.screenshot
source.segment = self.ensure_segments_type(source.segment)
@@ -56,4 +58,6 @@ class QwenGPT(GPT):
)
return response.choices[0].message.content.strip()
if __name__ == '__main__':
gpt = QwenGPT()
print(gpt.list_models())

17
backend/app/gpt/test.py Normal file
View File

@@ -0,0 +1,17 @@
from app.models.model_config import ModelConfig
if __name__ == '__main__':
from app.gpt.gpt_factory import GPTFactory
# 构建模型config
config=ModelConfig(
id='asas',
api_key='',
base_url='',
model_name="gpt-4o",
provider='openai',
name='gpt-4o'
)
# 构建GPT
gpt=GPTFactory().from_config(config)

View File

@@ -0,0 +1,69 @@
from app.gpt.base import GPT
from app.gpt.prompt_builder import generate_base_prompt
from app.models.gpt_model import GPTSource
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT, LINK
from app.gpt.utils import fix_markdown
from app.models.transcriber_model import TranscriptSegment
from datetime import timedelta
from typing import List
class UniversalGPT(GPT):
def __init__(self, client, model: str, temperature: float = 0.7):
self.client = client
self.model = model
self.temperature = temperature
self.screenshot = False
self.screenshot = False
self.link = False
def _format_time(self, seconds: float) -> str:
return str(timedelta(seconds=int(seconds)))[2:]
def _build_segment_text(self, segments: List[TranscriptSegment]) -> str:
return "\n".join(
f"{self._format_time(seg.start)} - {seg.text.strip()}"
for seg in segments
)
def ensure_segments_type(self, segments) -> List[TranscriptSegment]:
return [TranscriptSegment(**seg) if isinstance(seg, dict) else seg for seg in segments]
def create_messages(self, segments: List[TranscriptSegment],**kwargs):
print("UniversalGPT",kwargs)
content =generate_base_prompt(
title=kwargs.get('title'),
segment_text=self._build_segment_text(segments),
tags=kwargs.get('tags'),
_format=kwargs.get('_format'),
style=kwargs.get('style'),
extras=kwargs.get('extras')
)
return [{"role": "user", "content": content }]
def list_models(self):
return self.client.models.list()
def summarize(self, source: GPTSource) -> str:
self.screenshot = source.screenshot
self.link = source.link
source.segment = self.ensure_segments_type(source.segment)
messages = self.create_messages(
source.segment,
title=source.title,
tags=source.tags
,
_format=source._format,
style=source.style,
extras=source.extras
)
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.7
)
return response.choices[0].message.content.strip()
if __name__ == '__main__':
print('s')

View File

@@ -0,0 +1,16 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class ModelConfig:
"""
存储每个模型提供商的调用参数信息,用于从数据库读取并动态构建 GPT 调用实例。
"""
name: str # 展示名,如 "GPT-4 Turbo"(用于前端展示)
provider: str # 模型提供商,如 "openai"、"qwen"、"deepseek"
api_key: str # 调用该模型使用的 API Key
base_url: str # 模型 API 接口地址OpenAI SDK兼容
model_name: str # 实际请求用的模型名称,如 "gpt-4-turbo"
created_at: Optional[datetime] = None # 可选:创建时间(从 SQLite 自动生成)

Some files were not shown because too many files have changed in this diff Show More