mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-10 17:43:40 +08:00
19
.env.example
19
.env.example
@@ -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
3
.gitignore
vendored
@@ -315,4 +315,5 @@ cython_debug/
|
||||
/backend/logs/
|
||||
/backend/note_results
|
||||
/backend/models
|
||||
/backend/.idea
|
||||
/backend/.idea/*
|
||||
/backend/bili_note.db
|
||||
@@ -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
|
||||
8
BillNote_frontend/.prettierignore
Normal file
8
BillNote_frontend/.prettierignore
Normal file
@@ -0,0 +1,8 @@
|
||||
dist
|
||||
build
|
||||
node_modules
|
||||
*.svg
|
||||
*.lock
|
||||
*.png
|
||||
public
|
||||
coverage
|
||||
11
BillNote_frontend/.prettierrc
Normal file
11
BillNote_frontend/.prettierrc
Normal 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"]
|
||||
}
|
||||
@@ -18,4 +18,4 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
3296
BillNote_frontend/src/assets/Lottie/404.json
Normal file
3296
BillNote_frontend/src/assets/Lottie/404.json
Normal file
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
BIN
BillNote_frontend/src/assets/customAI.png
Normal file
BIN
BillNote_frontend/src/assets/customAI.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
281
BillNote_frontend/src/components/Form/modelForm/Form.tsx
Normal file
281
BillNote_frontend/src/components/Form/modelForm/Form.tsx
Normal 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
|
||||
@@ -0,0 +1,4 @@
|
||||
// iconMap.ts
|
||||
import * as Icons from '@lobehub/icons'
|
||||
|
||||
export const IconMap = Icons;
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
44
BillNote_frontend/src/components/Form/modelForm/Provider.tsx
Normal file
44
BillNote_frontend/src/components/Form/modelForm/Provider.tsx
Normal 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
|
||||
@@ -0,0 +1,6 @@
|
||||
.card {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.card:hover {
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
@@ -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
|
||||
4
BillNote_frontend/src/components/Icons/iconMap.ts
Normal file
4
BillNote_frontend/src/components/Icons/iconMap.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// iconMap.ts
|
||||
import * as Icons from '@lobehub/icons'
|
||||
|
||||
export const IconMap = Icons;
|
||||
24
BillNote_frontend/src/components/Icons/index.tsx
Normal file
24
BillNote_frontend/src/components/Icons/index.tsx
Normal 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;
|
||||
13
BillNote_frontend/src/components/Lottie/404.tsx
Normal file
13
BillNote_frontend/src/components/Lottie/404.tsx
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
40
BillNote_frontend/src/components/Lottie/download.tsx
Normal file
40
BillNote_frontend/src/components/Lottie/download.tsx
Normal 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
|
||||
21
BillNote_frontend/src/components/Lottie/error.tsx
Normal file
21
BillNote_frontend/src/components/Lottie/error.tsx
Normal 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
|
||||
64
BillNote_frontend/src/components/ui/alert.tsx
Normal file
64
BillNote_frontend/src/components/ui/alert.tsx
Normal 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 }
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
29
BillNote_frontend/src/components/ui/switch.tsx
Normal file
29
BillNote_frontend/src/components/ui/switch.tsx
Normal 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 }
|
||||
@@ -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}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
63
BillNote_frontend/src/layouts/SettingLayout.tsx
Normal file
63
BillNote_frontend/src/layouts/SettingLayout.tsx
Normal 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
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
/>
|
||||
)
|
||||
}
|
||||
41
BillNote_frontend/src/pages/HomePage/Home.tsx
Normal file
41
BillNote_frontend/src/pages/HomePage/Home.tsx
Normal 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} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
454
BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx
Normal file
454
BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx
Normal 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
|
||||
@@ -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
|
||||
106
BillNote_frontend/src/pages/HomePage/components/NoteHistory.tsx
Normal file
106
BillNote_frontend/src/pages/HomePage/components/NoteHistory.tsx
Normal 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
|
||||
54
BillNote_frontend/src/pages/HomePage/components/StepBar.tsx
Normal file
54
BillNote_frontend/src/pages/HomePage/components/StepBar.tsx
Normal 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
|
||||
10
BillNote_frontend/src/pages/Index.tsx
Normal file
10
BillNote_frontend/src/pages/Index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
const Index = () => {
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default Index
|
||||
25
BillNote_frontend/src/pages/NotFoundPage/index.tsx
Normal file
25
BillNote_frontend/src/pages/NotFoundPage/index.tsx
Normal 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
|
||||
49
BillNote_frontend/src/pages/SettingPage/Menu.tsx
Normal file
49
BillNote_frontend/src/pages/SettingPage/Menu.tsx
Normal 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
|
||||
16
BillNote_frontend/src/pages/SettingPage/Model.tsx
Normal file
16
BillNote_frontend/src/pages/SettingPage/Model.tsx
Normal 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
|
||||
@@ -0,0 +1,7 @@
|
||||
.menuBar {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.menuBar:hover {
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
@@ -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
|
||||
17
BillNote_frontend/src/pages/SettingPage/index.tsx
Normal file
17
BillNote_frontend/src/pages/SettingPage/index.tsx
Normal 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
|
||||
8
BillNote_frontend/src/pages/SettingPage/transcriber.tsx
Normal file
8
BillNote_frontend/src/pages/SettingPage/transcriber.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
31
BillNote_frontend/src/services/model.ts
Normal file
31
BillNote_frontend/src/services/model.ts
Normal 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')
|
||||
}
|
||||
@@ -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 // 抛出错误以便调用方处理
|
||||
}
|
||||
}
|
||||
|
||||
25
BillNote_frontend/src/store/configStore/index.ts
Normal file
25
BillNote_frontend/src/store/configStore/index.ts
Normal 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
|
||||
}
|
||||
)
|
||||
)
|
||||
101
BillNote_frontend/src/store/modelStore/index.ts
Normal file
101
BillNote_frontend/src/store/modelStore/index.ts
Normal 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: '' }),
|
||||
}))
|
||||
)
|
||||
125
BillNote_frontend/src/store/providerStore/index.ts
Normal file
125
BillNote_frontend/src/store/providerStore/index.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -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',
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
9
BillNote_frontend/src/types/index.d.ts
vendored
Normal file
9
BillNote_frontend/src/types/index.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface IProvider {
|
||||
id: string
|
||||
name: string
|
||||
logo: string
|
||||
type: string
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
enabled: number
|
||||
}
|
||||
9
BillNote_frontend/src/utils/index.ts
Normal file
9
BillNote_frontend/src/utils/index.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
|
||||
@@ -24,9 +24,7 @@
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }) => {
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
25
README.md
25
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
42
backend/app/db/builtin_providers.json
Normal file
42
backend/app/db/builtin_providers.json
Normal 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://"
|
||||
}
|
||||
]
|
||||
58
backend/app/db/model_dao.py
Normal file
58
backend/app/db/model_dao.py
Normal 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]
|
||||
220
backend/app/db/provider_dao.py
Normal file
220
backend/app/db/provider_dao.py
Normal 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}")
|
||||
@@ -1,4 +1,4 @@
|
||||
import sqlite3
|
||||
|
||||
def get_connection():
|
||||
return sqlite3.connect("note_tasks.db")
|
||||
return sqlite3.connect("bili_note.db")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
backend/app/enmus/task_status_enums.py
Normal file
28
backend/app/enmus/task_status_enums.py
Normal 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, "未知状态")
|
||||
@@ -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
|
||||
13
backend/app/gpt/gpt_factory.py
Normal file
13
backend/app/gpt/gpt_factory.py
Normal 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)
|
||||
@@ -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())
|
||||
|
||||
100
backend/app/gpt/prompt_builder.py
Normal file
100
backend/app/gpt/prompt_builder.py
Normal 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 总结。
|
||||
'''
|
||||
22
backend/app/gpt/provider/OpenAI_compatible_provider.py
Normal file
22
backend/app/gpt/provider/OpenAI_compatible_provider.py
Normal 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
|
||||
@@ -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
17
backend/app/gpt/test.py
Normal 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)
|
||||
|
||||
|
||||
69
backend/app/gpt/universal_gpt.py
Normal file
69
backend/app/gpt/universal_gpt.py
Normal 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')
|
||||
|
||||
16
backend/app/models/model_config.py
Normal file
16
backend/app/models/model_config.py
Normal 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
Reference in New Issue
Block a user