Compare commits

...

231 Commits

Author SHA1 Message Date
huangjianwu
499366da02 fix(docker): 优化 Vite 配置以支持 Docker 构建环境
- 修改 vite.config.ts 在 Docker 环境中使用当前目录加载 .env 文件
- 在 Dockerfile 中设置 DOCKER_BUILD 环境变量
- 移除不必要的 .env.example 复制步骤

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:54:16 +08:00
huangjianwu
cb5c11d41a fix(ci): 移除 pnpm install 的 --frozen-lockfile 标志
由于不提交 pnpm-lock.yaml 文件,移除 --frozen-lockfile 标志以修复桌面端构建失败

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:50:48 +08:00
huangjianwu
8e2f74c0f5 fix(frontend): 修复 ESM 模式下 __dirname 未定义的问题
在 vite.config.ts 中添加 ESM 兼容的 __dirname 定义,修复 Docker 构建时的配置加载错误

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:46:53 +08:00
huangjianwu
c0f978bd77 fix(docker): 修复前端构建时缺少 .env 文件的问题
在构建前端之前复制 .env.example 到父目录,供 vite.config.ts 加载环境变量使用

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:42:34 +08:00
huangjianwu
4bff57c774 fix(docker): 修复 apt-get 安装失败问题
将清华镜像源从 https 改为 http 协议,避免 SSL 证书验证问题导致的 apt-get update 失败

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:36:22 +08:00
huangjianwu
341d3ded06 chore: 删除 ffmpeg 二进制文件并更新 .gitignore
移除错误提交的 301MB ffmpeg 构建文件,并在 .gitignore 中添加 ffmpeg*/ 规则防止再次提交

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:31:32 +08:00
Jianwu Huang
0e10a3d906 Merge pull request #304 from JefferyHcool/master
修复打包错误
2026-03-23 18:13:24 +08:00
huangjianwu
6d5d1ad373 fix(ci): 修复 GitHub Actions 构建错误
移除 setup-node 中的 pnpm 缓存配置以修复 macOS 构建失败,修改 Dockerfile 不再依赖 pnpm-lock.yaml 以修复 Docker 构建失败

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:11:55 +08:00
huangjianwu
3582e65dc5 feat(ci): 桌面端构建自动创建 GitHub Release 并附带安装包
- 新增 release job:等所有平台构建完成后自动创建 Release
- 收集各平台产物(dmg/msi/exe/deb/AppImage)到统一目录
- 使用 softprops/action-gh-release 创建 Release 并上传产物
- 自动生成 SHA256 校验和文件
- 自动根据 commits 生成 Release Notes
- 仅在推送 tag 时触发 Release 创建

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:58:57 +08:00
Jianwu Huang
3010690d2e Merge pull request #303 from JefferyHcool/feature/update-readme-v2
docs: 更新 README 至 v2.0.0,补充新功能说明和 Docker 部署方式
2026-03-23 17:56:15 +08:00
Jianwu Huang
a40bb19743 Merge branch 'master' into feature/update-readme-v2 2026-03-23 17:56:07 +08:00
huangjianwu
6090982261 docs: 更新 README 至 v2.0.0,补充新功能说明和 Docker 部署方式
- 版本号升级为 2.0.0(README、tauri.conf.json、about 页面)
- 新增 v2.0.0 功能说明:RAG 问答、Function Calling、封面 Banner、面板折叠等
- Docker 部署改为推荐方式,支持 docker pull 预构建镜像
- 补充源码部署为方式二,修正前端访问端口为 3015
- 更新功能特性列表,补充快手、AI 问答等新功能
- TODO 标记 RAG 问答为已完成

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:52:30 +08:00
Jianwu Huang
90aeb22853 Merge pull request #302 from JefferyHcool/feature/optimize-build
feat(build): 全面优化打包流程,Docker 镜像自动发布到 GHCR
2026-03-23 17:49:25 +08:00
huangjianwu
f6a3438079 feat(build): 全面优化打包流程,Docker 镜像自动发布到 GHCR
Docker 优化:
- Dockerfile 层缓存(requirements/lockfile 先复制再安装)
- ARG 可配置镜像源,国际用户可覆盖为默认源
- 前端 Dockerfile 改用 corepack + frozen-lockfile
- 精简 .dockerignore,排除 .git 和 Tauri 构建产物

CI/CD 优化:
- docker-build 自动推送到 GHCR,支持 amd64/arm64 双架构
- 桌面端 CI 增加 pip/pnpm/cargo 缓存,升级 actions 版本
- Python 版本对齐为 3.11,增加 Linux 构建矩阵
- build.sh 加 -y 覆盖标志

文档更新:
- README Docker 部署简化为 docker pull + docker run

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:48:34 +08:00
Jianwu Huang
c553fd898f Merge pull request #301 from JefferyHcool/feature/youtube-subtitle-innertube
feat(youtube): 使用 youtube-transcript-api 优先获取字幕,有字幕时跳过音频下载
2026-03-23 17:32:48 +08:00
huangjianwu
f4801d5be7 feat(youtube): 使用 youtube-transcript-api 优先获取字幕,有字幕时跳过音频下载
- 新增 YouTubeSubtitleFetcher 模块,通过 youtube-transcript-api 获取字幕
- 重构笔记生成流程:缓存 → 平台字幕 → 按需下载 → 转写 fallback
- 有字幕时仅提取视频元信息,不下载音视频文件
- 添加 youtube-transcript-api 依赖

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:31:30 +08:00
Jianwu Huang
5861ef4168 Merge pull request #300 from JefferyHcool/feature/ui-optimize
feat(ui): 工作区和生成历史面板支持折叠/展开
2026-03-23 16:12:32 +08:00
huangjianwu
27758f95dd feat(ui): 工作区和生成历史面板支持折叠/展开
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:09:57 +08:00
Jianwu Huang
a2ab457f75 Merge pull request #299 from JefferyHcool/feature/note-qa-chat-optimize
Feature/note qa chat optimize
2026-03-23 16:00:15 +08:00
huangjianwu
795615f0f7 fix(ui): 修复 Banner 封面图不显示
MarkdownViewer 的 baseURL 去掉了 /api 前缀,导致
image_proxy 请求路径错误。改为组件内部直接读取
VITE_API_BASE_URL,与 NoteHistory 保持一致。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:59:02 +08:00
huangjianwu
c46c971e64 fix(ui): Banner 封面通过后端代理加载,移除来源链接模块
- 封面图通过 /image_proxy 代理请求,解决 B 站等 CDN 跨域问题
- 渲染 markdown 时过滤掉开头的「来源链接」行,
  该信息已由 VideoBanner 的「原视频」按钮替代

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:56:48 +08:00
huangjianwu
55cc3bcd63 feat(ui): 笔记顶部新增视频封面 Banner
- 视频封面做模糊暗色背景,上方叠加视频信息
- 显示视频标题、作者/UP主、平台
- 右侧「原视频」按钮跳转原始链接
- 无封面时降级为渐变背景

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:54:36 +08:00
huangjianwu
05877a2197 feat(chat): 支持 function calling,模型可主动查询原文数据
新增三个工具供 LLM 调用:
- lookup_transcript: 查询转录原文(按时间范围、关键词、位置筛选)
- get_video_info: 获取视频元信息(标题、作者、简介、标签等)
- get_note_content: 获取完整笔记 Markdown 内容

实现 tool calling 循环(最多 3 轮),LLM 可根据问题
主动调用工具获取所需信息,不再完全依赖 RAG 检索。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:48:23 +08:00
huangjianwu
3e9f908d7b fix(chat): 按固定配额检索,确保三种来源均被召回
之前各来源各取 n_results 条再按距离排序取 top-n,
markdown 距离普遍更近导致 transcript 被挤掉。
改为固定配额:meta 1 条、markdown 2 条、transcript 3 条。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:44:25 +08:00
huangjianwu
8a8e448e22 feat(chat): 索引视频元信息(标题、作者、简介、标签等)
- 新增 _build_meta_chunk,将 audio_meta 中的标题、UP主、
  简介、标签、时长、平台、链接等构建为可检索的 chunk
- query 时同时从 meta/markdown/transcript 三种来源检索
- is_indexed 检测旧索引缺少 meta 时返回 false,自动触发重建
- system prompt 新增 [视频信息] 来源说明

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:41:07 +08:00
huangjianwu
a92c779dd6 fix(chat): RAG 检索同时召回笔记和转录内容
之前 query 只做一次全局检索,embedding 模型倾向匹配笔记,
导致转录原文几乎不会被召回。

- 改为分别对 markdown 和 transcript 各检索 n_results 条,
  合并后按距离排序取 top-n
- 更新 system prompt,明确区分笔记和转录两种来源,
  引导 LLM 根据问题类型选择合适的来源回答

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:35:31 +08:00
huangjianwu
ef1dec1e47 feat(chat): AI 回复支持 Markdown 渲染
通过 Bubble role 的 contentRender 使用 ReactMarkdown
渲染 AI 回复内容,支持列表、加粗、代码块等格式。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:31:55 +08:00
huangjianwu
fea376d1cb fix(chat): 修复 avatar 传对象导致 React 渲染报错
avatar 属性类型是 ReactNode,不是 props 对象,
改为直接传 JSX 元素作为头像。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:30:15 +08:00
huangjianwu
b18277a3a0 fix(chat): 修复消息气泡左右布局不生效
- Bubble.List 的角色配置属性名是 role(单数)而非 roles
- 用户消息:右侧蓝色填充气泡 + 蓝色头像
- AI 回复:左侧描边气泡 + 灰色头像

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:29:09 +08:00
huangjianwu
d8fbceaadf refactor(chat): 全屏/半屏切换移入 ChatPanel 内部
- Header 恢复单个"AI 问答"按钮,点击默认打开半屏模式
- ChatPanel 头部新增全屏/半屏切换按钮(Maximize2/Minimize2 图标)
- 半屏:markdown 与问答并排各占一半
- 全屏:问答占满内容区,隐藏 markdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:23:55 +08:00
huangjianwu
dea393e713 feat(chat): 问答面板支持半屏和全屏两种模式
- 半屏模式:ChatPanel 与 markdown 各占一半并排显示
- 全屏模式:ChatPanel 占满整个内容区域,隐藏 markdown
- Header 新增两个按钮(问答 / 全屏问答),点击切换,再次点击关闭
- 当前激活的模式按钮高亮显示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:20:07 +08:00
huangjianwu
ae2bfe4d0a fix(chat): 修复 ChatPanel 不显示的布局问题
- ChatPanel 容器添加 h-full shrink-0 确保有高度且不被压缩
- ScrollArea 从 w-full 改为 flex-1 min-w-0,为 ChatPanel 让出空间

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:15:14 +08:00
huangjianwu
2f2eb646a4 fix(chat): 索引改为后台异步执行,前端轮询状态并展示进度提示
后端:
- /chat/index 改为 BackgroundTasks 异步执行,立即返回
- /chat/status 返回细粒度状态(idle/indexing/indexed/failed)
- 内存追踪索引进度,避免重复触发

前端:
- ChatPanel 每 2 秒轮询索引状态,索引完成后自动停止
- 索引中显示"正在索引笔记内容..."及首次下载模型提示
- 索引失败显示重试按钮

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:46:37 +08:00
huangjianwu
fdc888512a fix(chat): 修复 ChromaDB 1.x 兼容性问题导致索引失败
- ChromaDB 1.x delete/get 不存在的 collection 抛 NotFoundError
  而非 ValueError,统一改为 except Exception
- 简化 _collection_name,UUID 格式本身就是合法的 collection name
- requirements.txt 放宽 chromadb 版本约束

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:43:57 +08:00
huangjianwu
3cd4c749c1 fix(chat): 修复 ChatPanel 无限重渲染导致的 Maximum update depth exceeded
- useTaskStore 选择器内调用 getCurrentTask() 每次返回新对象引用,
  改为分别选取 currentTaskId + tasks 后用 useMemo 派生
- chatHistory[taskId] || [] 在选择器内每次创建新空数组引用,
  改为选择器返回原始值,外部用 ?? [] 兜底

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:40:47 +08:00
huangjianwu
efadbc267d feat(chat): 基于 RAG 的笔记内容 AI 问答功能
实现类似 Google NotebookLM 的效果:笔记生成后自动向量化,
用户可针对笔记内容进行 LLM 问答。

### 后端
- 新增 VectorStoreManager(ChromaDB),按标题/转录分块建立向量索引
- 新增 chat_service.py RAG 问答:检索相关片段 → 构建 prompt → 调用 LLM
- 新增 /chat/index, /chat/ask, /chat/status API 端点
- 笔记生成完成后自动建立向量索引

### 前端
- 使用 @ant-design/x Bubble.List + Sender 组件构建聊天面板
- 新增 chatStore(Zustand + persist)持久化聊天记录
- MarkdownViewer 右侧嵌入 ChatPanel,通过"AI 问答"按钮切换
- 首次打开自动检查/触发索引,支持重新索引

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:38:39 +08:00
Jianwu Huang
63b8ac7e2b Merge pull request #298 from JefferyHcool/bugfix/performance-and-transcriber-config
fix: 性能优化、前端转写器配置、任务进度丢失及 MLX Whisper 回退问题修复
2026-03-23 14:23:03 +08:00
huangjianwu
c105342ded fix: 性能优化、前端转写器配置、任务进度丢失及 MLX Whisper 回退问题修复
### 性能优化
- 后端任务执行从串行锁改为 ThreadPoolExecutor 并发执行(默认3线程)
- 添加 GZipMiddleware 响应压缩 + Nginx gzip 配置
- 数据库连接池参数优化(pool_size=10, max_overflow=20)
- 视频帧提取并行化(ThreadPoolExecutor)
- LLM 重试配置缓存到实例,避免每次请求读 env var
- 前端路由级代码拆分(React.lazy + Suspense)
- Vite manualChunks 拆分 markdown/markmap/vendor
- MarkdownViewer 用 React.memo + useMemo 减少不必要渲染
- NoteHistory Fuse.js 实例 useMemo 缓存
- useTaskPolling 无待处理任务时跳过轮询
- 移除 antd 依赖(NoteForm Alert、modelForm Tag),改用 shadcn/ui

### 前端转写器配置(新功能)
- 新增 TranscriberConfigManager(JSON 文件存储,替代环境变量)
- 新增 GET/POST /transcriber_config API 端点
- 新增 GET /transcriber_models_status 模型下载状态查询
- 新增 POST /transcriber_download 后台模型下载触发
- 前端转写器设置页面:引擎选择、模型大小选择、模型下载管理
- deploy_status 端点同步从配置文件读取

### Bug 修复
- 修复任务进行中切换页面后进度丢失:Home.tsx status 派生逻辑补全中间状态
- 修复 MLX Whisper 静默回退 fast-whisper:移除环境变量门控,macOS 下自动尝试导入
- MLX Whisper 不可用时抛出 RuntimeError 而非静默回退
- 前端展示 MLX Whisper 可用性状态,不可用时禁用保存

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:09:34 +08:00
huangjianwu
1cd8c33983 feat(note): 在笔记开头添加来源链接功能 2026-03-23 13:18:40 +08:00
Jianwu Huang
dd73e56c30 Merge pull request #273 from Sjshi763/Sjshi763/issue259
添加 Docker 构建工作流
2026-03-20 16:51:16 +08:00
Jianwu Huang
4a53b6aa32 Merge pull request #279 from CyanAutumn/master
🐞 fix: 增加错误之后对已解析段落的缓存功能,再次重试时不再重头开始
2026-03-20 16:50:52 +08:00
Jianwu Huang
15d851f0d0 Merge branch 'master' into master 2026-03-20 16:50:43 +08:00
Jianwu Huang
8172e64510 Merge pull request #275 from sibuchen/feature/deployment-monitor-clean
feat: 新增部署监控页面 (Deployment Monitor)
2026-03-20 16:49:46 +08:00
Jianwu Huang
7969d9a75c Merge pull request #283 from wanderer99176/fix-timestamp-format
再次优化 B站时间戳跳转格式
2026-03-20 16:49:13 +08:00
wanderer99176
7fb4fcba77 fix: update bilibili timestamp link format to - [MM:SS](URL#t=MM:SS) 2026-02-25 11:57:16 +08:00
CyanAutumn
d9a7b89e7d 🐞 fix: 增加错误之后对已解析段落的缓存功能,再次重试时不再重头开始
解析长视频时,当附件大小过大时不再调用后进行报错,而是将附件进行分批次发送

在每篇笔记开头默认增加地址来源链接,对模糊处可溯源
2026-02-12 18:28:11 +08:00
sibuchen
8cd8c6f7b4 feat: add deployment monitor page
- Add /deploy_status API endpoint for system status check
- Create Monitor.tsx component with real-time status display
- Support CUDA, FFmpeg, Whisper model status monitoring
- Auto-refresh every 30 seconds with manual refresh option
2026-02-06 16:15:11 +08:00
圣达生物多
769aca10db 添加 Docker 构建工作流和完整应用镜像的 Dockerfile 2026-02-05 21:34:57 +08:00
Jianwu Huang
7b45db2f59 Update README.md 2026-02-05 16:50:51 +08:00
Jianwu Huang
a5f0211fcb Merge pull request #262 from Sjshi763/Sjshi763/issue232
[BUG] 已经把ffmpeg加入到系统变量path了 还是检测不出来 ?
2026-02-05 16:05:03 +08:00
Jianwu Huang
658d29e72f Merge pull request #268 from nbzcy/feature/subtitle-priority-and-export-enhancements
feat: Add subtitle priority fetching and enhance mindmap export
2026-02-05 16:04:38 +08:00
Jianwu Huang
2b3f850478 Merge pull request #271 from kxuer/master
修复哔哩哔哩视频原片url问题
2026-02-05 16:04:23 +08:00
xuerk
caa4619aab 修复哔哩哔哩视频原片url问题 2026-02-05 15:41:04 +08:00
sunnyclubcn
85b24dee40 feat: Add subtitle priority fetching and enhance mindmap export
## Subtitle Priority (Backend)
- Add download_subtitles() method to base downloader
- Implement Bilibili subtitle fetching with cookies support
- Implement YouTube subtitle fetching
- Support SRT and JSON3 format parsing
- Prioritize platform subtitles over Whisper transcription

## Mindmap Export Enhancements (Frontend)
- Add SVG vector export with proper viewBox handling
- Add XMind format export with Chinese character encoding fix
- Fix PNG/SVG export to capture full content by calling fit() before export
- Add JSZip dependency for XMind export

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 17:12:14 +08:00
Sjshi763
844e1a102a [BUG] 已经把ffmpeg加入到系统变量path了 还是检测不出来 ?
Fixes #232
2026-01-03 14:57:42 +08:00
Jianwu Huang
10311c1438 Merge pull request #192 from HansYeoh/export-mind-map
Add an export mind map button to support exporting HTML and PNG.
2025-10-18 10:00:19 +08:00
Jianwu Huang
3a0f86e74e Merge pull request #193 from HansYeoh/allow-all-domains
Modify to allow access from all domains
2025-10-18 09:59:58 +08:00
Jianwu Huang
208aed41a1 Merge pull request #217 from MgAlNa3PO4/master
修复了docker部署当中前端页面图片显示异常的问题
2025-10-18 09:59:35 +08:00
userName
6e385b8d75 Fixed the problem of abnormal display of front-end page pictures in docker deployment 2025-09-06 16:17:32 +08:00
userName
9ba895fa8d Fixed the problem of abnormal display of front-end page pictures in docker deployment 2025-09-06 16:10:42 +08:00
Jianwu Huang
df72fa9366 Merge pull request #194 from Paper-Dragon/fixed_local_video
Refine local video form validation and update Docker configuration
2025-07-18 10:00:29 +08:00
Yang Han
6d077a4ed3 Update vite.config.ts
Modify to allow access from all domains
2025-07-17 01:29:59 +08:00
Yang Han
b1b0e87d85 Update MarkmapComponent.tsx
Add an export mind map button to support exporting HTML and PNG.
2025-07-17 01:27:33 +08:00
Yang Han
7d325517b3 Update MarkdownViewer.tsx
Add an export mind map button to support exporting HTML and PNG.
2025-07-17 01:27:09 +08:00
Paper-Dragon
dc29319a3e Refine form validation and update Docker configuration 2025-07-09 13:34:26 +08:00
Jianwu Huang
880f745718 Merge pull request #176 from Paper-Dragon/feat_docker_gpu_support
Add GPU support with Docker enhancements
2025-07-05 17:21:12 +08:00
Paper-Dragon
1ce8b41bde Add GPU support with Docker enhancements
- Introduced a `Dockerfile.gpu` for GPU-enabled backend setup.
- Added `docker-compose.gpu.yml` to utilize GPU resources via NVIDIA.
- Fixed Nginx configuration for GPU backend port changes.
2025-07-04 00:16:39 +08:00
Jianwu Huang
f667e9460b fix:修复 cpu 核心锁死问题
fix cpu 核心锁死问题
2025-07-03 10:28:33 +08:00
Jianwu Huang
5f346f1b04 Merge pull request #172 from Karasukaigan/master
fix: 修复多个前后端错误与警告
2025-07-02 15:13:35 +08:00
Karasukaigan
b813d83246 fix: 修复B站短链接无法解析的问题
增加了对b23.tv短链接的解析。
2025-07-02 15:03:03 +08:00
Karasukaigan
564eee2682 fix: 隐藏多余错误提示 2025-07-02 04:34:54 +08:00
Karasukaigan
8fecf293bb fix: 优化Schema校验逻辑
修复了以下问题:
1. 当视频链接为空时,原本的校验逻辑会导致首次点击生成笔记时报错“Required”而不是“视频链接不能为空”。
2. 当选择抖音时无法判断URL是否合法,即使填入“123”也能触发后面的逻辑。
2025-07-02 04:05:04 +08:00
Karasukaigan
ce76b78b34 fix: 缓解Ant Design与React版本兼容性问题
Ant Design v5与React 19+存在兼容性问题,出现报错:[antd: compatible] antd v5 support React is 16 ~ 18.

## 修复方式
1. 尝试升级antd到5.26.3,但不起作用。
2. 注释掉代码里的`message.error`,可以暂时解决问题。
2025-07-02 03:21:36 +08:00
Karasukaigan
a8c10d3961 微调前端左中右区域默认占比 2025-07-02 02:40:23 +08:00
Karasukaigan
c009afaa6c fix: 修复Checkbox组件受控与非受控切换的警告
修复React警告"Checkbox is changing from uncontrolled to controlled."
2025-07-02 01:33:31 +08:00
Karasukaigan
5ff88ac765 fix: 修复Input组件受控与非受控切换的警告
修复React警告"A component is changing an uncontrolled input to be controlled"。

## 原因
`input.tsx`中`<input>`元素未显式处理`value`和`onChange`属性,导致父组件传值时从`undefined`切换为具体值(或反之)

## 修复方式
- 显式提取`value`和`onChange`属性
- 使用`value ?? ''`保证默认值始终为字符串,避免从`undefined`切换
- 确保组件始终以受控模式运行
2025-07-02 01:21:35 +08:00
Karasukaigan
fabf4b7cd5 fix: 修复ResizablePanel默认大小总和非100%的警告
修复React可调整面板组件的布局警告"Invalid layout total size: 18%, 16%, 55%."
2025-07-02 01:09:46 +08:00
Karasukaigan
d8768d5d5b fix: 修复NoteHistory组件中的key警告
修复React警告"Each child in a list should have a unique 'key' prop"
2025-07-02 00:44:52 +08:00
Karasukaigan
f05ae6a27f refactor: 简化URL结构
将`HashRouter`替换为更现代的`BrowserRouter`,移除`#`片段以简化URL结构。
2025-07-02 00:20:28 +08:00
Jianwu Huang
e487b5382b Merge pull request #158 from JefferyHcool/feature/1.8.1
fix:windows 日志格式问题
2025-06-23 09:21:20 +08:00
JefferyHcool
b20725cb00 fix:修复windows 日志格式问题 2025-06-23 09:20:36 +08:00
JefferyHcool
e40c97b3fd fix:修复windows 日志格式问题 2025-06-23 09:18:31 +08:00
Jianwu Huang
ebb6d174ad Merge pull request #153 from JefferyHcool/feature/1.8.0
build:完成打包功能
2025-06-20 14:46:57 +08:00
JefferyHcool
ef4e67eda6 build:完成打包功能 2025-06-20 14:45:49 +08:00
Jianwu Huang
d5c3fe472a Merge pull request #152 from JefferyHcool/feature/1.8.0
refactor(backend): 修改系统初始化和健康检查相关逻辑
2025-06-20 13:46:36 +08:00
JefferyHcool
50bf467341 refactor(backend): 修改系统初始化和健康检查相关逻辑
- 更新 BackendInitDialog 组件中的提示信息,增加报错提示
- 在 config 路由中添加 sys_check 接口,用于系统检查
- 修改 useCheckBackend钩子,使用新的 sys_check接口进行系统检查
2025-06-20 13:44:48 +08:00
Jianwu Huang
caad44414f Merge pull request #151 from JefferyHcool/feature/1.8.0
feat(system): 添加后端初始化和健康检查功能
2025-06-20 13:06:45 +08:00
JefferyHcool
f23ed6ec6c feat(system): 添加后端初始化和健康检查功能
- 新增后端初始化对话框组件
- 实现后端健康检查和初始化逻辑
- 在 App 组件中集成后端初始化和健康检查
- 新增系统健康检查 API 和相关服务
2025-06-20 13:05:42 +08:00
Jianwu Huang
0919e65ad7 Merge pull request #150 from JefferyHcool/feature/1.8.0
docs: 更新 README 中微信交流群图片链接
2025-06-20 12:08:21 +08:00
JefferyHcool
7f8d4faa44 docs: 更新 README 中微信交流群图片链接
- 将微信交流群图片链接从外部 URL 更改为本地文件路径
- 从腾讯云存储地址改为相对路径的图片文件
2025-06-20 12:07:38 +08:00
Jianwu Huang
7687e898bc Merge pull request #149 from JefferyHcool/feature/1.8.0
build:完成打包功能
2025-06-20 12:03:41 +08:00
JefferyHcool
467deefd28 build:完成打包功能 2025-06-20 12:03:10 +08:00
Jianwu Huang
d3f42f967b Merge pull request #147 from JefferyHcool/feature/1.8.0 2025-06-20 09:47:01 +08:00
JefferyHcool
601bd7c4e3 refactor: 修复 build.bat脚本中的路径问题
- 修改了 .env 文件复制和删除操作的路径,使用反斜杠 (\) 替代斜杠 (/)
- 这个改动解决了 Windows 系统上路径解析的问题,确保脚本能够正确执行
2025-06-20 09:46:12 +08:00
JefferyHcool
6d06cb662d build(tauri): 更新后端端口并优化打包流程
- 将后端端口从8000 修改为 8483
- 更新前端请求基础 URL 以匹配新的后端端口
- 优化后端打包脚本,确保 .env 文件正确复制和清理
- 修改后端主程序和请求工具中的端口配置
2025-06-20 09:39:45 +08:00
Jianwu Huang
63e0345812 Merge pull request #146 from JefferyHcool/feature/1.8.0
build(tauri): 更新后端端口并优化打包流程
2025-06-20 09:36:22 +08:00
JefferyHcool
c24fcc6d7d build(tauri): 更新后端端口并优化打包流程
- 将后端端口从8000 修改为 8483
- 更新前端请求基础 URL 以匹配新的后端端口
- 优化后端打包脚本,确保 .env 文件正确复制和清理
- 修改后端主程序和请求工具中的端口配置
2025-06-20 09:35:34 +08:00
Jianwu Huang
2b0b1d2a85 Merge pull request #144 from JefferyHcool/feature/1.8.0
ci/cd:修复win打包
2025-06-19 18:22:05 +08:00
JefferyHcool
29372bab6b ci/cd:修复win打包 2025-06-19 18:21:13 +08:00
Jianwu Huang
03cb670bfa Update main.yml 2025-06-19 18:13:01 +08:00
Jianwu Huang
8ad1ea6d38 Merge pull request #143 from JefferyHcool/feature/1.8.0
chore:win打包
2025-06-19 18:10:11 +08:00
JefferyHcool
cdcbfc89bc ci/cd:修复win打包 2025-06-19 18:09:01 +08:00
JefferyHcool
2a510e8059 fix:修复bugs 2025-06-19 18:04:37 +08:00
Jianwu Huang
f8808737a3 Merge pull request #142 from JefferyHcool/feature/1.8.0
fix:修复bugs
2025-06-19 17:48:33 +08:00
JefferyHcool
0aaec4a53f fix:修复bugs 2025-06-19 17:44:48 +08:00
Jianwu Huang
1ab91965f3 Update main.yml 2025-06-19 17:35:06 +08:00
Jianwu Huang
689a6d99b0 Update main.yml 2025-06-19 17:26:49 +08:00
Jianwu Huang
2a164828a2 Update main.yml 2025-06-19 17:23:18 +08:00
Jianwu Huang
abbda6848a Update main.yml 2025-06-19 17:17:41 +08:00
Jianwu Huang
3afc0c1166 Update main.yml 2025-06-19 17:11:56 +08:00
Jianwu Huang
e9ac6e499f Merge pull request #141 from JefferyHcool/feature/1.8.0
fix:修复bugs
2025-06-19 17:01:31 +08:00
JefferyHcool
02d2b6d983 fix:修复bugs 2025-06-19 17:00:42 +08:00
Jianwu Huang
2683569a0b Update main.yml 2025-06-19 16:57:41 +08:00
Jianwu Huang
5876b88a8a Update main.yml 2025-06-19 16:28:25 +08:00
Jianwu Huang
58648399a2 Update main.yml 2025-06-19 16:26:58 +08:00
Jianwu Huang
4981e09ede Merge pull request #140 from JefferyHcool/feature/1.8.0
chore:打包测试
2025-06-19 16:24:36 +08:00
Jianwu Huang
29c4926306 Merge branch 'master' into feature/1.8.0 2025-06-19 16:24:26 +08:00
Jianwu Huang
7d9d47d7b7 Create workflow.yml 2025-06-19 16:21:14 +08:00
JefferyHcool
3b3e6b86f3 chore:打包测试 2025-06-19 16:20:32 +08:00
JefferyHcool
d92cc4a977 feat(NoteForm): 增加文件上传状态反馈 2025-06-19 14:54:51 +08:00
Jianwu Huang
4a0f483224 Update README.md 2025-06-08 18:12:54 +08:00
Jianwu Huang
5e63630033 Update README.md 2025-06-08 11:12:03 +08:00
Jianwu Huang
80f1b6b48b Merge pull request #133 from JefferyHcool/feature/1.7.5
fix:修复bugs
2025-06-06 22:16:35 +08:00
JefferyHcool
032446d5eb fix:修复bugs 2025-06-06 22:15:31 +08:00
Jianwu Huang
35ef60b956 Merge pull request #132 from JefferyHcool/feature/1.7.5
Feature/1.7.5
2025-06-06 22:03:53 +08:00
JefferyHcool
cf512e226f feat:优化上传体验 2025-06-06 22:03:12 +08:00
JefferyHcool
2dfc1c068f feat(NoteForm): 增加文件上传状态反馈
- 添加上传中和上传成功状态的显示- 优化上传逻辑,增加状态控制
- 提升用户体验,明确上传过程
2025-06-06 22:02:02 +08:00
Jianwu Huang
2b0fb8f4ad Merge pull request #131 from JefferyHcool/feature/v1.7.4
fix:修复bugs
2025-06-06 21:50:05 +08:00
JefferyHcool
f1cc79aab4 fix:修复bugs 2025-06-06 21:49:07 +08:00
Jianwu Huang
fff4fdc9c9 Merge pull request #126 from JefferyHcool/codex/查找并修复错误
Fix duplicate handler registration
2025-06-06 21:31:45 +08:00
Jianwu Huang
1945586b55 Merge pull request #130 from JefferyHcool/feature/v1.7.4
refactor(backend): 重构后端异常处理和模型管理
2025-06-06 21:31:22 +08:00
JefferyHcool
8b1bc54f2d refactor(backend): 重构后端异常处理和模型管理
- 新增自定义异常类 BizException、NoteError 和 ProviderError
- 优化了模型管理相关的逻辑,包括加载、删除和测试连接等功能
- 改进了 Douyin 下载器的错误处理
- 调整了任务重试逻辑和笔记生成的异常处理- 更新了相关组件和页面以适应新的异常处理机制
2025-06-06 21:30:23 +08:00
Jianwu Huang
707241bf6b Update README.md 2025-06-04 20:26:21 +08:00
Jianwu Huang
b965020491 Fix startup and GPT initialization issues 2025-06-04 09:37:21 +08:00
JefferyHcool
df5c0f771a feat(model): 增加模型管理和测试功能
- 新增模型删除功能
- 实现模型测试连接功能
- 优化模型选择器组件
- 更新模型相关API和数据库操作
2025-05-27 08:53:24 +08:00
JefferyHcool
31f42aa26e feat(model): 增加模型管理和测试功能
- 新增模型删除功能
- 实现模型测试连接功能
- 优化模型选择器组件
- 更新模型相关API和数据库操作
2025-05-27 08:52:38 +08:00
JefferyHcool
be3db5faaf feat(model): 增加模型管理和测试功能
- 新增模型删除功能
- 实现模型测试连接功能
- 优化模型选择器组件
- 更新模型相关API和数据库操作
2025-05-26 23:16:49 +08:00
JefferyHcool
9b298d3094 feat(model): 增加模型管理和测试功能
- 新增模型删除功能
- 实现模型测试连接功能
- 优化模型选择器组件
- 更新模型相关API和数据库操作
2025-05-26 23:16:19 +08:00
黄建武
ee9f6ed80c UPDATE:更新微信二维码 2025-05-25 20:10:57 +08:00
Jianwu Huang
0a43bca0c0 更新 .env.example 2025-05-23 19:05:58 +08:00
Jianwu Huang
9063026d45 Update README.md 2025-05-21 09:10:02 +08:00
Jianwu Huang
32c57b61b5 Update default.conf
修复文件上传大小限制问题
2025-05-21 09:06:48 +08:00
Jianwu Huang
3d91a4f29e Update README.md 2025-05-15 11:35:27 +08:00
Jianwu Huang
44203c5382 Merge pull request #108 from JefferyHcool/feature/kuaishou
feat(nginx): 调整客户端请求体大小限制
2025-05-15 11:35:13 +08:00
黄建武
44d89e3b73 feat(nginx): 调整客户端请求体大小限制
- 设置 client_max_body_size 为 10G,允许上传大文件- 设置 client_body_buffer_size 为 128k,优化请求体缓冲
2025-05-15 11:34:39 +08:00
Jianwu Huang
539d9f3868 Update README.md 2025-05-15 10:10:10 +08:00
Jianwu Huang
d6e7a6e394 Update README.md 2025-05-14 21:25:20 +08:00
Jianwu Huang
d4f18feaf9 Update README.md 2025-05-14 21:21:05 +08:00
Jianwu Huang
f365dfe5de Update .env.example 2025-05-14 21:00:57 +08:00
Jianwu Huang
8c4d59918e Update README.md 2025-05-14 15:30:59 +08:00
Jianwu Huang
53be1f341c Merge pull request #106 from JefferyHcool/feature/kuaishou
feat(db): 添加 Ollama本地离线模型支持
2025-05-14 15:30:05 +08:00
黄建武
aeae3410a0 feat(db): 添加 Ollama本地离线模型支持
- 在 builtin_providers.json 中添加 Ollama 提供商配置
- 修改 OpenAI_compatible_provider.py,优化与 Ollama 的兼容性
2025-05-14 15:28:57 +08:00
Jianwu Huang
41b067305e Merge pull request #103 from JefferyHcool/feature/kuaishou
Feature/kuaishou
2025-05-12 16:15:16 +08:00
黄建武
3862c657b7 build(docker): 更新 docker-compose配置
- 将 Nginx 容器的端口映射改为使用环境变量 APP_PORT- 移除 frontend 服务的依赖
2025-05-12 16:13:05 +08:00
黄建武
1c848c727f docs: 更新 .env.example 文件中的 TRANSCRIBER_TYPE 配置选项
- 调整配置选项的描述,使 groq 成为独立的配置项
-优化注释内容,提高可读性和准确性
2025-05-12 16:04:42 +08:00
Jianwu Huang
4a0bff9919 Merge pull request #102 from JefferyHcool/feature/kuaishou
feat(.env.example): 添加 groq 语音识别模型配置选项
2025-05-12 16:01:27 +08:00
黄建武
a5a523f918 feat(.env.example): 添加 groq 语音识别模型配置选项
- 在 TRANSCRIBER_TYPE 中添加 groq 作为可选的 Apple 平台专用模型
- 新增 GROQ_TRANSCRIBER_MODEL 变量,用于指定 groq 提供的 faster-whisper模型,默认为 whisper-large-v3-turbo
2025-05-12 15:59:04 +08:00
Jianwu Huang
1b78fb6417 Merge pull request #101 from JefferyHcool/feature/kuaishou
feat(backend): 添加 Groq供应商支持并优化笔记生成流程- 在 builtin_providers.json 中添加 Groq 供应商信息
- 实现 GroqTranscriber 类以支持 Groq 语音转录服务
- 新增异常处理中间件以提高系统稳定性
- 优化笔记生成流程,增加错误处理和日志记录
- 添加思维导图功能和相关组件
-重构 Markdown 查看器以支持切换视图模式
2025-05-12 15:39:27 +08:00
Jianwu Huang
c1ef98f6d9 Merge branch 'master' into feature/kuaishou 2025-05-12 15:38:54 +08:00
黄建武
fbb292d0e3 docs(README): 更新 BiliNote 版本号
将 README.md 中的 BiliNote 版本号从 v1.6.0 修改为 v1.7.0
2025-05-12 15:03:10 +08:00
黄建武
6ff8b4d90f feat(backend): 添加 Groq供应商支持并优化笔记生成流程- 在 builtin_providers.json 中添加 Groq 供应商信息
- 实现 GroqTranscriber 类以支持 Groq 语音转录服务
- 新增异常处理中间件以提高系统稳定性
- 优化笔记生成流程,增加错误处理和日志记录
- 添加思维导图功能和相关组件
-重构 Markdown 查看器以支持切换视图模式
2025-05-12 14:59:06 +08:00
Jianwu Huang
490ee11a85 Update README.md 2025-05-12 14:24:28 +08:00
Jianwu Huang
591f0d5ddd Merge pull request #100 from JefferyHcool/feature/kuaishou
feat(db): 更新内置 AI 服务提供商配置
2025-05-12 09:06:41 +08:00
黄建武
b2034c0865 feat(db): 更新内置 AI 服务提供商配置
- 移除Doubao 服务商配置- 添加 Gemini 服务商配置
- 更新 Claude 服务商的 base_url
2025-05-12 09:05:53 +08:00
Jianwu Huang
3239675e69 Merge pull request #98 from JefferyHcool/feature/kuaishou
fix(markdown): 修复 Markdown 组件以提高可读性和维护性
2025-05-09 16:09:04 +08:00
黄建武
137cf81d29 fix(markdown): 修复 Markdown 组件以提高可读性和维护性
- 格式化代码以提高可读性
-优化组件结构以提高维护性- 调整样式和布局以提升用户体验
2025-05-09 16:08:18 +08:00
Jianwu Huang
c6aa6603ef Update README.md 2025-05-09 15:39:21 +08:00
Jianwu Huang
235a044a1a Merge pull request #97 from JefferyHcool/feature/kuaishou
fix:修复视频生成错误
2025-05-09 15:39:05 +08:00
黄建武
1888849270 fix:修复视频生成错误 2025-05-09 15:38:31 +08:00
Jianwu Huang
8c0f637ab1 Merge pull request #96 from JefferyHcool/feature/kuaishou
refactor(env): 优化环境变量配置和请求基地址设置
2025-05-09 13:24:52 +08:00
Jianwu Huang
00ca7e891c Merge pull request #95 from JefferyHcool/feature/kuaishou
refactor(env): 优化环境变量配置和请求基地址设置
2025-05-09 13:24:21 +08:00
黄建武
5e7c381e07 refactor(env): 优化环境变量配置和请求基地址设置
- 移除 request.ts 中的重复 baseURL 配置
- 在 vite.config.ts 中添加默认的 API基地址和前端端口- 更新请求基地址为相对路径,提高可维护性
2025-05-09 13:24:12 +08:00
黄建武
da645291a2 refactor(env): 优化环境变量配置和请求基地址设置
- 移除 request.ts 中的重复 baseURL 配置
- 在 vite.config.ts 中添加默认的 API基地址和前端端口- 更新请求基地址为相对路径,提高可维护性
2025-05-09 13:23:29 +08:00
Jianwu Huang
c7656e5609 Update README.md 2025-05-09 12:45:21 +08:00
Jianwu Huang
faad0fd4d4 Merge pull request #94 from JefferyHcool/feature/kuaishou
refactor(app/utils): 更新 VideoReader 类的目录设置
2025-05-09 12:41:28 +08:00
黄建武
048a3b70df refactor(app/utils): 更新 VideoReader 类的目录设置
- 引入 get_app_dir 函数用于获取应用目录路径
- 修改 frame_dir 和 grid_dir 参数默认值为 None
- 在构造函数中使用 get_app_dir 设置默认目录路径
2025-05-09 12:40:36 +08:00
Jianwu Huang
bab8e3af65 Merge pull request #93 from JefferyHcool/feature/kuaishou
Feature/kuaishou
2025-05-09 11:58:40 +08:00
黄建武
b75caaea0e Merge remote-tracking branch 'origin/master' into feature/kuaishou 2025-05-09 11:58:03 +08:00
黄建武
140c9b1d88 refactor(path_helper): 重构路径获取方法,支持打包运行
- 修改 get_data_dir 函数,以支持打包后可写的运行目录- 新增 get_app_dir 函数,提供更灵活的路径获取方式
- 优化路径处理逻辑,确保在不同环境下都能正确获取路径
2025-05-09 11:57:41 +08:00
黄建武
668785ebe5 refactor(path_helper): 重构路径获取方法,支持打包运行
- 修改 get_data_dir 函数,以支持打包后可写的运行目录- 新增 get_app_dir 函数,提供更灵活的路径获取方式
- 优化路径处理逻辑,确保在不同环境下都能正确获取路径
2025-05-09 11:57:11 +08:00
Jianwu Huang
883b112fc2 Merge pull request #92 from JefferyHcool/release/v1.5.0
refactor(utils): 优化 request.ts 中的 API 地址配置- 提取 base URL 到单独的常量中
2025-05-09 11:13:22 +08:00
黄建武
8e917ee15e refactor(utils): 优化 request.ts 中的 API 地址配置- 提取 base URL 到单独的常量中
- 使用 import.meta.env.VITE_API_BASE_URL 或本地地址作为默认值
-简化了代码结构,提高了可读性和可维护性
2025-05-09 11:12:49 +08:00
Jianwu Huang
5298e6adb3 Merge pull request #91 from JefferyHcool/feature/kuaishou
feat(utils): 添加 API 路径到请求基础 URL
2025-05-09 10:54:44 +08:00
黄建武
17216534cb feat(utils): 添加 API 路径到请求基础 URL
- 在请求基础 URL 中加入 '/api' 路径
-确保无论是生产环境还是本地环境,请求 URL 都包含 API路径
2025-05-09 10:53:51 +08:00
Jianwu Huang
1071da2bed Merge pull request #90 from JefferyHcool/feature/kuaishou
refactor(backend): 更新默认提供商路径获取方法并配置前端请求基础 URL
2025-05-09 10:42:53 +08:00
黄建武
2dfcb600ae refactor(backend): 更新默认提供商路径获取方法并配置前端请求基础 URL
- 新增 get_builtin_providers_path 函数以动态获取内置提供商 JSON 文件路径
- 修改 seed_default_providers 函数,使用新的路径获取方法
- 更新前端请求工具,配置 API 基础 URL 以适应不同环境
2025-05-09 10:41:46 +08:00
Jianwu Huang
274d7b9677 Merge pull request #89 from JefferyHcool/feature/kuaishou
refactor(utils): 更新模型目录获取逻辑以支持打包运行
2025-05-09 10:28:27 +08:00
黄建武
0a5196a475 refactor(utils): 更新模型目录获取逻辑以支持打包运行
-增加对打包状态的判断,使用不同的目录路径
-打包时将模型目录设置为 APPDATA 或 ~/.cache 下的 BiliNote/models
- 开发时仍使用项目根目录下的 models目录
- 确保兼容性和可移植性
2025-05-09 10:27:16 +08:00
Jianwu Huang
892ccd9ee4 Merge pull request #88 from JefferyHcool/release/v1.5.0
refactor(layout): 优化网页布局和路由设置
2025-05-09 09:59:57 +08:00
黄建武
732ea0ba2b refactor(layout): 优化网页布局和路由设置
- 更新 logo显示方式,使用 import 代替直接引用
- 将 BrowserRouter 替换为 HashRouter,以适应前端路由
- 在项目中添加 logo.svg 文件,统一 logo 资源
- 调整 vite.config.ts,设置 base 为 './' 以优化构建
2025-05-09 09:57:13 +08:00
Jianwu Huang
8ed50ba662 Merge pull request #87 from JefferyHcool/feature/kuaishou
refactor(.gitignore and vite.config): 更新忽略文件配置和前端端口设置
2025-05-09 09:07:24 +08:00
黄建武
d4d5e063d0 refactor(.gitignore and vite.config): 更新忽略文件配置和前端端口设置
- 在 .gitignore 文件中添加 backend/config/* 和 BiliNo 到忽略列表
- 移除 BiliNote_frontend/.idea/* 以便于前端项目的重构
- 在 vite.config.ts 中添加前端端口配置,使用环境变量或默认值 3015
- 修改服务器端口配置,使用新设置的前端端口
2025-05-09 09:05:46 +08:00
Jianwu Huang
8e1ab5373f Update README.md 2025-05-08 18:16:54 +08:00
Jianwu Huang
61ca6d2fe6 Merge pull request #83 from JefferyHcool/feature/kuaishou
feat(download): 添加快手下载器并优化下载配置功能
2025-05-08 18:16:37 +08:00
黄建武
21c9d47495 feat(download): 添加快手下载器并优化下载配置功能
- 新增快手下载器,支持快手视频下载
- 添加下载配置页面,可设置各平台Cookies
- 优化后端接口,增加获取和更新Cookies的功能
- 前端新增Downloader组件和相关表单组件
- 更新路由配置,增加下载配置相关路由
2025-05-08 18:15:59 +08:00
Jianwu Huang
321d22271a Merge pull request #82 from JefferyHcool/deploy/docker
chore: 添加 .dockerignore 文件
2025-05-08 15:14:45 +08:00
黄建武
1af6cde68f chore: 添加 .dockerignore 文件
- 新增 .dockerignore 文件,用于配置 Docker 构建过程中需要忽略的文件和目录
- 忽略日志、缓存、临时文件等,以减少 Docker 镜像大小
-排除敏感信息文件,如 .env 等环境变量配置- 忽略开发相关的文件和目录,如 .idea、.pytest_cache 等
2025-05-08 15:13:18 +08:00
Jianwu Huang
5fe78c2a68 Update README.md 2025-05-08 14:46:02 +08:00
Jianwu Huang
17f5bad16d Merge pull request #81 from JefferyHcool/feature/regenerate
feat(transcriber): 使用 ModelScope 替代 Hugging Face 下载模型
2025-05-08 14:45:46 +08:00
黄建武
51fb59e3e1 feat(transcriber): 使用 ModelScope 替代 Hugging Face 下载模型
- 在 requirements.txt 中添加 modelscope 依赖
- 修改 whisper.py 中的模型下载逻辑,使用 ModelScope 的 snapshot_download 函数- 更新 MODEL_MAP 字典,映射不同大小的模型到对应的 ModelScope 仓库
- 调整模型路径,直接使用 ModelScope 下载的路径
2025-05-08 14:42:43 +08:00
Jianwu Huang
3d9cb1aaa9 Update README.md 2025-05-08 11:25:12 +08:00
Jianwu Huang
ae92ec190a Update README.md 2025-05-08 09:18:37 +08:00
Jianwu Huang
832c0fe437 Update README.md 2025-05-06 15:32:42 +08:00
Jianwu Huang
894e34b28d Update README.md 2025-05-06 15:29:28 +08:00
Jianwu Huang
b31588e00d Update README.md 2025-05-06 14:18:02 +08:00
Jianwu Huang
d1f108041b Merge pull request #79 from JefferyHcool/deploy/docker
docs(README): 更新快速开始指南
2025-05-06 14:01:16 +08:00
黄建武
e2757a18b9 docs(README): 更新快速开始指南
- 移除了 .env 文件重命名步骤的单独命令,将其合并到一行中
-简化了端口说明,指出了默认端口为 80
- 更新了访问地址示例,使用默认端口 80
2025-05-06 14:00:29 +08:00
Jianwu Huang
051a099d5f Merge pull request #78 from JefferyHcool/deploy/docker
feat(deploy): 重构部署方案并添加 nginx 代理
2025-05-06 13:57:23 +08:00
黄建武
be4c3313d4 feat(deploy): 重构部署方案并添加 nginx 代理
- 新增 nginx 服务作为前端和后端的代理
- 重新配置前端和后端服务,不再直接暴露端口
- 更新前端 Dockerfile,简化为静态文件服务器- 在 MarkdownViewer 组件中添加 ExternalLink 图标
2025-05-06 13:56:43 +08:00
Jianwu Huang
bab61d8462 Update README.md 2025-05-06 13:16:52 +08:00
Jianwu Huang
41a79d60a5 Merge pull request #77 from JefferyHcool/feature/regenerate
feat: 更新图片路径生成逻辑
2025-05-06 13:16:38 +08:00
黄建武
0bedd7ff6f feat: 更新图片路径生成逻辑- 修改了生成截图 URL 的方式,使用相对路径替代绝对路径- 在前端 Vite 配置中添加了对 /static路径的代理设置 2025-05-06 13:13:31 +08:00
Jianwu Huang
be2a749905 Update README.md 2025-05-06 10:26:47 +08:00
Jianwu Huang
c1b1439510 Merge pull request #75 from JefferyHcool/feature/regenerate
chore:更新版本号
2025-05-04 22:19:08 +08:00
黄建武
03c950eb63 chore:更新版本号 2025-05-04 22:18:18 +08:00
Jianwu Huang
3d8981f970 Merge pull request #74 from JefferyHcool/feature/regenerate
fix(provider): 重新启用通过 ID 获取供应商信息的接口并增强安全性
2025-05-04 17:48:54 +08:00
黄建武
cbc94fafce fix(provider): 重新启用通过 ID 获取供应商信息的接口并增强安全性
- 重新启用了 /get_provider_by_id/{id}接口
- 新增了 get_provider_by_id_safe 方法,用于安全地获取供应商信息
- 将原有的 get_provider_by_id 方法重命名为 get_provider_by_id_safe
2025-05-04 17:48:15 +08:00
Jianwu Huang
d8cec22f54 Merge pull request #73 from JefferyHcool/feature/regenerate
docs(README): 更新版本号至 v1.5.0 并添加新功能说明
2025-05-04 11:04:45 +08:00
黄建武
0f40a99f70 docs(README): 更新版本号至 v1.5.0 并添加新功能说明
- 将版本号从 v1.4.0 更新为 v1.5.0
- 新增功能:支持多版本记录保留
- 修正前端目录名称拼写错误
2025-05-04 11:03:58 +08:00
Jianwu Huang
b9b0e581e7 Merge pull request #72 from JefferyHcool/feature/regenerate
Feature/regenerate: 新增多版本笔记功能,并做了向下兼容。
2025-05-04 11:03:39 +08:00
黄建武
d6b50773b9 Merge remote-tracking branch 'origin/master' into feature/regenerate 2025-05-04 11:01:16 +08:00
黄建武
97f153646f feat(frontend): 新增多版本笔记功能,并做了向下兼容。
- 新增关于页面组件,介绍项目背景、功能和使用方法
- 重构笔记生成逻辑,支持多版本笔记
- 新增笔记版本选择、复制和导出功能
-优化笔记界面布局和交互
- 调整部分组件样式,提升用户体验
2025-05-04 11:00:54 +08:00
Jianwu Huang
6ea9023558 Merge pull request #71 from scdotbox/master
Update video_reader.py
2025-05-03 15:22:44 +08:00
scdotbox
c0746aab57 Update video_reader.py
增加本地视频下载文件的检查
2025-05-03 15:20:04 +08:00
Jianwu Huang
c492f0780b Delete .idea directory 2025-05-03 12:10:28 +08:00
Jianwu Huang
bf9098db3c Merge pull request #68 from JefferyHcool/ui/markdown_perf
feat(MarkdownViewer):增强 Markdown 解析和渲染能力
2025-05-03 02:25:54 +08:00
黄建武
0e055b34ca feat(MarkdownViewer):增强 Markdown 解析和渲染能力
- 添加对 GFM (GitHub Flavored Markdown) 的支持
- 增加数学公式渲染功能
- 实现加粗编号标题的特殊处理
- 优化代码块样式
- 添加图片缩放功能
2025-05-03 02:24:56 +08:00
Jianwu Huang
5fbf84fc36 Merge pull request #67 from JefferyHcool/feature/video_read
feat(note): 添加视频理解功能- 在 GPT 模型中增加 video_img_urls 字段用于存储视频截图
- 在笔记生成请求中添加视频理解相关参数
- 实现视频截图功能,支持按指定间隔生成截图
- 更新笔记生成逻辑,支持视频理解功能- 在前端服务中添加视频理解相
2025-05-03 00:03:44 +08:00
黄建武
bb64936a38 docs(README): 更新项目版本和功能说明
- 将 BiliNote 版本号从 v1.3.0 更新为 v1.4.0
- 新增多模态视频理解功能说明- 添加代码参考部分,说明抖音下载功能的代码来源
-增加 Star History 图标和相关说明
2025-05-03 00:01:02 +08:00
黄建武
749635e156 Merge remote-tracking branch 'origin/master' into feature/video_read
# Conflicts:
#	backend/requirements.txt
2025-05-02 23:54:05 +08:00
黄建武
6e084f720d feat(note): 添加视频理解功能- 在 GPT 模型中增加 video_img_urls 字段用于存储视频截图
- 在笔记生成请求中添加视频理解相关参数
- 实现视频截图功能,支持按指定间隔生成截图
- 更新笔记生成逻辑,支持视频理解功能- 在前端服务中添加视频理解相关参数
2025-05-02 23:47:15 +08:00
163 changed files with 34268 additions and 1827 deletions

35
.dockerignore Normal file
View File

@@ -0,0 +1,35 @@
# Git 和 IDE
.git
.github
.idea/
.vscode/
.DS_Store
# Tauri 构建产物(非常大)
BillNote_frontend/src-tauri/target
BillNote_frontend/src-tauri/bin
# 运行时数据
backend/data
backend/static
backend/models
backend/logs
backend/uploads
backend/*.db
backend/note_results
backend/bin/
# 依赖和构建缓存
node_modules/
__pycache__/
*.py[cod]
dist/
build/
*.tar
*.egg-info/
# 环境文件
.env
.env.local
.env.*.local
!.env.example

View File

@@ -1,32 +1,24 @@
###
# @Author: 思诺特 jefferyhcool@gmail.com
# @Date: 2025-04-14 08:49:59
# @LastEditors: 黄建武 huangjianwu@ynyuc.com
# @LastEditTime: 2025-05-02 14:04:11
# @FilePath: \BiliNote\.env.example
# @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
###
# 通用端口配置
BACKEND_PORT=8001
BACKEND_PORT=8483 # 后端端口
FRONTEND_PORT=3015
BACKEND_HOST=0.0.0.0 # 默认为 0.0.0.0,表示监听所有 IP 地址 不建议动
# 前端访问后端用(生产环境建议写公网或宿主机 IP
VITE_API_BASE_URL=http://127.0.0.1:8001
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8001/static/screenshots
APP_PORT= 3015 # docker 部署时用
# 前端访问后端用 (开发环境使用)
VITE_API_BASE_URL=http://127.0.0.1:8000
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8483/static/screenshots
VITE_FRONTEND_PORT=3015
# 生产环境配置
ENV=production
STATIC=/static
OUT_DIR=./static/screenshots
NOTE_OUTPUT_DIR=note_results
IMAGE_BASE_URL=/static/screenshots
DATA_DIR=data
# FFMPEG 配置
FFMPEG_BIN_PATH=
# transcriber 相关配置
TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台)
WHISPER_MODEL_SIZE=base
TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台)/groq
WHISPER_MODEL_SIZE=medium
#抖音Cookie设置
DOUYIN_COOKIES=
GROQ_TRANSCRIBER_MODEL=whisper-large-v3-turbo # groq提供的faster-whisper 默认为 whisper-large-v3-turbo

73
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Build and Publish Docker Image
on:
push:
tags:
- 'v*'
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and Push Docker Image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.complete
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
- name: Generate Usage Instructions
run: |
echo "=========================================="
echo "Docker Image Published!"
echo "=========================================="
echo ""
echo "Pull the image:"
echo " docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
echo ""
echo "Run the container:"
echo " docker run -d -p 80:80 \\"
echo " -v bilinote-data:/app/backend/data \\"
echo " --name bilinote \\"
echo " ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
echo ""
echo "Access the application at: http://localhost"
echo "=========================================="

160
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,160 @@
name: Build & Release Desktop App
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
strategy:
matrix:
include:
- platform: macos-latest
target: universal-apple-darwin
- platform: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- platform: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout Code
uses: actions/checkout@v4
# Linux 系统依赖Tauri 需要)
- name: Install Linux Dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
# 设置 Python 环境(带 pip 缓存)
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
cache-dependency-path: backend/requirements.txt
# 安装 Python 依赖并执行构建
- name: Install Python dependencies & Build backend
shell: bash
run: |
python -m pip install --upgrade pip
pip install -r backend/requirements.txt
if [ "$RUNNER_OS" = "Windows" ]; then
backend\\build.bat
else
chmod +x backend/build.sh
./backend/build.sh
fi
# 设置 pnpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 'latest'
# 设置 Node 环境
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install frontend dependencies
working-directory: BillNote_frontend
run: pnpm install
# 设置 Rust 环境
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
# Cargo 缓存
- name: Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
BillNote_frontend/src-tauri/target/
key: ${{ runner.os }}-cargo-${{ hashFiles('BillNote_frontend/src-tauri/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
# 打包 Tauri 应用
- name: Build Tauri App
working-directory: BillNote_frontend
run: pnpm tauri build
# 收集产物到统一目录
- name: Collect release artifacts
shell: bash
run: |
mkdir -p release-artifacts
BUNDLE_DIR="BillNote_frontend/src-tauri/target/release/bundle"
# macOS: .dmg
find "$BUNDLE_DIR" -name "*.dmg" -exec cp {} release-artifacts/ \; 2>/dev/null || true
# Windows: .msi, .exe (NSIS)
find "$BUNDLE_DIR" -name "*.msi" -exec cp {} release-artifacts/ \; 2>/dev/null || true
find "$BUNDLE_DIR/nsis" -name "*.exe" -exec cp {} release-artifacts/ \; 2>/dev/null || true
# Linux: .deb, .AppImage
find "$BUNDLE_DIR" -name "*.deb" -exec cp {} release-artifacts/ \; 2>/dev/null || true
find "$BUNDLE_DIR" -name "*.AppImage" -exec cp {} release-artifacts/ \; 2>/dev/null || true
echo "=== Collected artifacts ==="
ls -lh release-artifacts/
# 生成 SHA256 校验和
- name: Generate checksums
shell: bash
run: |
cd release-artifacts
sha256sum * > SHA256SUMS.txt 2>/dev/null || shasum -a 256 * > SHA256SUMS.txt
cat SHA256SUMS.txt
# 上传产物(供 release job 使用)
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: artifacts-${{ matrix.platform }}
path: release-artifacts/
# 创建 GitHub Release 并上传所有产物
release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Checkout Code
uses: actions/checkout@v4
# 下载所有平台的构建产物
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: all-artifacts
merge-multiple: true
- name: List all artifacts
run: |
echo "=== All release artifacts ==="
ls -lhR all-artifacts/
# 创建 Release 并上传产物
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: BiliNote ${{ github.ref_name }}
draft: false
prerelease: false
generate_release_notes: true
files: all-artifacts/*

9
.gitignore vendored
View File

@@ -319,4 +319,11 @@ cython_debug/
/backend/bili_note.db
/backend/uploads/*
/backend/.idea/*
/BiliNote_frontend/.idea/*
/backend/config/*
/backend/vector_db/
/BiliNote_frontend/.idea/*
/BiliNote_frontend/src-tauri/bin/
# FFmpeg 构建文件(不应该提交到仓库)
ffmpeg*/
ffmpg*/

8
.idea/.gitignore generated vendored
View File

@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

1
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://127.0.0.1:8483/api
VITE_PLATFORM=tauri

View File

@@ -22,4 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
/pnpm-lock.yaml
/src-tauri/bin/

View File

@@ -1,32 +1,23 @@
# === 前端构建阶段 ===
FROM node:18-alpine AS build
FROM node:18-alpine AS builder
# 安装 pnpm
RUN npm install -g pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# 设置工作目录
WORKDIR /app
# 拷贝前端源码
COPY ./BillNote_frontend /app
# 先复制 lockfile 利用依赖层缓存
COPY ./BillNote_frontend/package.json ./BillNote_frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# 安装依赖并构建
RUN pnpm install && pnpm run build
# 再复制源代码并构建
COPY ./BillNote_frontend/ ./
RUN pnpm run build
# === nginx 运行阶段 ===
FROM nginx:alpine
# --- 阶段2使用 nginx 作为静态服务器 ---
FROM nginx:1.25-alpine
# 拷贝模板配置
COPY ./BillNote_frontend/deploy/default.conf.template /etc/nginx/templates/default.conf.template
RUN rm -rf /etc/nginx/conf.d/default.conf
COPY ./BillNote_frontend/deploy/default.conf /etc/nginx/conf.d/default.conf
# 拷贝构建产物
COPY --from=build /app/dist /usr/share/nginx/html
# 拷贝启动脚本
COPY ./BillNote_frontend/deploy/start.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 80
# 使用启动脚本启动容器
CMD ["/start.sh"]
COPY --from=builder /app/dist /usr/share/nginx/html

View File

@@ -0,0 +1,11 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
}

18705
BillNote_frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/x": "^2.4.0",
"@hookform/resolvers": "^5.0.1",
"@lobehub/icons": "^1.97.1",
"@lobehub/icons-static-svg": "^1.45.0",
@@ -21,38 +22,53 @@
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.1.3",
"@tauri-apps/plugin-shell": "~2.2.2",
"@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",
"fuse.js": "^7.1.0",
"github-markdown-css": "^5.8.1",
"katex": "^0.16.21",
"jszip": "^3.10.1",
"katex": "^0.16.22",
"lottie-react": "^2.4.1",
"lucide-react": "^0.487.0",
"markdown-navbar": "^1.4.3",
"markmap-common": "^0.18.9",
"markmap-lib": "^0.18.11",
"markmap-toolbar": "^0.18.10",
"markmap-view": "^0.18.10",
"next-themes": "^0.4.6",
"pinyin-match": "^1.2.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.55.0",
"react-hot-toast": "^2.5.2",
"react-markdown": "^10.1.0",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^8.0.7",
"react-medium-image-zoom": "^5.2.14",
"react-resizable-panels": "^2.1.8",
"react-router-dom": "^7.5.1",
"react-syntax-highlighter": "^15.6.1",
"remark-gfm": "1.0.0",
"rehype-katex": "^6.0.2",
"remark-gfm": "3.0.1",
"remark-math": "^5.1.1",
"sonner": "^2.0.3",
"tailwind-merge": "^3.1.0",
"tailwindcss": "^4.1.3",
"tw-animate-css": "^1.2.5",
"uuid": "^11.1.0",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@tailwindcss/postcss": "^4.1.3",
"@tauri-apps/cli": "^2.5.0",
"@types/node": "^22.14.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View File

@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

5027
BillNote_frontend/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.2.0", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.5.0", features = ["devtools"] }
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2"
[package.metadata.tauri.bundle.macOS]
frameworks = ["bin/BiliNoteBackend/_internal/"]

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,19 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main"],
"permissions": [
"core:default",
{
"identifier": "shell:allow-execute",
"allow": [
{
"name": "BiliNoteBackend",
"sidecar": true
}
]
},
"shell:allow-open"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,285 @@
use tauri::{Manager, Emitter};
use tauri_plugin_shell::ShellExt;
use tauri_plugin_shell::process::CommandEvent;
use std::env;
use std::collections::HashMap;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
let exe_path = env::current_exe().expect("无法获取当前可执行文件路径");
let sidecar_dir = exe_path.parent().expect("无法获取可执行文件的父目录");
// 收集所有系统环境变量
let mut all_env_vars = HashMap::new();
for (key, value) in env::vars() {
all_env_vars.insert(key, value);
}
// 增强 PATH 环境变量,添加常见的二进制路径
let current_path = all_env_vars.get("PATH").cloned().unwrap_or_default();
let additional_paths = get_additional_binary_paths();
let enhanced_path = enhance_path_variable(&current_path, &additional_paths);
all_env_vars.insert("PATH".to_string(), enhanced_path);
// 打印一些关键环境变量用于调试
println!("Enhanced PATH: {}", all_env_vars.get("PATH").unwrap_or(&"Not found".to_string()));
println!("Total environment variables: {}", all_env_vars.len());
// 检查 ffmpeg 是否在 PATH 中可用
check_ffmpeg_availability();
// 启动 Python 后端侧车
let mut sidecar_command = app.shell().sidecar("BiliNoteBackend").unwrap();
// 设置所有环境变量到 sidecar
for (key, value) in &all_env_vars {
sidecar_command = sidecar_command.env(key, value);
}
let (mut rx, _child) = sidecar_command
.current_dir(sidecar_dir)
.spawn()
.expect("Failed to spawn sidecar");
// 获取主窗口句柄用于发送事件
let window = app.get_webview_window("main").unwrap();
tauri::async_runtime::spawn(async move {
// 读取诸如 stdout 之类的事件
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => {
let output = String::from_utf8_lossy(&line);
println!("Backend stdout: {}", output);
// 发送到前端
window
.emit("backend-message", Some(format!("'{}'", output)))
.expect("failed to emit event");
}
CommandEvent::Stderr(line) => {
let error = String::from_utf8_lossy(&line);
eprintln!("Backend stderr: {}", error);
window
.emit("backend-error", Some(format!("'{}'", error)))
.expect("failed to emit event");
}
CommandEvent::Terminated(payload) => {
println!("Backend terminated with code: {:?}", payload.code);
window
.emit("backend-terminated", Some(payload.code))
.expect("failed to emit event");
break;
}
_ => {
println!("Backend event: {:?}", event);
}
}
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_system_env_vars,
find_executable_path,
run_command_with_env,
test_ffmpeg_access
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// 获取额外的二进制路径
fn get_additional_binary_paths() -> Vec<String> {
if cfg!(target_os = "windows") {
vec![
"C:\\ffmpeg\\bin".to_string(),
"C:\\Program Files\\ffmpeg\\bin".to_string(),
"C:\\Program Files (x86)\\ffmpeg\\bin".to_string(),
"C:\\tools\\ffmpeg\\bin".to_string(),
"C:\\ProgramData\\chocolatey\\bin".to_string(),
]
} else if cfg!(target_os = "macos") {
vec![
"/usr/local/bin".to_string(),
"/opt/homebrew/bin".to_string(),
"/usr/bin".to_string(),
"/bin".to_string(),
"/opt/local/bin".to_string(), // MacPorts
]
} else {
vec![
"/usr/local/bin".to_string(),
"/usr/bin".to_string(),
"/bin".to_string(),
"/snap/bin".to_string(),
"/opt/bin".to_string(),
"/usr/local/sbin".to_string(),
]
}
}
// 增强 PATH 环境变量
fn enhance_path_variable(current_path: &str, additional_paths: &[String]) -> String {
let path_separator = if cfg!(target_os = "windows") { ";" } else { ":" };
let mut paths: Vec<String> = additional_paths.to_vec();
// 添加当前 PATH
if !current_path.is_empty() {
paths.push(current_path.to_string());
}
paths.join(path_separator)
}
// 检查 ffmpeg 可用性
fn check_ffmpeg_availability() {
use std::process::Command;
match Command::new("ffmpeg").arg("-version").output() {
Ok(output) => {
if output.status.success() {
println!("✓ FFmpeg is available in PATH");
let version_info = String::from_utf8_lossy(&output.stdout);
let first_line = version_info.lines().next().unwrap_or("Unknown version");
println!("FFmpeg version: {}", first_line);
} else {
println!("✗ FFmpeg found but returned error");
}
}
Err(e) => {
println!("✗ FFmpeg not found in PATH: {}", e);
// 尝试在常见路径中查找
let common_paths = get_additional_binary_paths();
for path in common_paths {
let ffmpeg_path = if cfg!(target_os = "windows") {
format!("{}\\ffmpeg.exe", path)
} else {
format!("{}/ffmpeg", path)
};
if std::path::Path::new(&ffmpeg_path).exists() {
println!("✓ Found FFmpeg at: {}", ffmpeg_path);
return;
}
}
println!("✗ FFmpeg not found in common installation paths");
}
}
}
// Tauri 命令:获取系统环境变量
#[tauri::command]
fn get_system_env_vars() -> HashMap<String, String> {
env::vars().collect()
}
// Tauri 命令:查找可执行文件路径
#[tauri::command]
fn find_executable_path(executable_name: String) -> Option<String> {
use std::process::Command;
// 首先尝试直接执行
if Command::new(&executable_name).arg("--version").output().is_ok() {
return Some(executable_name);
}
// 使用 which/where 命令查找
let which_cmd = if cfg!(target_os = "windows") { "where" } else { "which" };
if let Ok(output) = Command::new(which_cmd).arg(&executable_name).output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(path);
}
}
}
// 在常见路径中搜索
let common_paths = get_additional_binary_paths();
for base_path in common_paths {
let executable_path = if cfg!(target_os = "windows") {
format!("{}\\{}.exe", base_path, executable_name)
} else {
format!("{}/{}", base_path, executable_name)
};
if std::path::Path::new(&executable_path).exists() {
return Some(executable_path);
}
}
None
}
// Tauri 命令:使用完整环境变量运行命令
#[tauri::command]
async fn run_command_with_env(
program: String,
args: Vec<String>
) -> Result<String, String> {
use std::process::Command;
let mut cmd = Command::new(&program);
cmd.args(&args);
// 设置所有环境变量
for (key, value) in env::vars() {
cmd.env(key, value);
}
// 增强 PATH
let current_path = env::var("PATH").unwrap_or_default();
let additional_paths = get_additional_binary_paths();
let enhanced_path = enhance_path_variable(&current_path, &additional_paths);
cmd.env("PATH", enhanced_path);
match cmd.output() {
Ok(output) => {
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
Err(e) => Err(format!("Failed to execute {}: {}", program, e))
}
}
// Tauri 命令:测试 ffmpeg 访问
#[tauri::command]
async fn test_ffmpeg_access() -> Result<String, String> {
run_command_with_env("ffmpeg".to_string(), vec!["-version".to_string()]).await
}
// 可选:添加一个函数来动态更新 sidecar 的环境变量
#[tauri::command]
async fn update_sidecar_environment(
app_handle: tauri::AppHandle,
additional_env_vars: HashMap<String, String>
) -> Result<(), String> {
// 这个函数可以用来在运行时更新环境变量
// 注意:这需要重启 sidecar 才能生效
for (key, value) in additional_env_vars {
env::set_var(key, value);
}
Ok(())
}

View File

@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}

View File

@@ -0,0 +1,46 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "BiliNote",
"version": "2.0.0",
"identifier": "com.jefferyhuang.bilinote",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:3015",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build --mode tauri "
},
"app": {
"windows": [
{
"title": "BiliNote",
"width": 1600,
"height": 1000,
"resizable": true,
"fullscreen": false,
"devtools": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"externalBin": [
"bin/BiliNoteBackend/BiliNoteBackend"
],
"resources": {
"bin/BiliNoteBackend/_internal":"_internal"
},
"macOS":{
"files": {
"Frameworks": "bin/BiliNoteBackend/_internal"
}
},
"active": true,
"targets": "all",
"icon": [
"icons/icon.ico",
"icons/icon.png"
]
}
}

View File

@@ -1,44 +1,70 @@
import './App.css'
import { HomePage } from './pages/HomePage/Home.tsx'
import { lazy, Suspense, useEffect } from 'react'
import { BrowserRouter, Navigate, Routes, Route } from 'react-router-dom'
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 { useCheckBackend } from '@/hooks/useCheckBackend.ts'
import { systemCheck } from '@/services/system.ts'
import BackendInitDialog from '@/components/BackendInitDialog'
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'
import { HomePage } from './pages/HomePage/Home.tsx'
// 非首屏页面使用 React.lazy 按需加载
const SettingPage = lazy(() => import('./pages/SettingPage/index.tsx'))
const Model = lazy(() => import('@/pages/SettingPage/Model.tsx'))
const ProviderForm = lazy(() => import('@/components/Form/modelForm/Form.tsx'))
const AboutPage = lazy(() => import('@/pages/SettingPage/about.tsx'))
const Monitor = lazy(() => import('@/pages/SettingPage/Monitor.tsx'))
const Downloader = lazy(() => import('@/pages/SettingPage/Downloader.tsx'))
const DownloaderForm = lazy(() => import('@/components/Form/DownloaderForm/Form.tsx'))
const TranscriberPage = lazy(() => import('@/pages/SettingPage/transcriber.tsx'))
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
function App() {
useTaskPolling(3000) // 每 3 秒轮询一次
const steps = [
{ label: '解析链接', key: 'PARSING', icon: <Downloading /> },
{ label: '下载音频', key: 'DOWNLOADING' },
{ label: '转写文字', key: 'TRANSCRIBING' },
{ label: '总结内容', key: 'SUMMARIZING' },
{ label: '保存完成', key: 'SUCCESS' },
]
const { loading, initialized } = useCheckBackend()
// 在后端初始化完成后执行系统检查
useEffect(() => {
if (initialized) {
systemCheck()
}
}, [initialized])
// 如果后端还未初始化,显示初始化对话框
if (!initialized) {
return (
<>
<BackendInitDialog open={loading} />
</>
)
}
// 后端已初始化,渲染主应用
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 />} />
<Suspense fallback={<div className="flex h-screen items-center justify-center"></div>}>
<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 path=":id" element={<ProviderForm />} />
</Route>
<Route path="download" element={<Downloader />}>
<Route path=":id" element={<DownloaderForm />} />
</Route>
<Route path="transcriber" element={<TranscriberPage />} />
<Route path="monitor" element={<Monitor />}></Route>
<Route path="about" element={<AboutPage />}></Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="transcriber" elment={<Transcriber />}></Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</Routes>
</Suspense>
</BrowserRouter>
</>
)

View File

@@ -0,0 +1,12 @@
<svg width="415" height="412" viewBox="0 0 415 412" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 28C0 12.536 12.536 0 28 0H387C402.464 0 415 12.536 415 28V384C415 399.464 402.464 412 387 412H28C12.536 412 0 399.464 0 384V28Z" fill="#3C77FB"/>
<rect x="60" y="64" width="296" height="283" rx="37" fill="white"/>
<path d="M268.422 175.657C276.308 180.298 276.308 191.702 268.422 196.343L186.335 244.641C178.336 249.348 168.25 243.58 168.25 234.298V137.702C168.25 128.42 178.336 122.652 186.335 127.359L268.422 175.657Z" fill="#3C77FB"/>
<path d="M17 282C17 270.954 25.9543 262 37 262H83C94.0457 262 103 270.954 103 282V282C103 293.046 94.0457 302 83 302H37C25.9543 302 17 293.046 17 282V282Z" fill="#3C77FB"/>
<path d="M38 281.5C38 274.044 44.0442 268 51.5 268H82.5C89.9558 268 96 274.044 96 281.5V281.5C96 288.956 89.9558 295 82.5 295H51.5C44.0442 295 38 288.956 38 281.5V281.5Z" fill="white"/>
<path d="M17 206C17 194.954 25.9543 186 37 186H83C94.0457 186 103 194.954 103 206V206C103 217.046 94.0457 226 83 226H37C25.9543 226 17 217.046 17 206V206Z" fill="#3C77FB"/>
<path d="M38 205.5C38 198.044 44.0442 192 51.5 192H82.5C89.9558 192 96 198.044 96 205.5V205.5C96 212.956 89.9558 219 82.5 219H51.5C44.0442 219 38 212.956 38 205.5V205.5Z" fill="white"/>
<path d="M17 130C17 118.954 25.9543 110 37 110H83C94.0457 110 103 118.954 103 130V130C103 141.046 94.0457 150 83 150H37C25.9543 150 17 141.046 17 130V130Z" fill="#3C77FB"/>
<path d="M38 129.5C38 122.044 44.0442 116 51.5 116H82.5C89.9558 116 96 122.044 96 129.5V129.5C96 136.956 89.9558 143 82.5 143H51.5C44.0442 143 38 136.956 38 129.5V129.5Z" fill="white"/>
<path d="M145 290C145 285.582 148.582 282 153 282H284C288.418 282 292 285.582 292 290V299C292 303.418 288.418 307 284 307H153C148.582 307 145 303.418 145 299V290Z" fill="#3C77FB"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,23 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Loader2 } from 'lucide-react'
interface Props {
open: boolean
}
function BackendInitDialog({ open }: Props) {
return (
<Dialog open={open}>
<DialogContent className="text-center">
<DialogHeader>
<DialogTitle className="flex items-center justify-center gap-2">
<Loader2 className="animate-spin w-5 h-5" />
</DialogTitle>
</DialogHeader>
<p className="text-muted-foreground mt-2">,</p>
</DialogContent>
</Dialog>
)
}
export default BackendInitDialog

View File

@@ -0,0 +1,95 @@
// 下载器 Cookie 设置表单(最简化版)
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { getDownloaderCookie, updateDownloaderCookie } from '@/services/downloader' // 你自定义的请求
import { useParams } from 'react-router-dom'
import { videoPlatforms } from '@/constant/note.ts'
const CookieSchema = z.object({
cookie: z.string().min(10, '请填写有效 Cookie'),
})
const DownloaderForm = () => {
const form = useForm({
resolver: zodResolver(CookieSchema),
defaultValues: { cookie: '' },
})
const { id } = useParams()
const [loading, setLoading] = useState(true)
useEffect(() => {
const loadCookie = async () => {
setLoading(true) // 🔁 切换平台时显示 loading
try {
const res = await getDownloaderCookie(id)
const cookie = res?.cookie || ''
form.reset({ cookie }) // ✅ 正确重置表单值
} catch (e) {
toast.error('加载 Cookie 失败: ' + e)
form.reset({ cookie: '' }) // ❗失败时也要清空旧值
} finally {
setLoading(false)
}
}
if (id) loadCookie()
}, [id]) // 🔁 每当 id 变化时触发
const onSubmit = async values => {
try {
await updateDownloaderCookie({
platform: id,
cookie: String(values.cookie),
})
toast.success('保存成功')
} catch (e) {
toast.error('保存失败')
}
}
if (loading) return <div className="p-4">...</div>
return (
<div className="max-w-xl p-4">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
<div className="text-lg font-bold">
{videoPlatforms.find(item => item.value === id)?.label} Cookie
</div>
<FormField
control={form.control}
name="cookie"
render={({ field }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel>Cookie</FormLabel>
<FormControl>
<Input {...field} placeholder="输入 Cookie" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit"></Button>
</form>
</Form>
</div>
)
}
export default DownloaderForm

View File

@@ -0,0 +1,34 @@
import ProviderCard from '@/components/Form/DownloaderForm/providerCard.tsx'
import { Button } from '@/components/ui/button.tsx'
import { useProviderStore } from '@/store/providerStore'
import { useNavigate } from 'react-router-dom'
import { DouyinLogo, KuaishouLogo } from '@/components/Icons/platform.tsx'
import { videoPlatforms } from '@/constant/note.ts'
const Provider = () => {
const navigate = useNavigate()
const handleClick = () => {
navigate(`/settings/model/new`)
}
return (
<div className="flex flex-col gap-2">
<div className="text-sm font-light"></div>
<div>
{videoPlatforms &&
videoPlatforms.map((provider, index) => {
if (provider.value !== 'local')
return (
<ProviderCard
key={index}
providerName={provider.label}
Icon={provider?.logo}
id={provider.value}
/>
)
})}
</div>
</div>
)
}
export default Provider

View File

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

View File

@@ -0,0 +1,42 @@
import { Switch } from '@/components/ui/switch.tsx'
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: any
}
const ProviderCard: FC<IProviderCardProps> = ({ providerName, Icon, id }: IProviderCardProps) => {
const navigate = useNavigate()
const updateProvider = useProviderStore(state => state.updateProvider)
const handleClick = () => {
navigate(`/settings/download/${id}`)
}
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 gap-2 text-lg">
<div className="flex h-6 w-6 items-center">{<Icon></Icon>}</div>
<div className="font-semibold">{providerName}</div>
</div>
</div>
)
}
export default ProviderCard

View File

@@ -16,7 +16,7 @@ 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 { testConnection, fetchModels, deleteModelById } from '@/services/model.ts'
import {
Select,
SelectContent,
@@ -26,6 +26,9 @@ import {
} from '@/components/ui/select.tsx' // ⚡新增 fetchModels
import { ModelSelector } from '@/components/Form/modelForm/ModelSelector.tsx'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert.tsx'
import { Tags } from 'lucide-react'
import { X } from 'lucide-react'
import { useModelStore } from '@/store/modelStore'
// ✅ Provider表单schema
const ProviderSchema = z.object({
@@ -52,7 +55,7 @@ interface IModel {
root: string
}
const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
const { id } = useParams()
let { id } = useParams()
const navigate = useNavigate()
const isEditMode = !isCreate
@@ -60,12 +63,16 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
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 loadModelsById= useModelStore(state => state.loadModelsById)
const [modelOptions, setModelOptions] = useState<IModel[]>([]) // ⚡新增,保存模型列表
const [models, setModels]= useState([])
const [modelLoading, setModelLoading] = useState(false)
const randomColor = ()=>{
return '#' + Math.floor(Math.random() * 16777215).toString(16)
}
const [search, setSearch] = useState('')
const providerForm = useForm<ProviderFormValues>({
@@ -91,8 +98,10 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
})
useEffect(() => {
const load = async () => {
if (isEditMode) {
const data = await loadProviderById(id!)
providerForm.reset(data)
setIsBuiltIn(data.type === 'built-in')
@@ -105,11 +114,29 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
})
setIsBuiltIn(false)
}
const models = await loadModelsById(id!)
if(models){
console.log('🔧 模型列表:', models)
setModels(models)
}
setLoading(false)
}
load()
}, [id])
const handelDelete=async (modelId)=>{
if (!window.confirm('确定要删除这个模型吗?')) return
try {
const res = await deleteModelById(modelId)
console.log('🔧 删除结果:', res)
toast.success('删除成功')
} catch (e) {
toast.error('删除异常')
}
}
// 测试连通性
const handleTest = async () => {
const values = providerForm.getValues()
@@ -118,18 +145,21 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
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 || '未知错误'}`)
if (!id){
toast.error('请先保存供应商信息')
return
}
setTesting(true)
await testConnection({
id
})
toast.success('测试连通性成功 🎉')
} catch (error) {
toast.error('测试连通性异常')
toast.error(`连接失败: ${data.data.msg || '未知错误'}`)
// toast.error('测试连通性异常')
} finally {
setTesting(false)
}
@@ -162,18 +192,21 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
// 保存Provider信息
const onProviderSubmit = async (values: ProviderFormValues) => {
if (isEditMode) {
updateProvider({ ...values, id: id! })
await updateProvider({ ...values, id: id! })
toast.success('更新供应商成功')
} else {
addNewProvider({ ...values })
id = await addNewProvider({ ...values })
toast.success('新增供应商成功')
}
// 刷新页面
}
// 保存Model信息
const onModelSubmit = async (values: ModelFormValues) => {
console.log('🔧 选择的模型:', values.modelName)
toast.success(`保存模型: ${values.modelName}`)
await loadModelsById(id!)
}
if (loading) return <div className="p-4">...</div>
@@ -267,6 +300,32 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
</div>
<ModelSelector providerId={id!} />
{/*<datalist id="model-options">*/}
{/* {modelOptions.map(model => (*/}
{/* <option key={model.id + '1'} value={model.id} />*/}
{/* ))}*/}
{/*</datalist>*/}
</div>
<div className="flex flex-col gap-2">
<span className="font-bold"></span>
<div className={'flex flex-wrap gap-2 rounded p-2.5'}>
{
models && models.map(model => {
return (
<span key={model.id} className="inline-flex items-center gap-1 rounded-md bg-blue-100 px-2 py-0.5 text-sm text-blue-700">
{model.model_name}
<button type="button" onClick={() => handelDelete(model.id)} className="hover:text-blue-900">
<X className="h-3 w-3" />
</button>
</span>
)
})
}
</div>
{/*<ModelSelector providerId={id!} />*/}
{/*<datalist id="model-options">*/}
{/* {modelOptions.map(model => (*/}
{/* <option key={model.id + '1'} value={model.id} />*/}

View File

@@ -76,8 +76,8 @@ export function ModelSelector({ providerId }: ModelSelectorProps) {
className="h-8"
/>
</div>
{filteredModels.map(model => (
<SelectItem key={model.id} value={model.id}>
{filteredModels.map((model, index) => (
<SelectItem key={`${model.id}-${index}`} value={model.id}>
{model.id}
</SelectItem>
))}

View File

@@ -0,0 +1,168 @@
export const KuaishouLogo = () => {
return (
<svg
t="1746695310517"
className="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="1680"
width="200"
height="200"
>
<path
d="M299.27936 624.43008v87.48544c0 14.64832 10.70592 21.24288 23.78752 14.65856l83.49696-42.01984v-32.76288L323.072 609.7664c-13.08672-6.58432-23.79264 0.01536-23.79264 14.66368zM654.42304 436.03456c36.72064 0 66.59584-29.87008 66.59584-66.59072s-29.8752-66.59584-66.59584-66.59584c-36.71552 0-66.5856 29.8752-66.5856 66.59584s29.87008 66.59072 66.5856 66.59072zM443.56096 435.65056c47.73376 0 86.56384-38.8352 86.56384-86.56896s-38.83008-86.56896-86.56384-86.56896-86.56896 38.8352-86.56896 86.56896 38.8352 86.56896 86.56896 86.56896z"
fill="#FF4A08"
p-id="1681"
></path>
<path
d="M849.92 51.2H174.08c-67.8656 0-122.88 55.0144-122.88 122.88v675.84c0 67.8656 55.0144 122.88 122.88 122.88h675.84c67.8656 0 122.88-55.0144 122.88-122.88V174.08c0-67.8656-55.0144-122.88-122.88-122.88zM443.56096 204.8c54.05184 0 101.22752 29.89056 125.93664 73.99936 22.24128-20.85376 52.11136-33.664 84.93056-33.664 68.54656 0 124.30848 55.76704 124.30848 124.30848s-55.76704 124.30336-124.30848 124.30336c-41.40544 0-78.12608-20.37248-100.73088-51.60448-26.48576 31.29856-66.01728 51.22048-110.13632 51.22048-79.55968 0-144.2816-64.72704-144.2816-144.2816S364.00128 204.8 443.56096 204.8z m336.65536 505.63584c0 59.97568-48.78848 108.76416-108.76416 108.76416H515.328c-47.05792 0-87.22432-30.04416-102.34368-71.96672l-87.81824 42.40384c-9.43616 4.5568-18.97984 6.8608-28.37504 6.8608h-0.00512c-30.70976 0-53.00224-24.3712-53.00224-57.9328v-140.5696c0-33.57696 22.29248-57.94304 53.00736-57.94304 9.3952 0 18.93888 2.30912 28.36992 6.86592l87.59808 42.29632c14.93504-42.26048 55.26528-72.63232 102.56896-72.63232h156.11904c59.97568 0 108.76416 48.7936 108.76416 108.76928v85.08416z"
fill="#FF4A08"
p-id="1682"
></path>
<path
d="M671.45216 574.28992H515.328c-28.14976 0-51.05664 22.90688-51.05664 51.05664v85.08928c0 28.14976 22.90688 51.05664 51.05664 51.05664h156.11904c28.14976 0 51.05664-22.90688 51.05664-51.05664v-85.08928c0-28.14976-22.90176-51.05664-51.05152-51.05664z"
fill="#FF4A08"
p-id="1683"
></path>
</svg>
)
}
export const DouyinLogo = () => {
return (
<svg
t="1746695428425"
className="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="2731"
width="200"
height="200"
>
<path
d="M0 0m184.32 0l655.36 0q184.32 0 184.32 184.32l0 655.36q0 184.32-184.32 184.32l-655.36 0q-184.32 0-184.32-184.32l0-655.36q0-184.32 184.32-184.32Z"
fill="#111111"
p-id="2732"
></path>
<path
d="M204.27776 670.59712a246.25152 246.25152 0 0 1 245.97504-245.97504v147.57888a98.49856 98.49856 0 0 0-98.38592 98.38592c0 48.34304 26.14272 100.352 83.54816 100.352 3.81952 0 93.55264-0.88064 93.55264-77.19936V134.35904h157.26592a133.31456 133.31456 0 0 0 133.12 132.99712l-0.13312 147.31264a273.152 273.152 0 0 1-142.62272-38.912l-0.06144 317.98272c0 146.00192-124.24192 224.77824-241.14176 224.77824-131.74784 0.03072-231.1168-106.56768-231.1168-247.92064z"
fill="#FF4040"
p-id="2733"
></path>
<path
d="M164.92544 631.23456a246.25152 246.25152 0 0 1 245.97504-245.97504v147.57888a98.49856 98.49856 0 0 0-98.38592 98.38592c0 48.34304 26.14272 100.352 83.54816 100.352 3.81952 0 93.55264-0.88064 93.55264-77.19936V94.99648h157.26592a133.31456 133.31456 0 0 0 133.12 132.99712l-0.13312 147.31264a273.152 273.152 0 0 1-142.62272-38.912l-0.06144 317.98272c0 146.00192-124.24192 224.77824-241.14176 224.77824-131.74784 0.03072-231.1168-106.56768-231.1168-247.92064z"
fill="#00F5FF"
p-id="2734"
></path>
<path
d="M410.91072 427.58144c-158.8224 20.15232-284.44672 222.72-154.112 405.00224 120.40192 98.47808 373.68832 41.20576 380.70272-171.85792l-0.17408-324.1472a280.7296 280.7296 0 0 0 142.88896 38.62528V261.2224a144.98816 144.98816 0 0 1-72.8064-54.82496 135.23968 135.23968 0 0 1-54.70208-72.45824h-123.66848l-0.08192 561.41824c-0.11264 78.46912-130.9696 106.41408-164.18816 30.2592-83.18976-39.77216-64.37888-190.9248 46.31552-192.57344z"
fill="#FFFFFF"
p-id="2735"
></path>
</svg>
)
}
export const BiliBiliLogo = () => {
return (
<svg
t="1746696526393"
className="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="3757"
width="200"
height="200"
>
<path
d="M0 0m184.32 0l655.36 0q184.32 0 184.32 184.32l0 655.36q0 184.32-184.32 184.32l-655.36 0q-184.32 0-184.32-184.32l0-655.36q0-184.32 184.32-184.32Z"
fill="#EC5D85"
p-id="3758"
></path>
<path
d="M512 241.96096h52.224l65.06496-96.31744c49.63328-50.31936 89.64096 0.43008 63.85664 45.71136l-34.31424 51.5072c257.64864 5.02784 257.64864 43.008 257.64864 325.03808 0 325.94944 0 336.46592-404.48 336.46592S107.52 893.8496 107.52 567.90016c0-277.69856 0-318.80192 253.14304-324.95616l-39.43424-58.368c-31.26272-54.90688 37.33504-90.40896 64.68608-42.37312l60.416 99.80928c18.18624-0.0512 41.18528-0.0512 65.66912-0.0512z"
fill="#EF85A7"
p-id="3759"
></path>
<path
d="M512 338.5856c332.8 0 332.8 0 332.8 240.64s0 248.39168-332.8 248.39168-332.8-7.75168-332.8-248.39168 0-240.64 332.8-240.64z"
fill="#EC5D85"
p-id="3760"
></path>
<path
d="M281.6 558.08a30.72 30.72 0 0 1-27.47392-16.97792 30.72 30.72 0 0 1 13.73184-41.216l122.88-61.44a30.72 30.72 0 0 1 41.216 13.74208 30.72 30.72 0 0 1-13.74208 41.216l-122.88 61.44a30.59712 30.59712 0 0 1-13.73184 3.23584zM752.64 558.08a30.60736 30.60736 0 0 1-12.8512-2.83648l-133.12-61.44a30.72 30.72 0 0 1-15.04256-40.7552 30.72 30.72 0 0 1 40.76544-15.02208l133.12 61.44A30.72 30.72 0 0 1 752.64 558.08zM454.656 666.88a15.36 15.36 0 0 1-12.288-6.1952 15.36 15.36 0 0 1 3.072-21.49376l68.5056-50.91328 50.35008 52.62336a15.36 15.36 0 0 1-22.20032 21.23776l-31.5904-33.024-46.71488 34.72384a15.28832 15.28832 0 0 1-9.13408 3.04128z"
fill="#EF85A7"
p-id="3761"
></path>
<path
d="M65.536 369.31584c15.03232 101.90848 32.84992 147.17952 44.544 355.328 14.63296 2.18112 177.70496 10.04544 204.05248-74.62912a16.14848 16.14848 0 0 0 1.64864-10.87488c-30.60736-80.3328-169.216-60.416-169.216-60.416s-10.36288-146.50368-11.49952-238.83776zM362.25024 383.03744l34.816 303.17568h34.64192L405.23776 381.1328zM309.52448 536.28928h45.48608l16.09728 158.6176-31.82592 1.85344zM446.86336 542.98624h45.80352V705.3312h-33.87392zM296.6016 457.97376h21.39136l5.2736 58.99264-18.91328 2.26304zM326.99392 457.97376h21.39136l2.53952 55.808-17.408 1.61792zM470.62016 459.88864h19.456v62.27968h-19.456zM440.23808 459.88864h22.20032v62.27968h-16.62976z"
fill="#FFFFFF"
p-id="3762"
></path>
<path
d="M243.56864 645.51936a275.456 275.456 0 0 1-28.4672 23.74656 242.688 242.688 0 0 1-29.53216 17.52064 2.70336 2.70336 0 0 1-4.4032-1.95584 258.60096 258.60096 0 0 1-5.12-29.57312c-1.41312-12.1856-1.95584-25.68192-2.16064-36.36224 0-0.3072 0-2.5088 3.01056-1.90464a245.92384 245.92384 0 0 1 34.22208 9.5744 257.024 257.024 0 0 1 32.3584 15.17568c0.52224 0.256 2.51904 1.4848 0.09216 3.77856z"
fill="#EB5480"
p-id="3763"
></path>
<path
d="M513.29024 369.31584c15.03232 101.90848 32.84992 147.17952 44.544 355.328 14.63296 2.18112 177.70496 10.04544 204.05248-74.62912a16.14848 16.14848 0 0 0 1.64864-10.87488c-30.60736-80.3328-169.216-60.416-169.216-60.416s-10.36288-146.50368-11.49952-238.83776zM810.00448 383.03744l34.816 303.17568h34.64192L852.992 381.1328zM757.27872 536.28928h45.48608l16.09728 158.6176-31.82592 1.85344zM894.6176 542.98624h45.80352V705.3312H906.5472zM744.35584 457.97376h21.39136l5.2736 58.99264-18.91328 2.26304zM774.74816 457.97376h21.39136l2.53952 55.808-17.408 1.61792zM918.3744 459.88864h19.456v62.27968h-19.456zM887.99232 459.88864h22.20032v62.27968h-16.62976z"
fill="#FFFFFF"
p-id="3764"
></path>
<path
d="M691.32288 645.51936a275.456 275.456 0 0 1-28.4672 23.74656 242.688 242.688 0 0 1-29.53216 17.52064 2.70336 2.70336 0 0 1-4.4032-1.95584 258.60096 258.60096 0 0 1-5.12-29.57312c-1.41312-12.1856-1.95584-25.68192-2.16064-36.36224 0-0.3072 0-2.5088 3.01056-1.90464a245.92384 245.92384 0 0 1 34.22208 9.5744 257.024 257.024 0 0 1 32.3584 15.17568c0.52224 0.256 2.51904 1.4848 0.09216 3.77856z"
fill="#EB5480"
p-id="3765"
></path>
</svg>
)
}
export const YoutubeLogo = () => {
return (
<svg
t="1746696577253"
className="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="4785"
width="200"
height="200"
>
<path
d="M426.666667 682.666667V384l256 149.845333L426.666667 682.666667z m587.093333-355.541334s-10.026667-71.04-40.704-102.357333c-38.954667-41.088-82.602667-41.258667-102.613333-43.648C727.168 170.666667 512.213333 170.666667 512.213333 170.666667h-0.426666s-214.954667 0-358.229334 10.453333c-20.053333 2.389333-63.658667 2.56-102.656 43.648-30.677333 31.317333-40.661333 102.4-40.661333 102.4S0 410.538667 0 493.952v78.293333c0 83.456 10.24 166.912 10.24 166.912s9.984 71.04 40.661333 102.357334c38.997333 41.088 90.154667 39.765333 112.938667 44.074666C245.76 893.568 512 896 512 896s215.168-0.341333 358.442667-10.752c20.053333-2.432 63.658667-2.602667 102.613333-43.690667 30.72-31.317333 40.704-102.4 40.704-102.4s10.24-83.413333 10.24-166.869333v-78.250667c0-83.456-10.24-166.912-10.24-166.912z"
fill="#FF0000"
p-id="4786"
></path>
</svg>
)
}
export const LocalLogo = () => {
return (
<svg
t="1746696617516"
className="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="5795"
width="200"
height="200"
>
<path
d="M948.736 144.384H461.568l-56.576-83.456c-6.144-7.168-15.36-10.752-24.576-9.728H79.872c-17.152-0.512-34.048 5.632-46.592 17.408-12.544 11.776-19.968 28.16-20.48 45.312v222.464c0-18.944 7.424-37.12 20.992-50.432 13.312-13.312 31.488-20.992 50.432-20.992h855.808c18.944 0 37.12 7.424 50.432 20.992 13.312 13.312 20.992 31.488 20.992 50.432V213.248c1.28-36.096-26.624-66.816-62.72-68.864z m0 0"
fill="#FFD569"
p-id="5796"
></path>
<path
d="M939.776 265.216H84.224C44.8 265.216 12.8 297.216 12.8 336.64v570.368c0 18.944 7.424 37.12 20.992 50.432 13.312 13.312 31.488 20.992 50.432 20.992h855.808c18.944 0 37.12-7.424 50.432-20.992 13.312-13.312 20.992-31.488 20.992-50.432V336.64c0-18.944-7.424-37.12-20.992-50.432-13.568-13.312-31.744-20.992-50.688-20.992z m-213.76 467.968c0.256 6.4-3.328 12.288-9.216 14.848-1.792 0.256-3.84 0.256-5.632 0-4.096 0-7.936-1.792-10.752-4.864l-54.784-59.136v77.056c0.256 8.704-6.4 15.872-14.848 16.384h-317.44c-7.936-0.512-14.336-6.912-14.848-14.848V495.616c-0.256-8.704 6.4-15.872 14.848-16.384h317.44c8.704 0.512 15.616 7.68 15.36 16.384v76.544l54.784-57.344c3.84-4.864 10.496-6.144 16.128-3.584 5.632 2.816 9.472 8.704 9.216 14.848v207.104z m0 0"
fill="#FFC225"
p-id="5797"
></path>
</svg>
)
}

View File

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

View File

@@ -1,10 +1,26 @@
import * as React from 'react'
import { useState, useEffect } from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
function Checkbox({ className, checked, onChange, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
const [isChecked, setIsChecked] = useState(checked || false);
useEffect(() => {
if (checked !== undefined) {
setIsChecked(checked);
}
}, [checked]);
const handleCheckChange = (newChecked: boolean) => {
setIsChecked(newChecked);
if (onChange) {
onChange({} as React.FormEvent<HTMLButtonElement>);
}
};
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
@@ -12,6 +28,8 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
'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
)}
checked={isChecked}
onCheckedChange={handleCheckChange}
{...props}
>
<CheckboxPrimitive.Indicator

View File

@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -2,7 +2,7 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
function Input({ className, type, value, onChange, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
@@ -13,6 +13,8 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
value={value ?? ''}
onChange={onChange}
{...props}
/>
)

View File

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

View File

@@ -0,0 +1,35 @@
/* -------------------- 常量 -------------------- */
import {
BiliBiliLogo,
DouyinLogo,
KuaishouLogo,
LocalLogo,
YoutubeLogo,
} from '@/components/Icons/platform.tsx'
export const noteFormats = [
{ label: '目录', value: 'toc' },
{ label: '原片跳转', value: 'link' },
{ label: '原片截图', value: 'screenshot' },
{ label: 'AI总结', value: 'summary' },
] as const
export const noteStyles = [
{ label: '精简', value: 'minimal' },
{ label: '详细', value: 'detailed' },
{ label: '教程', value: 'tutorial' },
{ label: '学术', value: 'academic' },
{ label: '小红书', value: 'xiaohongshu' },
{ label: '生活向', value: 'life_journal' },
{ label: '任务导向', value: 'task_oriented' },
{ label: '商业风格', value: 'business' },
{ label: '会议纪要', value: 'meeting_minutes' },
] as const
export const videoPlatforms = [
{ label: '哔哩哔哩', value: 'bilibili', logo: BiliBiliLogo },
{ label: 'YouTube', value: 'youtube', logo: YoutubeLogo },
{ label: '抖音', value: 'douyin', logo: DouyinLogo },
{ label: '快手', value: 'kuaishou', logo: KuaishouLogo },
{ label: '本地视频', value: 'local', logo: LocalLogo },
] as const

View File

@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react'
import request from '@/utils/request'
const MAX_RETRIES = 3
const RETRY_INTERVAL = 10000 // 10秒
export const useCheckBackend = () => {
const [loading, setLoading] = useState(false)
const [initialized, setInitialized] = useState(false)
useEffect(() => {
let retries = 0
const check = async () => {
try {
await request.get('/sys_check')
setInitialized(true)
setLoading(false)
} catch {
if (retries === 0) {
// 第一次失败时开始显示加载状态
setLoading(true)
}
if (retries < MAX_RETRIES) {
retries++
setTimeout(check, RETRY_INTERVAL)
} else {
// 达到重试上限,继续轮询直到后端就绪
waitUntilBackendReady()
}
}
}
const waitUntilBackendReady = async () => {
while (true) {
try {
await request.get('/sys_health')
setInitialized(true)
setLoading(false)
break
} catch {
await new Promise(res => setTimeout(res, RETRY_INTERVAL))
}
}
}
check()
}, [])
return { loading, initialized }
}

View File

@@ -22,15 +22,17 @@ export const useTaskPolling = (interval = 3000) => {
task => task.status != 'SUCCESS' && task.status != 'FAILED'
)
// 无活跃任务时跳过轮询
if (pendingTasks.length === 0) return
for (const task of pendingTasks) {
try {
console.log('🔄 正在轮询任务:', task.id)
const res = await get_task_status(task.id)
const { status } = res.data
const { status } = res
if (status && status !== task.status) {
if (status === 'SUCCESS') {
const { markdown, transcript, audio_meta } = res.data.result
const { markdown, transcript, audio_meta } = res.result
toast.success('笔记生成成功')
updateTaskContent(task.id, {
status,
@@ -47,9 +49,7 @@ export const useTaskPolling = (interval = 3000) => {
}
} catch (e) {
console.error('❌ 任务轮询失败:', e)
toast.error(`生成失败 ${e.message || e}`)
updateTaskContent(task.id, { status: 'FAILED' })
// removeTask(task.id)
}
}
}, interval)

View File

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

View File

@@ -1,5 +1,5 @@
import React, { FC } from 'react'
import { SlidersHorizontal } from 'lucide-react'
import React, { FC, useRef, useState } from 'react'
import { SlidersHorizontal, PanelLeftClose, PanelLeftOpen, History as HistoryIcon } from 'lucide-react'
import {
Tooltip,
TooltipContent,
@@ -7,32 +7,63 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip.tsx'
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable'
import { ScrollArea } from "@/components/ui/scroll-area.tsx"
import type { ImperativePanelHandle } from 'react-resizable-panels'
import logo from '@/assets/icon.svg'
interface IProps {
NoteForm: React.ReactNode
Preview: React.ReactNode
History: React.ReactNode
}
const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
const [, setShowSettings] = useState(false)
const [isLeftCollapsed, setIsLeftCollapsed] = useState(false)
const [isMiddleCollapsed, setIsMiddleCollapsed] = useState(false)
const leftPanelRef = useRef<ImperativePanelHandle>(null)
const middlePanelRef = useRef<ImperativePanelHandle>(null)
return (
<div className="flex h-screen flex-col overflow-hidden">
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
{/* 左边表单 */}
<ResizablePanel defaultSize={18} minSize={10} maxSize={35}>
<ResizablePanel
ref={leftPanelRef}
defaultSize={23}
minSize={10}
maxSize={35}
collapsible
collapsedSize={0}
onCollapse={() => setIsLeftCollapsed(true)}
onExpand={() => setIsLeftCollapsed(false)}
>
<aside className="flex h-full flex-col overflow-hidden border-r border-neutral-200 bg-white">
<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" />
<img src={logo} alt="logo" className="h-full w-full object-contain" />
</div>
<div className="text-2xl font-bold text-gray-800">BiliNote</div>
</div>
<div>
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => leftPanelRef.current?.collapse()}
className="text-muted-foreground hover:text-primary cursor-pointer rounded p-1 hover:bg-neutral-100"
>
<PanelLeftClose className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent>
<span></span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger onClick={() => setShowSettings(true)}>
@@ -47,23 +78,92 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
</TooltipProvider>
</div>
</header>
<div className="flex-1 overflow-auto p-4">{NoteForm}</div>
<ScrollArea className="flex-1 overflow-auto">
<div className="p-4">{NoteForm}</div>
</ScrollArea>
</aside>
</ResizablePanel>
<ResizableHandle />
{/* 左面板折叠时的展开按钮 */}
{isLeftCollapsed && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => leftPanelRef.current?.expand()}
className="flex h-full w-8 shrink-0 items-center justify-center border-r border-neutral-200 bg-white hover:bg-neutral-50"
>
<PanelLeftOpen className="h-4 w-4 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<span></span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* 中间历史 */}
<ResizablePanel defaultSize={16} minSize={10} maxSize={30}>
<ResizablePanel
ref={middlePanelRef}
defaultSize={16}
minSize={10}
maxSize={30}
collapsible
collapsedSize={0}
onCollapse={() => setIsMiddleCollapsed(true)}
onExpand={() => setIsMiddleCollapsed(false)}
>
<aside className="flex h-full flex-col overflow-hidden border-r border-neutral-200 bg-white">
<div className="flex-1 overflow-auto p-4">{History}</div>
<header className="flex h-10 shrink-0 items-center justify-between border-b border-neutral-100 px-3">
<span className="text-sm font-medium text-gray-600"></span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => middlePanelRef.current?.collapse()}
className="text-muted-foreground hover:text-primary cursor-pointer rounded p-1 hover:bg-neutral-100"
>
<PanelLeftClose className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<span></span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</header>
<ScrollArea className="flex-1 overflow-auto">
<div>{History}</div>
</ScrollArea>
</aside>
</ResizablePanel>
<ResizableHandle />
{/* 中间面板折叠时的展开按钮 */}
{isMiddleCollapsed && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => middlePanelRef.current?.expand()}
className="flex h-full w-8 shrink-0 items-center justify-center border-r border-neutral-200 bg-white hover:bg-neutral-50"
>
<HistoryIcon className="h-4 w-4 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<span></span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* 右边预览 */}
<ResizablePanel defaultSize={55} minSize={30}>
<ResizablePanel defaultSize={61} minSize={30}>
<main className="flex h-full flex-col overflow-hidden bg-white p-6">{Preview}</main>
</ResizablePanel>
</ResizablePanelGroup>

View File

@@ -7,6 +7,8 @@ import {
import { Link, Outlet } from 'react-router-dom'
import { SlidersHorizontal } from 'lucide-react'
import React from 'react'
import logo from '@/assets/icon.svg'
interface ISettingLayoutProps {
Menu: React.ReactNode
}
@@ -25,7 +27,7 @@ const SettingLayout = ({ Menu }: ISettingLayoutProps) => {
<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" />
<img src={logo} alt="logo" className="h-full w-full object-contain" />
</div>
<div className="text-2xl font-bold text-gray-800">BiliNote</div>
</div>

View File

@@ -0,0 +1,8 @@
import { loadCSS, loadJS } from 'markmap-common'
import { Transformer } from 'markmap-lib'
import * as markmap from 'markmap-view'
export const transformer = new Transformer()
const { scripts, styles } = transformer.getAssets()
loadCSS(styles)
loadJS(scripts, { getMarkmap: () => markmap })

View File

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

View File

@@ -18,14 +18,15 @@ export const HomePage: FC = () => {
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')
} else {
// PENDING、PARSING、DOWNLOADING、TRANSCRIBING、SUMMARIZING 等所有进行中状态
setStatus('loading')
}
}, [currentTask])
}, [currentTask, currentTask?.status])
// useEffect( () => {
// get_task_status('d4e87938-c066-48a0-bbd5-9bec40d53354').then(res=>{
@@ -36,7 +37,7 @@ export const HomePage: FC = () => {
return (
<HomeLayout
NoteForm={<NoteForm />}
Preview={<MarkdownViewer status={status} content={content} />}
Preview={<MarkdownViewer status={status} />}
History={<History />}
/>
)

View File

@@ -0,0 +1,294 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Bubble, Sender } from '@ant-design/x'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Loader2, Trash2, ChevronDown, ChevronUp, BookOpen, UserRound, Bot, Maximize2, Minimize2 } from 'lucide-react'
import { toast } from 'react-hot-toast'
import { useChatStore } from '@/store/chatStore'
import { useTaskStore } from '@/store/taskStore'
import { askQuestion, getChatStatus, indexTask, type ChatSource, type IndexStatus } from '@/services/chat'
type ChatMode = 'half' | 'full'
interface ChatPanelProps {
taskId: string
mode: ChatMode
onModeChange: (mode: ChatMode) => void
}
function SourceBadges({ sources }: { sources: ChatSource[] }) {
const [expanded, setExpanded] = useState(false)
if (!sources || sources.length === 0) return null
return (
<div className="mt-1.5">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-xs text-neutral-400 hover:text-neutral-600"
>
<BookOpen className="h-3 w-3" />
<span> ({sources.length})</span>
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
{expanded && (
<div className="mt-1 flex flex-wrap gap-1">
{sources.map((s, i) => (
<Badge key={i} variant="outline" className="text-xs font-normal">
{s.source_type === 'markdown'
? s.section_title || '笔记'
: `${(s.start_time ?? 0).toFixed(0)}s ~ ${(s.end_time ?? 0).toFixed(0)}s`}
</Badge>
))}
</div>
)}
</div>
)
}
export default function ChatPanel({ taskId, mode, onModeChange }: ChatPanelProps) {
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [indexStatus, setIndexStatus] = useState<IndexStatus | null>(null)
const messages = useChatStore(state => state.chatHistory[taskId]) ?? []
const addMessage = useChatStore(state => state.addMessage)
const clearChat = useChatStore(state => state.clearChat)
const currentTaskId = useTaskStore(state => state.currentTaskId)
const tasks = useTaskStore(state => state.tasks)
const currentTask = useMemo(
() => tasks.find(t => t.id === currentTaskId) ?? null,
[tasks, currentTaskId],
)
// 检查索引状态未索引时自动触发indexing 时轮询
useEffect(() => {
if (!taskId) return
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
const poll = async () => {
try {
const res = await getChatStatus(taskId)
if (cancelled) return
setIndexStatus(res.status)
if (res.status === 'idle') {
// 未索引,触发后台索引
await indexTask(taskId)
if (!cancelled) setIndexStatus('indexing')
}
// indexing 状态持续轮询
if (res.status === 'indexing' || res.status === 'idle') {
timer = setTimeout(poll, 2000)
}
} catch {
if (!cancelled) setIndexStatus('failed')
}
}
poll()
return () => {
cancelled = true
if (timer) clearTimeout(timer)
}
}, [taskId])
const handleSend = useCallback(
async (value: string) => {
const question = value.trim()
if (!question || loading) return
const providerId = currentTask?.formData?.provider_id
const modelName = currentTask?.formData?.model_name
if (!providerId || !modelName) {
toast.error('无法获取模型配置,请确认任务已完成')
return
}
addMessage(taskId, { role: 'user', content: question })
setInput('')
setLoading(true)
try {
const history = messages.map(m => ({ role: m.role, content: m.content }))
const res = await askQuestion({
task_id: taskId,
question,
history,
provider_id: providerId,
model_name: modelName,
})
addMessage(taskId, {
role: 'assistant',
content: res.answer,
sources: res.sources,
})
} catch {
toast.error('问答请求失败')
} finally {
setLoading(false)
}
},
[loading, taskId, currentTask, messages, addMessage],
)
// 转换为 Bubble.List 的数据格式
const bubbleItems = useMemo(() => {
const items = messages.map((msg, i) => ({
key: `msg-${i}`,
role: msg.role === 'user' ? ('user' as const) : ('ai' as const),
content: msg.content,
footer:
msg.role === 'assistant' && msg.sources ? (
<SourceBadges sources={msg.sources} />
) : undefined,
}))
if (loading) {
items.push({
key: 'loading',
role: 'ai' as const,
content: '思考中...',
loading: true,
} as any)
}
return items
}, [messages, loading])
// Bubble 角色配置
const roles = useMemo(
() => ({
user: {
placement: 'end' as const,
avatar: (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-blue-500 text-white">
<UserRound className="h-4 w-4" />
</div>
),
variant: 'filled' as const,
styles: { content: { background: '#3b82f6', color: '#fff' } },
},
ai: {
placement: 'start' as const,
avatar: (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-neutral-500 text-white">
<Bot className="h-4 w-4" />
</div>
),
variant: 'outlined' as const,
contentRender: (content: any) => (
<div className="markdown-body prose prose-sm max-w-none prose-p:my-1 prose-li:my-0.5 prose-headings:my-2">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{typeof content === 'string' ? content : String(content)}
</ReactMarkdown>
</div>
),
},
}),
[],
)
if (indexStatus === null || indexStatus === 'indexing' || indexStatus === 'idle') {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-neutral-400">
<Loader2 className="h-6 w-6 animate-spin" />
<div className="text-center">
<p className="text-sm font-medium">...</p>
<p className="mt-1 text-xs">使 Embedding 80MB</p>
</div>
</div>
)
}
if (indexStatus === 'failed') {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-neutral-400">
<span className="text-sm"></span>
<Button
size="sm"
variant="outline"
onClick={async () => {
setIndexStatus('indexing')
try {
await indexTask(taskId)
} catch {
toast.error('索引请求失败')
setIndexStatus('failed')
}
}}
>
</Button>
</div>
)
}
return (
<div className="flex h-full flex-col border-l">
{/* 头部 */}
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">AI </span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-neutral-400 hover:text-neutral-600"
onClick={() => onModeChange(mode === 'half' ? 'full' : 'half')}
title={mode === 'half' ? '全屏' : '半屏'}
>
{mode === 'half' ? (
<Maximize2 className="h-3.5 w-3.5" />
) : (
<Minimize2 className="h-3.5 w-3.5" />
)}
</Button>
{messages.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-neutral-400 hover:text-red-500"
onClick={() => clearChat(taskId)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{/* 消息列表 */}
<div className="flex-1 overflow-hidden">
{messages.length === 0 && !loading ? (
<div className="flex h-full items-center justify-center text-center text-sm text-neutral-400">
<div>
<p></p>
<p className="mt-1 text-xs"></p>
</div>
</div>
) : (
<Bubble.List
items={bubbleItems}
role={roles}
style={{ height: '100%' }}
/>
)}
</div>
{/* 输入区域 */}
<div className="border-t px-3 py-2">
<Sender
value={input}
onChange={setInput}
onSubmit={handleSend}
loading={loading}
placeholder="输入你的问题..."
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,211 @@
'use client'
import { useEffect, useState } from 'react'
import { Copy, Download, BrainCircuit, MessageSquare } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Badge } from '@/components/ui/badge'
interface VersionNote {
ver_id: string
model_name?: string
style?: string
created_at?: string
}
interface NoteHeaderProps {
currentTask?: {
markdown: VersionNote[] | string
}
isMultiVersion: boolean
currentVerId: string
setCurrentVerId: (id: string) => void
modelName: string
style: string
noteStyles: { value: string; label: string }[]
onCopy: () => void
onDownload: () => void
createAt?: string | Date
setShowTranscribe: (show: boolean) => void
showChat?: false | 'half' | 'full'
setShowChat?: (mode: false | 'half' | 'full') => void
}
export function MarkdownHeader({
currentTask,
isMultiVersion,
currentVerId,
setCurrentVerId,
modelName,
style,
noteStyles,
onCopy,
onDownload,
createAt,
showTranscribe,
setShowTranscribe,
showChat,
setShowChat,
viewMode,
setViewMode,
}: NoteHeaderProps) {
const [copied, setCopied] = useState(false)
useEffect(() => {
let timer: NodeJS.Timeout
if (copied) {
timer = setTimeout(() => setCopied(false), 2000)
}
return () => clearTimeout(timer)
}, [copied])
const handleCopy = () => {
onCopy()
setCopied(true)
}
const styleName = noteStyles.find(v => v.value === style)?.label || style
const reversedMarkdown: VersionNote[] = Array.isArray(currentTask?.markdown)
? [...currentTask!.markdown].reverse()
: []
const formatDate = (date: string | Date | undefined) => {
if (!date) return ''
const d = typeof date === 'string' ? new Date(date) : date
if (isNaN(d.getTime())) return ''
return d
.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
.replace(/\//g, '-')
}
return (
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-3 border-b bg-white/95 px-4 py-2 backdrop-blur-sm">
{/* 左侧区域:版本 + 标签 + 创建时间 */}
<div className="flex flex-wrap items-center gap-3">
{isMultiVersion && (
<Select value={currentVerId} onValueChange={setCurrentVerId}>
<SelectTrigger className="h-8 w-[160px] text-sm">
<div className="flex items-center">
{(() => {
const idx = currentTask?.markdown.findIndex(v => v.ver_id === currentVerId)
return idx !== -1 ? `版本(${currentVerId.slice(-6)}` : ''
})()}
</div>
</SelectTrigger>
<SelectContent>
{(currentTask?.markdown || []).map((v, idx) => {
const shortId = v.ver_id.slice(-6)
return (
<SelectItem key={v.ver_id} value={v.ver_id}>
{`版本(${shortId}`}
</SelectItem>
)
})}
</SelectContent>
</Select>
)}
<Badge variant="secondary" className="bg-pink-100 text-pink-700 hover:bg-pink-200">
{modelName}
</Badge>
<Badge variant="secondary" className="bg-cyan-100 text-cyan-700 hover:bg-cyan-200">
{styleName}
</Badge>
{createAt && (
<div className="text-muted-foreground text-sm">: {formatDate(createAt)}</div>
)}
</div>
{/* 右侧操作按钮 */}
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => {
setViewMode(viewMode == 'preview' ? 'map' : 'preview')
}}
variant="ghost"
size="sm"
className="h-8 px-2"
>
<BrainCircuit className="mr-1.5 h-4 w-4" />
<span className="text-sm">{viewMode == 'preview' ? '思维导图' : 'markdown'}</span>
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleCopy} variant="ghost" size="sm" className="h-8 px-2">
<Copy className="mr-1.5 h-4 w-4" />
<span className="text-sm">{copied ? '已复制' : '复制'}</span>
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownload} variant="ghost" size="sm" className="h-8 px-2">
<Download className="mr-1.5 h-4 w-4" />
<span className="text-sm"> Markdown</span>
</Button>
</TooltipTrigger>
<TooltipContent> Markdown </TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => {
setShowTranscribe(!showTranscribe)
}}
variant="ghost"
size="sm"
className="h-8 px-2"
>
{/*<Download className="mr-1.5 h-4 w-4" />*/}
<span className="text-sm"></span>
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</TooltipProvider>
{setShowChat && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => setShowChat(showChat ? false : 'half')}
variant={showChat ? 'default' : 'ghost'}
size="sm"
className="h-8 px-2"
>
<MessageSquare className="mr-1.5 h-4 w-4" />
<span className="text-sm">AI </span>
</Button>
</TooltipTrigger>
<TooltipContent> AI </TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
)
}

View File

@@ -1,19 +1,40 @@
import { useState } from 'react'
import { useState, useEffect, useRef, useMemo, memo, FC } 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 { Copy, Download, ArrowRight, Play, ExternalLink } from 'lucide-react'
import { toast } from 'react-hot-toast'
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 {
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { atomDark as codeStyle } from 'react-syntax-highlighter/dist/esm/styles/prism'
import Zoom from 'react-medium-image-zoom'
import 'react-medium-image-zoom/dist/styles.css'
import gfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
import 'github-markdown-css/github-markdown-light.css'
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
import { useTaskStore } from '@/store/taskStore'
import { noteStyles } from '@/constant/note.ts'
import { MarkdownHeader } from '@/pages/HomePage/components/MarkdownHeader.tsx'
import TranscriptViewer from '@/pages/HomePage/components/transcriptViewer.tsx'
import MarkmapEditor from '@/pages/HomePage/components/MarkmapComponent.tsx'
import ChatPanel from '@/pages/HomePage/components/ChatPanel.tsx'
import VideoBanner from '@/pages/HomePage/components/VideoBanner.tsx'
interface VersionNote {
ver_id: string
content: string
style: string
model_name: string
created_at?: string
}
interface MarkdownViewerProps {
content: string | VersionNote[]
status: 'idle' | 'loading' | 'success' | 'failed'
}
@@ -25,36 +46,329 @@ const steps = [
{ label: '保存完成', key: 'SUCCESS' },
]
const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
const remarkPlugins = [gfm, remarkMath]
const rehypePlugins = [rehypeKatex]
/**
* 构建 ReactMarkdown components 对象baseURL 用于修正图片路径。
* 使用函数 + useMemo 避免每次渲染都创建新的函数实例。
*/
function createMarkdownComponents(baseURL: string) {
return {
h1: ({ children, ...props }: any) => (
<h1
className="text-primary my-6 scroll-m-20 text-3xl font-extrabold tracking-tight lg:text-4xl"
{...props}
>
{children}
</h1>
),
h2: ({ children, ...props }: any) => (
<h2
className="text-primary mt-10 mb-4 scroll-m-20 border-b pb-2 text-2xl font-semibold tracking-tight first:mt-0"
{...props}
>
{children}
</h2>
),
h3: ({ children, ...props }: any) => (
<h3
className="text-primary mt-8 mb-4 scroll-m-20 text-xl font-semibold tracking-tight"
{...props}
>
{children}
</h3>
),
h4: ({ children, ...props }: any) => (
<h4
className="text-primary mt-6 mb-2 scroll-m-20 text-lg font-semibold tracking-tight"
{...props}
>
{children}
</h4>
),
p: ({ children, ...props }: any) => (
<p className="leading-7 [&:not(:first-child)]:mt-6" {...props}>
{children}
</p>
),
a: ({ href, children, ...props }: any) => {
const isOriginLink =
typeof children[0] === 'string' &&
(children[0] as string).startsWith('原片 @')
if (isOriginLink) {
const timeMatch = (children[0] as string).match(/原片 @ (\d{2}:\d{2})/)
const timeText = timeMatch ? timeMatch[1] : '原片'
return (
<span className="origin-link my-2 inline-flex">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-100"
{...props}
>
<Play className="h-3.5 w-3.5" />
<span>{timeText}</span>
</a>
</span>
)
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary/80 inline-flex items-center gap-0.5 font-medium underline underline-offset-4"
{...props}
>
{children}
{href?.startsWith('http') && (
<ExternalLink className="ml-0.5 inline-block h-3 w-3" />
)}
</a>
)
},
img: ({ node, ...props }: any) => {
let src = props.src
if (src.startsWith('/')) {
src = baseURL + src
}
props.src = src
return (
<div className="my-8 flex justify-center">
<Zoom>
<img
{...props}
className="max-w-full cursor-zoom-in rounded-lg object-cover shadow-md transition-all hover:shadow-lg"
style={{ maxHeight: '500px' }}
/>
</Zoom>
</div>
)
},
strong: ({ children, ...props }: any) => (
<strong className="text-primary font-bold" {...props}>
{children}
</strong>
),
li: ({ children, ...props }: any) => {
const rawText = String(children)
const isFakeHeading = /^(\*\*.+\*\*)$/.test(rawText.trim())
if (isFakeHeading) {
return (
<div className="text-primary my-4 text-lg font-bold">{children}</div>
)
}
return (
<li className="my-1" {...props}>
{children}
</li>
)
},
ul: ({ children, ...props }: any) => (
<ul className="my-6 ml-6 list-disc [&>li]:mt-2" {...props}>
{children}
</ul>
),
ol: ({ children, ...props }: any) => (
<ol className="my-6 ml-6 list-decimal [&>li]:mt-2" {...props}>
{children}
</ol>
),
blockquote: ({ children, ...props }: any) => (
<blockquote
className="border-primary/20 text-muted-foreground mt-6 border-l-4 pl-4 italic"
{...props}
>
{children}
</blockquote>
),
code: ({ inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '')
const codeContent = String(children).replace(/\n$/, '')
if (!inline && match) {
return (
<div className="group bg-muted relative my-6 overflow-hidden rounded-lg border shadow-sm">
<div className="bg-muted text-muted-foreground flex items-center justify-between px-4 py-1.5 text-sm font-medium">
<div>{match[1].toUpperCase()}</div>
<button
onClick={() => {
navigator.clipboard.writeText(codeContent)
toast.success('代码已复制')
}}
className="bg-background/80 hover:bg-background flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
<SyntaxHighlighter
style={codeStyle}
language={match[1]}
PreTag="div"
className="!bg-muted !m-0 !p-0"
customStyle={{
margin: 0,
padding: '1rem',
background: 'transparent',
fontSize: '0.9rem',
}}
{...props}
>
{codeContent}
</SyntaxHighlighter>
</div>
)
}
return (
<code
className="bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm"
{...props}
>
{children}
</code>
)
},
table: ({ children, ...props }: any) => (
<div className="my-6 w-full overflow-y-auto">
<table className="w-full border-collapse text-sm" {...props}>
{children}
</table>
</div>
),
th: ({ children, ...props }: any) => (
<th
className="border-muted-foreground/20 border px-4 py-2 text-left font-medium [&[align=center]]:text-center [&[align=right]]:text-right"
{...props}
>
{children}
</th>
),
td: ({ children, ...props }: any) => (
<td
className="border-muted-foreground/20 border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right"
{...props}
>
{children}
</td>
),
hr: ({ ...props }: any) => (
<hr className="border-muted-foreground/20 my-8" {...props} />
),
}
}
const MarkdownViewer: FC<MarkdownViewerProps> = memo(({ status }) => {
const [copied, setCopied] = useState(false)
const [currentVerId, setCurrentVerId] = useState<string>('')
const [selectedContent, setSelectedContent] = useState<string>('')
const [modelName, setModelName] = useState<string>('')
const [style, setStyle] = useState<string>('')
const [createTime, setCreateTime] = useState<string>('')
// 确保baseURL没有尾部斜杠
const baseURL = (String(import.meta.env.VITE_API_BASE_URL || '').replace('/api','') || '').replace(/\/$/, '')
const getCurrentTask = useTaskStore.getState().getCurrentTask
const currentTask = useTaskStore(state => state.getCurrentTask())
const taskStatus = currentTask?.status || 'PENDING'
const retryTask = useTaskStore.getState().retryTask
const isMultiVersion = Array.isArray(currentTask?.markdown)
const [showTranscribe, setShowTranscribe] = useState(false)
const [showChat, setShowChat] = useState<false | 'half' | 'full'>(false)
const [viewMode, setViewMode] = useState<'map' | 'preview'>('preview')
const svgRef = useRef<SVGSVGElement>(null)
// 缓存 ReactMarkdown components仅在 baseURL 变化时重建
const markdownComponents = useMemo(() => createMarkdownComponents(baseURL), [baseURL])
// 多版本内容处理
useEffect(() => {
if (!currentTask) return
if (!isMultiVersion) {
setCurrentVerId('') // 清空旧版本 ID
setModelName(currentTask.formData.model_name)
setStyle(currentTask.formData.style)
setCreateTime(currentTask.createdAt)
setSelectedContent(currentTask?.markdown)
} else {
const latestVersion = [...currentTask.markdown].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)[0]
if (latestVersion) {
setCurrentVerId(latestVersion.ver_id)
}
}
}, [currentTask?.id, taskStatus])
useEffect(() => {
if (!currentTask || !isMultiVersion) return
const currentVer = currentTask.markdown.find(v => v.ver_id === currentVerId)
if (currentVer) {
setModelName(currentVer.model_name)
setStyle(currentVer.style)
setCreateTime(currentVer.created_at || '')
setSelectedContent(currentVer.content)
}
}, [currentVerId, currentTask?.id])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content)
await navigator.clipboard.writeText(selectedContent)
setCopied(true)
toast.success('已复制到剪贴板')
setTimeout(() => setCopied(false), 2000)
} catch (e) {
toast.error(`复制失败${e}`)
toast.error('复制失败', e)
toast.error('复制失败')
}
}
const alertButton = {
id: 'alert',
title: '测试警告',
content: '⚠️',
onClick: () => alert('你点击了自定义按钮!'),
}
const exportButton = {
id: 'export',
title: '导出思维导图',
content: '⤓',
onClick: () => {
const svgEl = svgRef.current
if (!svgEl) return
// 同上面的序列化逻辑
const serializer = new XMLSerializer()
const source = serializer.serializeToString(svgEl)
const blob = new Blob(['<?xml version="1.0" encoding="UTF-8"?>', source], {
type: 'image/svg+xml;charset=utf-8',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'mindmap.svg'
a.click()
URL.revokeObjectURL(url)
},
}
const handleDownload = () => {
const currentTask = getCurrentTask()
const currentTaskName = currentTask?.audioMeta.title
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
const task = getCurrentTask()
const name = task?.audioMeta.title || 'note'
const blob = new Blob([selectedContent], { type: 'text/markdown;charset=utf-8' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `${currentTaskName}.md`
link.download = `${name}.md`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
if (status === 'loading') {
return (
<div className="flex h-screen w-full flex-col items-center justify-center space-y-4 text-neutral-500">
@@ -66,30 +380,29 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
</div>
</div>
)
} else if (status === 'idle') {
}
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>
<Idle />
<div className="text-center">
<p className="text-lg font-bold"></p>
<p className="mt-2 text-xs text-neutral-500">YouTube </p>
<p className="text-lg font-bold">"生成笔记"</p>
<p className="mt-2 text-xs text-neutral-500">YouTube </p>
</div>
</div>
)
} else if (status === 'failed') {
}
if (status === 'failed' && !isMultiVersion) {
return (
<div className="flex h-screen w-full flex-col items-center justify-center gap-4 space-y-3">
<Error /> {/* 你可以换成 Failed 动画 */}
<Error />
<div className="text-center">
<p className="text-lg font-bold text-red-500"></p>
<p className="mt-2 mb-2 text-xs text-red-400"></p>
<Button
onClick={() => {
retryTask(currentTask.id)
}}
size="lg"
>
<Button onClick={() => retryTask(currentTask.id)} size="lg">
</Button>
</div>
@@ -98,100 +411,95 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
}
return (
<div className="flex h-full w-full flex-col">
{/* 顶部操作栏 */}
<div className="mb-4 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-xl font-semibold text-neutral-900">
<FileText className="text-primary h-5 w-5" />
</h2>
<div className="flex items-center gap-2">
<Button onClick={handleCopy} variant="outline" size="sm">
<Copy className="mr-1 h-4 w-4" />
{copied ? '已复制' : '复制'}
</Button>
<Button onClick={handleDownload} variant="outline" size="sm">
<Download className="mr-1 h-4 w-4" />
Markdown
</Button>
<div className="flex h-screen w-full flex-col overflow-hidden">
<MarkdownHeader
currentTask={currentTask}
isMultiVersion={isMultiVersion}
currentVerId={currentVerId}
setCurrentVerId={setCurrentVerId}
modelName={modelName}
style={style}
noteStyles={noteStyles}
onCopy={handleCopy}
onDownload={handleDownload}
createAt={createTime}
showTranscribe={showTranscribe}
setShowTranscribe={setShowTranscribe}
showChat={showChat}
setShowChat={setShowChat}
viewMode={viewMode}
setViewMode={setViewMode}
/>
{viewMode === 'map' ? (
<div className="flex w-full flex-1 overflow-hidden bg-white">
<div className={'w-full'}>
<MarkmapEditor
value={selectedContent}
onChange={() => {}}
height="100%" // 根据需求可以设定百分比或固定高度
title={currentTask?.audioMeta?.title || '思维导图'}
/>
</div>
</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 className="flex flex-1 overflow-hidden bg-white py-2">
{selectedContent && selectedContent !== 'loading' && selectedContent !== 'empty' ? (
<>
{showChat === 'full' && currentTask ? (
<div className="h-full w-full">
<ChatPanel taskId={currentTask.id} mode="full" onModeChange={setShowChat} />
</div>
) : (
<>
<ScrollArea className="min-w-0 flex-1">
<div className="px-2">
<VideoBanner
audioMeta={currentTask?.audioMeta}
videoUrl={currentTask?.formData?.video_url}
/>
</div>
<div className={'markdown-body w-full px-2'}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{selectedContent.replace(/^>\s*来源链接:[^\n]*\n*/m, '')}
</ReactMarkdown>
</div>
</ScrollArea>
{showTranscribe && (
<div className={'ml-2 w-2/4'}>
<TranscriptViewer />
</div>
)}
{/* 侧边问答模式markdown + ChatPanel 各占一半 */}
{showChat === 'half' && currentTask && (
<div className="ml-2 h-full w-1/2 shrink-0">
<ChatPanel taskId={currentTask.id} mode="half" onModeChange={setShowChat} />
</div>
)}
</>
)}
</>
) : (
<div className="flex h-full w-full items-center justify-center">
<div className="w-[300px] flex-col justify-items-center">
<div className="bg-primary-light mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<ArrowRight className="text-primary h-8 w-8" />
</div>
<p className="mb-2 text-neutral-600">"生成笔记"</p>
<p className="text-xs text-neutral-500">YouTube等视频网站</p>
</div>
<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>
)}
</div>
)
}
})
MarkdownViewer.displayName = 'MarkdownViewer'
export default MarkdownViewer

View File

@@ -0,0 +1,497 @@
import { useEffect, useRef, useState } from 'react'
import { Markmap } from 'markmap-view'
import { transformer } from '@/lib/markmap.ts'
import { Toolbar } from 'markmap-toolbar'
import 'markmap-toolbar/dist/style.css'
import JSZip from 'jszip'
export interface MarkmapEditorProps {
/** 要渲染的 Markdown 文本 */
value: string
/** 内容变化时的回调 */
onChange: (value: string) => void
/** Toolbar 上要展示的 item id 列表,默认使用 Toolbar.defaultItems */
toolbarItems?: string[]
/** 自定义按钮列表,会依次注册 */
customButtons?: any[]
/** 容器 SVG 的高度,默认为 600px */
height?: string
/** 文档标题用于导出HTML时的文件名 */
title?: string
}
export default function MarkmapEditor({
value,
onChange,
toolbarItems,
customButtons = [],
height = '600px',
title = 'mindmap',
}: MarkmapEditorProps) {
const svgRef = useRef<SVGSVGElement>(null)
const mmRef = useRef<Markmap | undefined>()
const toolbarRef = useRef<HTMLDivElement>(null)
// 用于跟踪是否处于全屏状态
const [isFullscreen, setIsFullscreen] = useState(false)
// 监听全屏状态变化
useEffect(() => {
const handler = () => {
setIsFullscreen(!!document.fullscreenElement)
}
document.addEventListener('fullscreenchange', handler)
return () => {
document.removeEventListener('fullscreenchange', handler)
}
}, [])
// 进入全屏
const enterFullscreen = () => {
const el = svgRef.current?.parentElement
if (el && el.requestFullscreen) {
el.requestFullscreen()
}
}
// 退出全屏
const exitFullscreen = () => {
if (document.exitFullscreen) {
document.exitFullscreen()
}
}
// 导出HTML思维导图
const exportHtml = () => {
try {
const { root } = transformer.transform(value)
const data = JSON.stringify(root)
// 创建HTML内容
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title || 'BiliNote思维导图'}</title>
<style>
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}
#mindmap {
display: block;
width: 100%;
height: 100vh;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script src="https://cdn.jsdelivr.net/npm/markmap-view@0.18.10"></script>
</head>
<body>
<svg id="mindmap"></svg>
<script>
(async () => {
const { markmap } = window;
const { Markmap } = markmap;
const mm = Markmap.create(document.getElementById('mindmap'));
mm.setData(${data});
mm.fit();
})();
</script>
</body>
</html>`;
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'mindmap'}.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('导出HTML失败:', error);
}
};
// 导出SVG思维导图矢量图
const exportSvg = async () => {
try {
if (!svgRef.current || !mmRef.current) return;
const svgEl = svgRef.current;
const mm = mmRef.current;
// 先调用fit()确保显示完整的思维导图内容
await mm.fit();
// 等待渲染完成
await new Promise(resolve => setTimeout(resolve, 100));
// 克隆SVG以避免修改原始SVG
const clonedSvg = svgEl.cloneNode(true) as SVGSVGElement;
// 获取SVG内容的实际边界框
const gElement = svgEl.querySelector('g');
if (gElement) {
const bbox = gElement.getBBox();
// 添加一些边距
const padding = 50;
const viewBoxX = bbox.x - padding;
const viewBoxY = bbox.y - padding;
const viewBoxWidth = bbox.width + padding * 2;
const viewBoxHeight = bbox.height + padding * 2;
// 设置viewBox以确保SVG可以无限缩放
clonedSvg.setAttribute('viewBox', `${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}`);
// 移除固定尺寸让SVG根据viewBox自适应
clonedSvg.removeAttribute('width');
clonedSvg.removeAttribute('height');
// 设置默认尺寸为100%,可以在任何容器中自适应
clonedSvg.setAttribute('width', '100%');
clonedSvg.setAttribute('height', '100%');
// 保持宽高比
clonedSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
}
// 设置SVG的背景为白色
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
style.textContent = 'svg { background-color: white; }';
clonedSvg.insertBefore(style, clonedSvg.firstChild);
// 添加白色背景矩形(确保背景在所有查看器中都是白色)
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
const viewBox = clonedSvg.getAttribute('viewBox')?.split(' ').map(Number) || [0, 0, 800, 600];
bgRect.setAttribute('x', viewBox[0].toString());
bgRect.setAttribute('y', viewBox[1].toString());
bgRect.setAttribute('width', viewBox[2].toString());
bgRect.setAttribute('height', viewBox[3].toString());
bgRect.setAttribute('fill', 'white');
// 插入到最前面作为背景
const firstG = clonedSvg.querySelector('g');
if (firstG) {
clonedSvg.insertBefore(bgRect, firstG);
} else {
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
}
// 确保SVG有正确的命名空间
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
// 序列化SVG
const svgData = new XMLSerializer().serializeToString(clonedSvg);
// 创建下载
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'mindmap'}.svg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('导出SVG失败:', error);
}
};
// 导出XMind格式思维导图
const exportXMind = async () => {
try {
const { root } = transformer.transform(value);
// 生成唯一ID
const generateId = () => Math.random().toString(36).substring(2, 15);
// 解码HTML实体如 &#x5b9e; -> 实,&#12345; -> 对应字符)
const decodeHtmlEntities = (text: string): string => {
if (!text) return text;
// 首先手动处理十六进制数字实体 &#xHHHH;
let decoded = text.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
return String.fromCodePoint(parseInt(hex, 16));
});
// 处理十进制数字实体 &#DDDD;
decoded = decoded.replace(/&#(\d+);/g, (_, dec) => {
return String.fromCodePoint(parseInt(dec, 10));
});
// 使用textarea处理命名实体如 &amp; &lt; &gt; 等)
const textarea = document.createElement('textarea');
textarea.innerHTML = decoded;
return textarea.value;
};
// 清理HTML标签只保留纯文本
const stripHtml = (html: string): string => {
if (!html) return html;
// 先解码HTML实体
let text = decodeHtmlEntities(html);
// 移除HTML标签
const div = document.createElement('div');
div.innerHTML = text;
return div.textContent || div.innerText || text;
};
// 将 markmap 节点转换为 XMind 节点格式
const convertToXMindNode = (node: any, isRoot = false): any => {
const rawTitle = node.content || node.payload?.content || '未命名';
const xmindNode: any = {
id: generateId(),
class: isRoot ? 'topic' : 'topic',
title: stripHtml(rawTitle),
};
if (node.children && node.children.length > 0) {
xmindNode.children = {
attached: node.children.map((child: any) => convertToXMindNode(child, false))
};
}
return xmindNode;
};
const rootTopic = convertToXMindNode(root, true);
const sheetId = generateId();
// XMind content.json 结构
const content = [{
id: sheetId,
class: 'sheet',
title: stripHtml(title) || '思维导图',
rootTopic: rootTopic,
topicPositioning: 'fixed'
}];
// XMind metadata.json
const metadata = {
creator: {
name: 'BiliNote',
version: '1.0.0'
}
};
// XMind manifest.json
const manifest = {
'file-entries': {
'content.json': {},
'metadata.json': {}
}
};
// 使用 JSZip 创建 .xmind 文件
// 直接传入字符串JSZip会自动处理UTF-8编码
const zip = new JSZip();
zip.file('content.json', JSON.stringify(content, null, 2));
zip.file('metadata.json', JSON.stringify(metadata, null, 2));
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
// 生成 ZIP 并下载
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'mindmap'}.xmind`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('导出XMind失败:', error);
}
};
// 导出PNG思维导图
const exportPng = async () => {
try {
if (!svgRef.current || !mmRef.current) return;
const svgEl = svgRef.current;
const mm = mmRef.current;
// 先调用fit()确保显示完整的思维导图内容
await mm.fit();
// 等待渲染完成
await new Promise(resolve => setTimeout(resolve, 100));
// 获取SVG实际尺寸
const svgWidth = svgEl.width.baseVal.value || svgEl.clientWidth || 800;
const svgHeight = svgEl.height.baseVal.value || svgEl.clientHeight || 600;
// 设置足够大的缩放比例以确保高清输出
const scale = 3;
// 克隆SVG以避免修改原始SVG
const clonedSvg = svgEl.cloneNode(true) as SVGSVGElement;
// 设置SVG的背景为白色
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
style.textContent = 'svg { background-color: white; }';
clonedSvg.insertBefore(style, clonedSvg.firstChild);
// 确保SVG有正确的命名空间
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clonedSvg.setAttribute('width', svgWidth.toString());
clonedSvg.setAttribute('height', svgHeight.toString());
// 将SVG转换为Data URI (避免使用Blob URL来解决跨域问题)
const svgData = new XMLSerializer().serializeToString(clonedSvg);
const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
const dataUri = `data:image/svg+xml;base64,${svgBase64}`;
// 创建Canvas
const canvas = document.createElement('canvas');
canvas.width = svgWidth * scale;
canvas.height = svgHeight * scale;
// 获取上下文并设置白色背景
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('无法获取Canvas上下文');
}
// 设置白色背景
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 创建Image对象
const img = new Image();
// 当图片加载完成后在Canvas上绘制并导出
img.onload = () => {
try {
// 应用缩放
ctx.setTransform(scale, 0, 0, scale, 0, 0);
// 绘制SVG
ctx.drawImage(img, 0, 0);
// 重置变换
ctx.setTransform(1, 0, 0, 1, 0, 0);
// 将Canvas转换为PNG Blob
canvas.toBlob((blob) => {
if (blob) {
// 创建下载链接
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'mindmap'}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
console.error('无法创建Blob对象');
}
}, 'image/png');
} catch (err) {
console.error('Canvas处理失败:', err);
}
};
// 设置图片加载错误处理
img.onerror = (error) => {
console.error('导出PNG失败图片加载错误:', error);
};
// 开始加载SVG图像 (使用Data URI而不是Blob URL)
img.src = dataUri;
} catch (error) {
console.error('导出PNG失败:', error);
}
};
// 初始化 Markmap 实例 + Toolbar
useEffect(() => {
if (!svgRef.current || mmRef.current) return
const mm = Markmap.create(svgRef.current)
mmRef.current = mm
if (toolbarRef.current) {
toolbarRef.current.innerHTML = ''
const toolbar = new Toolbar()
toolbar.attach(mm)
customButtons.forEach(btn => toolbar.register(btn))
toolbar.setItems(toolbarItems ?? Toolbar.defaultItems)
toolbarRef.current.appendChild(toolbar.render())
}
}, [customButtons, toolbarItems])
// 当 value 变化时,重新渲染数据
useEffect(() => {
const mm = mmRef.current
if (!mm) return
const { root } = transformer.transform(value)
mm.setData(root).then(() => mm.fit())
}, [value])
// 文本输入变化回调(如果你自行添加 textarea 编辑区)
// const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
// onChange(e.target.value)
// }
return (
<div className="relative flex h-full flex-col bg-white">
{/* 全屏/退出全屏 按钮 */}
<div className="absolute top-2 right-2 z-20 flex space-x-2">
<button
onClick={exportXMind}
className="rounded p-1 hover:bg-gray-200"
title="导出XMind格式"
>
🧠
</button>
<button
onClick={exportSvg}
className="rounded p-1 hover:bg-gray-200"
title="导出SVG矢量图可无限放大"
>
📐
</button>
<button
onClick={exportPng}
className="rounded p-1 hover:bg-gray-200"
title="导出PNG图片"
>
🖼
</button>
<button
onClick={exportHtml}
className="rounded p-1 hover:bg-gray-200"
title="导出HTML可交互"
>
💾
</button>
{isFullscreen ? (
<button
onClick={exitFullscreen}
className="rounded p-1 hover:bg-gray-200"
title="退出全屏"
>
🗗
</button>
) : (
<button onClick={enterFullscreen} className="rounded p-1 hover:bg-gray-200" title="全屏">
🗖
</button>
)}
</div>
{/* 如果需要编辑区,就自己加一个 <textarea> 并把 handleChange 绑上 */}
{/* <textarea value={value} onChange={handleChange} className="mb-2 p-2 border rounded" /> */}
{/* 思维导图区 */}
<svg ref={svgRef} className="w-full flex-1" style={{ height, overflow: 'auto' }} />
{/* 如果你还想保留 markmap-toolbar */}
{/* <div ref={toolbarRef} className="absolute right-2 bottom-2 z-10" /> */}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,86 @@
import { ExternalLink } from 'lucide-react'
import type { AudioMeta } from '@/store/taskStore'
interface VideoBannerProps {
audioMeta?: AudioMeta
videoUrl?: string
}
/** 平台 label 映射 */
const platformLabel: Record<string, string> = {
bilibili: '哔哩哔哩',
youtube: 'YouTube',
douyin: '抖音',
xiaohongshu: '小红书',
}
export default function VideoBanner({ audioMeta, videoUrl }: VideoBannerProps) {
if (!audioMeta) return null
const rawCover = audioMeta.cover_url
// 通过后端代理加载封面,避免跨域/Referrer 限制
const apiBase = String(import.meta.env.VITE_API_BASE_URL || 'api').replace(/\/$/, '')
const coverUrl = rawCover
? `${apiBase}/image_proxy?url=${encodeURIComponent(rawCover)}`
: ''
const title = audioMeta.title
const uploader = audioMeta.raw_info?.uploader || ''
const platform = platformLabel[audioMeta.platform] || audioMeta.platform || ''
const originalUrl = videoUrl || audioMeta.raw_info?.webpage_url || ''
return (
<div className="relative mb-4 overflow-hidden rounded-lg">
{/* 模糊背景封面 */}
<div className="absolute inset-0">
{coverUrl ? (
<img
src={coverUrl}
alt=""
referrerPolicy="no-referrer"
className="h-full w-full object-cover blur-md brightness-[0.4] scale-110"
/>
) : (
<div className="h-full w-full bg-gradient-to-r from-blue-600 to-indigo-700" />
)}
</div>
{/* 内容层 */}
<div className="relative flex items-center gap-4 px-5 py-4">
{/* 封面缩略图 */}
{coverUrl && (
<img
src={coverUrl}
alt={title}
referrerPolicy="no-referrer"
className="h-16 w-28 shrink-0 rounded-md object-cover shadow-md"
/>
)}
{/* 文字信息 */}
<div className="min-w-0 flex-1">
<h2 className="truncate text-base font-bold text-white" title={title}>
{title}
</h2>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-white/70">
{uploader && <span>{uploader}</span>}
{uploader && platform && <span className="text-white/40">·</span>}
{platform && <span>{platform}</span>}
</div>
</div>
{/* 跳转原视频 */}
{originalUrl && (
<a
href={originalUrl}
target="_blank"
rel="noopener noreferrer"
className="flex shrink-0 items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/25"
>
<ExternalLink className="h-3.5 w-3.5" />
<span></span>
</a>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,117 @@
"use client"
import { useTaskStore } from "@/store/taskStore"
import { useEffect, useState, useRef } from "react"
import { Play } from "lucide-react"
import { cn } from "@/lib/utils"
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
interface Segment {
start: number
end: number
text: string
}
interface Task {
transcript?: {
segments?: Segment[]
}
}
const TranscriptViewer = () => {
const getCurrentTask = useTaskStore((state) => state.getCurrentTask)
const currentTaskId = useTaskStore((state) => state.currentTaskId)
const [task, setTask] = useState<Task | null>(null)
const [activeSegment, setActiveSegment] = useState<number | null>(null)
const segmentRefs = useRef<(HTMLDivElement | null)[]>([])
useEffect(() => {
setTask(getCurrentTask())
}, [currentTaskId, getCurrentTask])
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, "0")}`
}
const handleSegmentClick = (index: number) => {
setActiveSegment(index)
// Here you could add functionality to play the audio from this segment
}
const scrollToSegment = (index: number) => {
segmentRefs.current[index]?.scrollIntoView({
behavior: "smooth",
block: "center",
})
}
return (
<div className="transcript-viewer flex h-full w-full flex-col rounded-md border bg-white p-4 shadow-sm">
<h2 className="mb-4 text-lg font-medium"></h2>
{!task?.transcript?.segments?.length ? (
<div className="flex h-full items-center justify-center text-muted-foreground"></div>
) : (
<>
<div className="mb-3 grid grid-cols-[80px_1fr] gap-2 border-b pb-2 text-xs font-medium text-muted-foreground">
<div></div>
<div></div>
</div>
<ScrollArea className="w-full overflow-y-auto">
<div className="space-y-1">
{task.transcript.segments.map((segment, index) => (
<div
key={index}
ref={(el) => (segmentRefs.current[index] = el)}
className={cn(
"group grid grid-cols-[80px_1fr] gap-2 rounded-md p-2 transition-colors hover:bg-slate-50",
activeSegment === index && "bg-slate-100",
)}
onClick={() => handleSegmentClick(index)}
>
<div className="flex items-center gap-1 text-xs text-slate-500">
<button
className="invisible rounded-full p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-700 group-hover:visible"
onClick={(e) => {
e.stopPropagation()
// Add play functionality here
}}
>
{/*<Play className="h-3 w-3" />*/}
</button>
<span>{formatTime(segment.start)}</span>
</div>
<div className="text-sm leading-relaxed text-slate-700">
{segment.speaker && (
<span className="mr-2 rounded bg-slate-200 px-1.5 py-0.5 text-xs font-medium text-slate-700">
{segment.speaker}
</span>
)}
{segment.text}
</div>
</div>
))}
</div>
</ScrollArea>
</>
)}
{task?.transcript?.segments?.length > 0 && (
<div className="mt-4 flex justify-between border-t pt-3 text-xs text-slate-500">
<span> {task.transcript.segments.length} </span>
<span>: {formatTime(task.transcript.segments[task.transcript.segments.length - 1]?.end || 0)}</span>
</div>
)}
</div>
)
}
export default TranscriptViewer

View File

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

View File

@@ -1,4 +1,10 @@
import { BotMessageSquare, Captions, HardDriveDownload, Wrench } from 'lucide-react'
import {
BotMessageSquare,
Captions,
HardDriveDownload,
Info,
Activity,
} from 'lucide-react'
import MenuBar, { IMenuProps } from '@/pages/SettingPage/components/menuBar.tsx'
const Menu = () => {
@@ -9,22 +15,38 @@ const Menu = () => {
icon: <BotMessageSquare />,
path: '/settings/model',
},
// TODO :下一版本升级优化
// {
// id: ' transcriber',
// name: '音频转译配置',
// icon: <Captions />,
// path: '/settings/transcriber',
// },
// //下载配置
// {
// id: 'download',
// name: '下载配置',
// icon: <HardDriveDownload />,
// path: '/settings/download',
// },
{
id: 'transcriber',
name: '音频转写配置',
icon: <Captions />,
path: '/settings/transcriber',
},
{
id: 'download',
name: '下载配置',
icon: <HardDriveDownload />,
path: '/settings/download',
},
// //其他配置
// {
// id: 'prompt',
// name: '提示词设置',
// icon: <SquareChevronRight />,
// path: '/settings/prompt',
// },
{
id: 'monitor',
name: '部署监控',
icon: <Activity />,
path: '/settings/monitor',
},
{
id: 'about',
name: '关于',
icon: <Info />,
path: '/settings/about',
},
// {
// id: 'other',
// name: '其他配置',
// icon: <Wrench />,

View File

@@ -0,0 +1,241 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Server,
Cpu,
AudioLines,
Film,
RefreshCw,
CheckCircle2,
XCircle,
Loader2
} from 'lucide-react'
import { useState, useEffect, useCallback } from 'react'
import { getDeployStatus, DeployStatus } from '@/services/system'
export default function Monitor() {
const [status, setStatus] = useState<DeployStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const fetchStatus = useCallback(async () => {
try {
setLoading(true)
setError(null)
const data = await getDeployStatus()
setStatus(data)
setLastUpdated(new Date())
} catch (err) {
setError('无法连接到后端服务')
setStatus(null)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchStatus()
// 自动刷新(每 30 秒)
const interval = setInterval(fetchStatus, 30000)
return () => clearInterval(interval)
}, [fetchStatus])
const StatusBadge = ({ ok, label }: { ok: boolean; label?: string }) => (
<Badge
variant={ok ? 'default' : 'destructive'}
className={ok ? 'bg-green-500 hover:bg-green-600' : ''}
>
{ok ? (
<><CheckCircle2 className="mr-1 h-3 w-3" />{label || '正常'}</>
) : (
<><XCircle className="mr-1 h-3 w-3" />{label || '异常'}</>
)}
</Badge>
)
return (
<ScrollArea className="h-full overflow-y-auto bg-white">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground text-sm">
</p>
</div>
<div className="flex items-center gap-4">
{lastUpdated && (
<span className="text-muted-foreground text-xs">
: {lastUpdated.toLocaleTimeString()}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={fetchStatus}
disabled={loading}
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
</Button>
</div>
</div>
{error && (
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 p-4 text-red-700">
{error}
</div>
)}
{/* Status Cards */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Backend FastAPI */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-lg font-medium">
<Server className="mr-2 inline h-5 w-5 text-blue-500" />
FastAPI
</CardTitle>
{status && <StatusBadge ok={status.backend.status === 'running'} label="运行中" />}
</CardHeader>
<CardContent>
{loading && !status ? (
<div className="flex items-center gap-2 text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
) : status ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className={status.backend.status === 'running' ? 'font-medium text-green-600' : 'font-medium text-red-600'}>
{status.backend.status === 'running' ? '运行中' : status.backend.status}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-mono">{status.backend.port}</span>
</div>
</div>
) : null}
</CardContent>
</Card>
{/* CUDA GPU */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-lg font-medium">
<Cpu className="mr-2 inline h-5 w-5 text-green-500" />
CUDA GPU
</CardTitle>
{status && <StatusBadge ok={status.cuda.available} label={status.cuda.available ? '已启用' : '未启用'} />}
</CardHeader>
<CardContent>
{loading && !status ? (
<div className="flex items-center gap-2 text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
) : status ? (
<div className="space-y-2 text-sm">
{status.cuda.available ? (
<>
<div className="flex justify-between">
<span className="text-muted-foreground">GPU:</span>
<span className="font-medium">{status.cuda.gpu_name}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">CUDA :</span>
<span className="font-mono">{status.cuda.version}</span>
</div>
</>
) : (
<div className="text-muted-foreground">
CUDA 使 CPU
</div>
)}
</div>
) : null}
</CardContent>
</Card>
{/* Whisper Model */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-lg font-medium">
<AudioLines className="mr-2 inline h-5 w-5 text-purple-500" />
Whisper
</CardTitle>
{status && <StatusBadge ok={true} label="已配置" />}
</CardHeader>
<CardContent>
{loading && !status ? (
<div className="flex items-center gap-2 text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
) : status ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{status.whisper.model_size}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-mono">{status.whisper.transcriber_type}</span>
</div>
</div>
) : null}
</CardContent>
</Card>
{/* FFmpeg */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-lg font-medium">
<Film className="mr-2 inline h-5 w-5 text-orange-500" />
FFmpeg
</CardTitle>
{status && <StatusBadge ok={status.ffmpeg.available} label={status.ffmpeg.available ? '可用' : '不可用'} />}
</CardHeader>
<CardContent>
{loading && !status ? (
<div className="flex items-center gap-2 text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
) : status ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className={status.ffmpeg.available ? 'font-medium text-green-600' : 'font-medium text-red-600'}>
{status.ffmpeg.available ? '已安装' : '未安装'}
</span>
</div>
{!status.ffmpeg.available && (
<div className="text-xs text-red-500">
FFmpeg PATH
</div>
)}
</div>
) : null}
</CardContent>
</Card>
</div>
{/* Footer Info */}
<div className="mt-8 text-center text-xs text-gray-400">
30
</div>
</div>
</ScrollArea>
)
}

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,255 @@
const Transcriber = () => {
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AudioLines, AlertTriangle, CheckCircle2, Download, Loader2, Save, XCircle } from 'lucide-react'
import { toast } from 'react-hot-toast'
import {
getTranscriberConfig,
updateTranscriberConfig,
getModelsStatus,
downloadModel,
TranscriberConfig,
ModelStatus,
} from '@/services/transcriber'
const isWhisperType = (type: string) =>
type === 'fast-whisper' || type === 'mlx-whisper'
export default function Transcriber() {
const [config, setConfig] = useState<TranscriberConfig | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [selectedType, setSelectedType] = useState('')
const [selectedModelSize, setSelectedModelSize] = useState('')
const [modelStatuses, setModelStatuses] = useState<ModelStatus[]>([])
const [mlxModelStatuses, setMlxModelStatuses] = useState<ModelStatus[]>([])
const [mlxAvailable, setMlxAvailable] = useState(false)
const fetchModelsStatus = useCallback(async () => {
try {
const data = await getModelsStatus()
setModelStatuses(data.whisper)
setMlxModelStatuses(data.mlx_whisper)
setMlxAvailable(data.mlx_available)
} catch {
// 静默失败,不阻塞主流程
}
}, [])
useEffect(() => {
const load = async () => {
try {
const data = await getTranscriberConfig()
setConfig(data)
setSelectedType(data.transcriber_type)
setSelectedModelSize(data.whisper_model_size)
} catch {
toast.error('获取转写器配置失败')
} finally {
setLoading(false)
}
}
load()
fetchModelsStatus()
}, [fetchModelsStatus])
// 有下载中的模型时自动轮询状态
useEffect(() => {
const hasDownloading =
modelStatuses.some(m => m.downloading) || mlxModelStatuses.some(m => m.downloading)
if (!hasDownloading) return
const timer = setInterval(fetchModelsStatus, 3000)
return () => clearInterval(timer)
}, [modelStatuses, mlxModelStatuses, fetchModelsStatus])
const handleSave = async () => {
setSaving(true)
try {
const payload: { transcriber_type: string; whisper_model_size?: string } = {
transcriber_type: selectedType,
}
if (isWhisperType(selectedType)) {
payload.whisper_model_size = selectedModelSize
}
await updateTranscriberConfig(payload)
toast.success('转写器配置已保存')
} catch {
toast.error('保存失败')
} finally {
setSaving(false)
}
}
const handleDownload = async (modelSize: string, transcriberType: string) => {
try {
await downloadModel({ model_size: modelSize, transcriber_type: transcriberType })
toast.success(`模型 ${modelSize} 开始下载`)
// 立即刷新状态
setTimeout(fetchModelsStatus, 1000)
} catch {
toast.error('下载请求失败')
}
}
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-neutral-400" />
</div>
)
}
if (!config) {
return <div className="p-6 text-center text-neutral-500"></div>
}
const currentModels = selectedType === 'mlx-whisper' ? mlxModelStatuses : modelStatuses
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 className="space-y-6 p-6">
<div>
<h2 className="text-2xl font-semibold"></h2>
<p className="mt-1 text-sm text-neutral-500">
使
</p>
</div>
{/* 转写引擎选择 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<AudioLines className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Select value={selectedType} onValueChange={setSelectedType}>
<SelectTrigger className="w-full max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.available_types.map(t => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isWhisperType(selectedType) && (
<div className="space-y-2">
<label className="text-sm font-medium">Whisper </label>
<Select value={selectedModelSize} onValueChange={setSelectedModelSize}>
<SelectTrigger className="w-full max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.whisper_model_sizes.map(size => {
const status = currentModels.find(m => m.model_size === size)
return (
<SelectItem key={size} value={size}>
<span className="flex items-center gap-2">
{size}
{status?.downloaded && (
<CheckCircle2 className="h-3 w-3 text-green-500" />
)}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
<p className="text-xs text-neutral-400">
</p>
</div>
)}
{selectedType === 'mlx-whisper' && !config.mlx_whisper_available && (
<Alert variant="warning" className="text-sm">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
MLX Whisper macOS {' '}
<code className="rounded bg-neutral-100 px-1">pip install mlx_whisper</code>
</AlertDescription>
</Alert>
)}
<Button onClick={handleSave} disabled={saving || (selectedType === 'mlx-whisper' && !config.mlx_whisper_available)} className="mt-2">
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
</Button>
</CardContent>
</Card>
{/* Whisper 模型管理 */}
{isWhisperType(selectedType) && currentModels.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Download className="h-5 w-5" />
<span className="text-sm font-normal text-neutral-400">
{selectedType === 'mlx-whisper' ? 'MLX Whisper' : 'Faster Whisper'}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{currentModels.map(model => (
<div
key={model.model_size}
className="flex items-center justify-between rounded-md border px-4 py-3"
>
<div className="flex items-center gap-3">
<span className="font-medium">{model.model_size}</span>
{model.downloaded ? (
<Badge variant="default" className="bg-green-500 hover:bg-green-600">
</Badge>
) : model.downloading ? (
<Badge variant="secondary" className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
</Badge>
) : (
<Badge variant="outline"></Badge>
)}
</div>
{!model.downloaded && !model.downloading && (
<Button
size="sm"
variant="outline"
onClick={() => handleDownload(model.model_size, selectedType)}
>
<Download className="mr-1 h-4 w-4" />
</Button>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}
export default Transcriber

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
export interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
export interface ChatSource {
text: string
source_type: 'markdown' | 'transcript'
section_title?: string
start_time?: number
end_time?: number
}
export interface AskResponse {
answer: string
sources: ChatSource[]
}
export type IndexStatus = 'idle' | 'indexing' | 'indexed' | 'failed'
export interface ChatStatusResponse {
indexed: boolean
status: IndexStatus
}
export const indexTask = async (taskId: string): Promise<void> => {
return await request.post('/chat/index', { task_id: taskId })
}
export const askQuestion = async (data: {
task_id: string
question: string
history: ChatMessage[]
provider_id: string
model_name: string
}): Promise<AskResponse> => {
return await request.post('/chat/ask', data, { timeout: 60000 })
}
export const getChatStatus = async (taskId: string): Promise<ChatStatusResponse> => {
return await request.get(`/chat/status?task_id=${taskId}`)
}

View File

@@ -0,0 +1,9 @@
import request from '@/utils/request.ts'
export const getDownloaderCookie = async id => {
return await request.get('/get_downloader_cookie/' + id)
}
export const updateDownloaderCookie = async (data: { cookie: string; platform: any }) => {
return await request.post('/update_downloader_cookie', data)
}

View File

@@ -18,10 +18,14 @@ export const testConnection = async (data: any) => {
return await request.post('/connect_test', data)
}
export const fetchModels = async (providerId: any) => {
export const fetchModels = async (providerId: string) => {
return await request.get('/model_list/' + providerId)
}
export const fetchEnableModelById = async (id: string) => {
return await request.get('/model_enable/' + id)
}
export async function addModel(data: { provider_id: string; model_name: string }) {
return request.post('/models', data)
}
@@ -29,3 +33,7 @@ export async function addModel(data: { provider_id: string; model_name: string }
export const fetchEnableModels = async () => {
return await request.get('/model_list')
}
export const deleteModelById = async (modelId: number) => {
return await request.get(`/models/delete/${modelId}`)
}

View File

@@ -1,7 +1,6 @@
import request from '@/utils/request'
import toast from 'react-hot-toast'
import { useTaskStore } from '@/store/taskStore'
import request from '@/utils/request'
export const generateNote = async (data: {
video_url: string
platform: string
@@ -12,11 +11,15 @@ export const generateNote = async (data: {
format: Array<string>
style: string
extras?: string
video_understand?: boolean
video_interval?: number
grid_size: Array<number>
}) => {
try {
console.log('generateNote', data)
const response = await request.post('/generate_note', data)
if (response.data.code != 0) {
if (!response) {
if (response.data.msg) {
toast.error(response.data.msg)
}
@@ -27,12 +30,12 @@ export const generateNote = async (data: {
console.log('res', response)
// 成功提示
return response.data
return response
} catch (e: any) {
console.error('❌ 请求出错', e)
// 错误提示
toast.error('笔记生成失败,请稍后重试')
// toast.error('笔记生成失败,请稍后重试')
throw e // 抛出错误以便调用方处理
}
@@ -46,13 +49,9 @@ export const delete_task = async ({ 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 || '删除失败')
}
return res
} catch (e) {
toast.error('请求异常,删除任务失败')
console.error('❌ 删除任务失败:', e)
@@ -62,15 +61,9 @@ export const delete_task = async ({ video_id, platform }) => {
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
return await request.get('/task_status/' + task_id)
} catch (e) {
console.error('❌ 请求出错', e)

View File

@@ -0,0 +1,29 @@
import request from '@/utils/request'
export const systemCheck = async () => {
return await request.get('/sys_health')
}
export interface DeployStatus {
backend: {
status: string
port: number
}
cuda: {
available: boolean
version: string | null
gpu_name: string | null
}
whisper: {
model_size: string
transcriber_type: string
}
ffmpeg: {
available: boolean
}
}
export const getDeployStatus = async (): Promise<DeployStatus> => {
return await request.get('/deploy_status')
}

View File

@@ -0,0 +1,43 @@
import request from '@/utils/request'
export interface TranscriberConfig {
transcriber_type: string
whisper_model_size: string
available_types: { value: string; label: string }[]
whisper_model_sizes: string[]
mlx_whisper_available: boolean
}
export interface ModelStatus {
model_size: string
downloaded: boolean
downloading: boolean
}
export interface ModelsStatusResponse {
whisper: ModelStatus[]
mlx_whisper: ModelStatus[]
mlx_available: boolean
}
export const getTranscriberConfig = async (): Promise<TranscriberConfig> => {
return await request.get('/transcriber_config')
}
export const updateTranscriberConfig = async (data: {
transcriber_type: string
whisper_model_size?: string
}) => {
return await request.post('/transcriber_config', data)
}
export const getModelsStatus = async (): Promise<ModelsStatusResponse> => {
return await request.get('/transcriber_models_status')
}
export const downloadModel = async (data: {
model_size: string
transcriber_type?: string
}) => {
return await request.post('/transcriber_download', data)
}

View File

@@ -0,0 +1,43 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { ChatSource } from '@/services/chat'
export interface ChatMessage {
role: 'user' | 'assistant'
content: string
sources?: ChatSource[]
}
interface ChatState {
chatHistory: Record<string, ChatMessage[]>
addMessage: (taskId: string, msg: ChatMessage) => void
clearChat: (taskId: string) => void
getMessages: (taskId: string) => ChatMessage[]
}
export const useChatStore = create<ChatState>()(
persist(
(set, get) => ({
chatHistory: {},
addMessage: (taskId, msg) =>
set(state => ({
chatHistory: {
...state.chatHistory,
[taskId]: [...(state.chatHistory[taskId] || []), msg],
},
})),
clearChat: (taskId) =>
set(state => {
const { [taskId]: _, ...rest } = state.chatHistory
return { chatHistory: rest }
}),
getMessages: (taskId) => get().chatHistory[taskId] || [],
}),
{
name: 'bilinote-chat-storage',
},
),
)

View File

@@ -1,6 +1,12 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { fetchModels, addModel, fetchEnableModels } from '@/services/model.ts'
import {
fetchModels,
addModel,
fetchEnableModels,
fetchEnableModelById,
deleteModelById
} from '@/services/model'
interface IModel {
id: string
@@ -11,69 +17,93 @@ interface IModel {
root: string
}
interface IModelListItem {
id: string
provider_id: string
model_name: string
created_at?: string
}
interface ModelStore {
models: IModel[]
modelList: []
modelList: IModelListItem[]
loading: boolean
selectedModel: string
loadModels: (providerId: string) => Promise<void>
loadModelsById: (providerId: string) => Promise<IModelListItem[]>
loadEnabledModels: () => Promise<void>
addNewModel: (providerId: string, modelId: string) => Promise<void>
deleteModel: (modelId: number) => Promise<void>
setSelectedModel: (modelId: string) => void
clearModels: () => void
}
export const useModelStore = create<ModelStore>()(
devtools(set => ({
devtools((set) => ({
models: [],
modelList: [],
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('模型列表加载失败')
}
const list = await fetchEnableModels()
set({ modelList: list })
} 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 if (res.data.code === 0 && res.data.data.models.length > 0) {
set({ models: res.data.data.models.data })
} else {
set({ models: [] })
console.error('模型列表加载失败')
}
} catch (error) {
set({ models: [] })
console.error('加载模型出错', error)
console.error('加载可用模型失败', error)
} finally {
set({ loading: false })
}
},
// 新增模型
// 通过 provider 获取该供应商的模型列表
loadModels: async (providerId: string) => {
try {
set({ loading: true })
const res = await fetchModels(providerId)
let models: IModel[] = []
// 兼容 SyncPage 分页对象与普通数组两种格式
if (Array.isArray(res.models)) {
models = res.models
} else if (res.models?.data && Array.isArray(res.models.data)) {
models = res.models.data
}
set({ models })
} catch (error) {
set({ models: [] })
console.error('加载模型列表失败', error)
} finally {
set({ loading: false })
}
},
// 单独获取某个供应商下已启用模型
loadModelsById: async (providerId: string) => {
try {
const models = await fetchEnableModelById(providerId)
console.log('获取供应商模型成功:', models)
return models
} catch (error) {
console.error('加载供应商模型失败', error)
return []
}
},
// 新增模型逻辑
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 => ({
set((state) => ({
models: [
...state.models,
{
@@ -87,17 +117,30 @@ export const useModelStore = create<ModelStore>()(
],
}))
} else {
console.error('新增模型失败')
console.error('新增模型失败', res.msg)
}
} catch (error) {
console.error('添加模型出错', error)
}
},
// 设置选中的模型
setSelectedModel: modelId => set({ selectedModel: modelId }),
// 删除模型
deleteModel: async (modelId: number) => {
try {
await deleteModelById(modelId)
// 删除后更新本地状态(可选)
set((state) => ({
models: state.models.filter((model) => model.id !== modelId.toString())
}))
} catch (error) {
console.error('删除模型失败', error)
}
},
// 清空
clearModels: () => set({ models: [], selectedModel: '' }),
// 切换选中模型
setSelectedModel: (modelId: string) => set({ selectedModel: modelId }),
// 清空
clearModels: () => set({ models: [], selectedModel: '', modelList: [] }),
}))
)
)

View File

@@ -1,5 +1,5 @@
import { create } from 'zustand'
import { IProvider } from '@/types'
import { IProvider, IResponse } from '@/types'
import {
addProvider,
getProviderById,
@@ -38,10 +38,9 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
// 设置整个 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)
const res:IResponse<IProvider> = await getProviderById(id)
const item = res
return {
id: item.id,
name: item.name,
@@ -51,9 +50,7 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
type: item.type,
enabled: item.enabled,
}
} else {
console.log('Provider not found')
}
},
addNewProvider: async (provider: IProvider) => {
const payload = {
@@ -66,7 +63,9 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
if (res.data.code === 0) {
const item = res.data.data
console.log('Provider ', item)
await get().fetchProviderList()
return item
}
} catch (error) {
console.error('Error fetching provider:', error)
@@ -94,16 +93,18 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
getProviderList: () => get().provider,
fetchProviderList: async () => {
try {
const res = await getProviderList()
if (res.data.code === 0) {
const res = await getProviderList()
set({
provider: res.data.data.map(
provider: res.map(
(item: {
id: string
name: string
logo: string
api_key: string
base_url: string
type: string
enabled: number
}) => {
return {
id: item.id,
@@ -117,7 +118,6 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
}
),
})
}
} catch (error) {
console.error('Error fetching provider list:', error)
}

View File

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

View File

@@ -7,3 +7,8 @@ export interface IProvider {
baseUrl: string
enabled: number
}
export interface IResponse<T> {
code: number
data:T
msg: string
}

View File

@@ -1,8 +1,59 @@
import axios from 'axios'
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import toast from 'react-hot-toast'
const request = axios.create({
baseURL: '/api', // 默认请求路径前缀
// 统一响应类型
export interface IResponse<T = any> {
code: number;
msg: string;
data: T;
}
// 模拟一个消息提示函数 (实际项目中会使用UI库的组件如 Ant Design 的 message 或 Element UI 的 ElMessage)
// This function simulates a message display (in real projects, you'd use a UI library's component)
const baseURL = import.meta.env.VITE_API_BASE_URL;
// 创建实例
const request: AxiosInstance = axios.create({
baseURL: baseURL || '/api',
timeout: 10000,
})
});
// 响应拦截器
request.interceptors.response.use(
(response: AxiosResponse<IResponse>) => {
const res = response.data;
if (res.code === 0) {
// 业务成功,可以根据需要显示成功消息,或者不显示(如果操作本身就是可见的)
// showMessage('success', res.msg || '操作成功'); // 如果需要显示成功消息
return res.data; // 返回data部分简化后续业务代码
} else {
// 业务错误,统一显示后端返回的错误消息
// Business error, uniformly display the error message returned from the backend
toast.error(res.msg || '操作失败,请稍后再试');
return Promise.reject(res); // 拒绝Promise让业务代码可以捕获并处理
}
},
(error) => {
// 网络/服务器错误
const res = error?.response?.data as IResponse | undefined;
if (res) {
// 如果后端有返回错误信息,则显示后端信息
// If the backend returns an error message, display it
toast.error(res.msg || '服务器错误,请稍后再试');
return Promise.reject(res);
} else {
// 没有响应数据(如网络中断),显示通用网络错误
// No response data (e.g., network disconnected), display generic network error
toast.error( '请求失败,请检查网络连接或稍后再试')
return Promise.reject({
code: -1,
msg: '请求失败,请检查网络连接',
data: null
} as IResponse);
}
}
);
export default request

View File

@@ -1,30 +1,54 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
import { fileURLToPath } from 'url'
import tailwindcss from '@tailwindcss/vite'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd() + '/../')
// 在 Docker 环境中,父目录可能没有 .env 文件,使用当前目录
const envDir = process.env.DOCKER_BUILD ? __dirname : path.resolve(__dirname, '../')
const env = loadEnv(mode, envDir)
const apiBaseUrl = env.VITE_API_BASE_URL
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://127.0.0.1:8483'
const port = parseInt(env.VITE_FRONTEND_PORT || '3015', 10)
return {
base: './',
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
markdown: ['react-markdown', 'react-syntax-highlighter', 'remark-gfm', 'remark-math', 'rehype-katex'],
markmap: ['markmap-lib', 'markmap-view', 'markmap-toolbar', 'markmap-common'],
vendor: ['react', 'react-dom', 'react-router-dom'],
},
},
},
},
server: {
host: '0.0.0.0',
port: 5173,
port: port,
allowedHosts: true, // 允许任意域名访问
proxy: {
'/api': {
target: apiBaseUrl,
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, '/api'),
},
'/static': {
target: apiBaseUrl,
changeOrigin: true,
rewrite: path => path.replace(/^\/static/, '/static'),
},
},
},
}

74
CLAUDE.md Normal file
View File

@@ -0,0 +1,74 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
BiliNote is an AI video note generation tool. It extracts content from video links (Bilibili, YouTube, Douyin, Kuaishou, local files) and generates structured Markdown notes using LLM models. Full-stack app with a FastAPI backend, React frontend, and optional Tauri desktop packaging.
## Development Commands
### Backend (Python 3.11 + FastAPI)
```bash
cd backend
pip install -r requirements.txt
python main.py # Starts on 0.0.0.0:8483
```
### Frontend (React 19 + Vite + TypeScript)
```bash
cd BillNote_frontend
pnpm install
pnpm dev # Dev server on port 3015, proxies /api to backend
pnpm build # Production build
pnpm lint # ESLint
```
### Docker
```bash
docker-compose up # Web stack (backend + frontend + nginx)
docker-compose -f docker-compose.gpu.yml up # GPU variant
```
### Desktop (Tauri)
```bash
cd backend && ./build.sh # Build PyInstaller backend binary
cd BillNote_frontend && pnpm tauri build
```
## Architecture
**Backend** (`backend/`) — FastAPI app, entry point `main.py`:
- `app/routers/` — API routes: `note.py` (generation), `provider.py`, `model.py`, `config.py`
- `app/services/` — Business logic: `note.py` (NoteGenerator orchestrates the full pipeline), `task_serial_executor.py` (task queue)
- `app/downloaders/` — Platform adapters (bilibili, youtube, douyin, kuaishou, local) with shared `base.py` interface
- `app/transcriber/` — Speech-to-text engines (fast-whisper, groq, bcut, kuaishou, mlx-whisper) with factory in `transcriber_provider.py`
- `app/gpt/` — LLM integration with factory pattern (`gpt_factory.py`), prompt templates (`prompt.py`, `prompt_builder.py`), and `request_chunker.py` for long transcripts
- `app/db/` — SQLite + SQLAlchemy: DAO pattern (`provider_dao.py`, `model_dao.py`, `video_task_dao.py`), models in `models/`
- `app/utils/``response.py` (ResponseWrapper for consistent JSON), `video_helper.py` (screenshots via FFmpeg), `export.py` (PDF/DOCX)
- `events/` (root level) — Blinker signal system for post-processing (e.g., temp file cleanup after transcription)
**Frontend** (`BillNote_frontend/src/`) — React 19 + Vite + Tailwind + shadcn/ui:
- `pages/HomePage/` — Main note generation UI: `NoteForm.tsx` (input), `MarkdownViewer.tsx` (preview), `MarkmapComponent.tsx` (mind map)
- `pages/SettingPage/` — LLM provider management, system monitoring, transcriber config
- `store/` — Zustand stores: `taskStore`, `modelStore`, `configStore`, `providerStore`
- `services/` — Axios API clients matching backend routes
- `hooks/useTaskPolling.ts` — Polls task status every 3 seconds
- `components/ui/` — shadcn/ui (Radix-based) components
- Path alias: `@``./src`
**Core Workflow**: User submits URL → task queued → download video → extract audio (FFmpeg) → transcribe (Whisper/Groq/etc) → generate notes (LLM) → frontend polls for completion → display Markdown + mind map.
## Key Configuration
- **Ports**: Backend 8483, Frontend dev 3015, Docker maps 3015→80
- **Environment**: Root `.env` (copy from `.env.example`). LLM API keys are configured through the UI, not env vars.
- **Database**: SQLite at `backend/app/db/bili_note.db`, auto-initialized on first run
- **FFmpeg**: Required system dependency for video/audio processing
- **Vite proxy**: Dev server proxies `/api` and `/static` to backend (configured in `vite.config.ts`, reads env from parent dir)
## Code Style
- **Frontend**: ESLint + Prettier (2 spaces, single quotes, 100 char width, Tailwind plugin). TypeScript strict mode.
- **Backend**: Python with type hints. No configured linter. Uses Pydantic models for validation.
- **Note**: The frontend directory is named `BillNote_frontend` (not "Bili").

112
Dockerfile.complete Normal file
View File

@@ -0,0 +1,112 @@
# === 阶段1构建 Backend ===
FROM python:3.11-slim AS backend-builder
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
RUN set -ex && \
rm -f /etc/apt/sources.list && \
rm -rf /etc/apt/sources.list.d/* && \
echo "deb http://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
echo "deb http://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb http://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
apt-get update && \
apt-get install -y --no-install-recommends ffmpeg && \
rm -rf /var/lib/apt/lists/*
ENV PATH="/usr/bin:${PATH}"
ENV HF_ENDPOINT=https://hf-mirror.com
WORKDIR /tmp/backend
# 先复制 requirements.txt 利用层缓存
COPY ./backend/requirements.txt /tmp/backend/requirements.txt
RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt
COPY ./backend /tmp/backend
# === 阶段2构建 Frontend ===
FROM node:18-alpine AS frontend-builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /tmp/frontend
# 先复制 package.json 利用依赖层缓存
COPY ./BillNote_frontend/package.json ./
RUN pnpm install
COPY ./BillNote_frontend /tmp/frontend
# 设置环境变量,告诉 vite.config.ts 这是 Docker 构建
ENV DOCKER_BUILD=1
RUN pnpm run build
# === 阶段3完整应用镜像 ===
FROM python:3.11-slim
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
# 安装必要的运行时依赖
RUN set -ex && \
rm -f /etc/apt/sources.list && \
rm -rf /etc/apt/sources.list.d/* && \
echo "deb http://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
echo "deb http://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb http://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
apt-get update && \
apt-get install -y --no-install-recommends ffmpeg nginx supervisor procps && \
rm -rf /var/lib/apt/lists/*
ENV PATH="/usr/bin:${PATH}"
ENV HF_ENDPOINT=https://hf-mirror.com
ENV PYTHONUNBUFFERED=1
# 复制 Python 依赖
COPY --from=backend-builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=backend-builder /usr/local/bin /usr/local/bin
# 复制 backend 代码
COPY ./backend /app/backend
WORKDIR /app/backend
# 复制前端静态文件到 nginx
COPY --from=frontend-builder /tmp/frontend/dist /usr/share/nginx/html
# 配置 nginx
RUN rm -rf /etc/nginx/conf.d/default.conf
COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf
# 创建 supervisor 配置
RUN mkdir -p /var/log/supervisor
COPY <<EOF /etc/supervisor/conf.d/supervisord.conf
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:nginx]
command=nginx -g "daemon off;"
stdout_logfile=/var/log/supervisor/nginx.log
stderr_logfile=/var/log/supervisor/nginx.log
autorestart=true
priority=10
[program:backend]
command=python main.py
directory=/app/backend
stdout_logfile=/var/log/supervisor/backend.log
stderr_logfile=/var/log/supervisor/backend.log
autorestart=true
priority=20
environment=BACKEND_PORT="8483",BACKEND_HOST="0.0.0.0"
EOF
# 修改 nginx 配置以使用本地 backend
RUN sed -i 's/proxy_pass http:\/\/backend:8483/proxy_pass http:\/\/127.0.0.1:8483/g' /etc/nginx/conf.d/default.conf && \
sed -i 's/proxy_pass http:\/\/frontend:80/proxy_pass http:\/\/127.0.0.1:8080/g' /etc/nginx/conf.d/default.conf
# 启动 supervisor
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

153
README.md
View File

@@ -3,17 +3,17 @@
<p align="center">
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
</p>
<h1 align="center" > BiliNote v1.3.0</h1>
<h1 align="center" > BiliNote v2.0.0</h1>
</div>
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
<p align="center">
<img src="https://img.shields.io/badge/license-MIT-blue.svg" />
<img src="https://img.shields.io/badge/frontend-react-blue" />
<img src="https://img.shields.io/badge/frontend-react%2019-blue" />
<img src="https://img.shields.io/badge/backend-fastapi-green" />
<img src="https://img.shields.io/badge/GPT-openai%20%7C%20deepseek%20%7C%20qwen-ff69b4" />
<img src="https://img.shields.io/badge/docker-compose-blue" />
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" />
<img src="https://img.shields.io/badge/status-active-success" />
<img src="https://img.shields.io/github/stars/jefferyhcool/BiliNote?style=social" />
</p>
@@ -22,28 +22,48 @@
## ✨ 项目简介
BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、YouTube、抖音等视频链接自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转等功能。
BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、YouTube、抖音等视频链接自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转、AI 问答等功能。
## 🚀 体验地址
[https://www.bilinote.app](https://www.bilinote.app)
注意:由于 项目部署在 Cloudflare Pages访问速度可能存在一些问题请耐心等待。
## 📝 使用文档
详细文档可以查看[这里](https://docs.bilinote.app/)
## 📦 Windows 打包版
本项目提供了 Windows 系统的 exe 文件,可在[release](https://github.com/JefferyHcool/BiliNote/releases/tag/v1.1.1)进行下载。**注意一定要在没有中文路径的环境下运行。**
## 体验地址
可以通过访问 [这里](https://www.bilinote.app/) 进行体验,速度略慢,不支持长视频。
## 📦 桌面版下载
本项目提供了 Windows 和 macOS 桌面客户端,可在 [Releases](https://github.com/JefferyHcool/BiliNote/releases) 页面下载最新版本。
> Windows 用户请注意:一定要在没有中文路径的环境下运行。
## 🔧 功能特性
- 支持多平台Bilibili、YouTube、本地视频、抖音(后续会加入更多平台)
- 支持多平台Bilibili、YouTube、本地视频、抖音、快手
- 支持返回笔记格式选择
- 支持笔记风格选择
- 支持自行配置 GPT 大模型
- 本地模型音频转写(支持 Fast-Whisper
- 支持多模态视频理解
- 支持多版本记录保留
- 支持自行配置 GPT 大模型OpenAI、DeepSeek、Qwen 等)
- 本地模型音频转写(支持 Fast-Whisper、MLX-Whisper、Groq、BCut
- GPT 大模型总结视频内容
- 自动生成结构化 Markdown 笔记
- 可选插入截图(自动截取)
- 可选内容跳转链接(关联原视频)
- 任务记录与历史回看
- 基于 RAG 的笔记内容 AI 问答(支持 Function Calling
- 笔记顶部视频封面 Banner 展示
- 工作区和生成历史面板支持折叠/展开
### v2.0.0 新增
- 基于 RAG 的笔记内容 AI 问答功能,支持半屏/全屏模式
- AI 问答支持 Function Calling模型可主动查询原文数据
- RAG 索引支持视频元信息(标题、作者、简介、标签等)
- AI 回复支持 Markdown 渲染
- 笔记顶部新增视频封面 Banner
- 工作区和生成历史面板支持折叠/展开
- 笔记开头添加来源链接功能
- YouTube 字幕优先获取,有字幕时跳过音频下载
- 性能优化与转写器配置改进
## 📸 截图预览
![screenshot](./doc/image1.png)
@@ -54,7 +74,34 @@ BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、Y
## 🚀 快速开始
### 1. 克隆仓库
### 方式一Docker 部署(推荐)
确保已安装 Docker直接拉取预构建镜像运行
```bash
docker pull ghcr.io/jefferyhcool/bilinote:latest
docker run -d -p 80:80 \
-v bilinote-data:/app/backend/data \
--name bilinote \
ghcr.io/jefferyhcool/bilinote:latest
```
访问:`http://localhost`
也可以使用 docker-compose 本地构建:
```bash
# 标准部署
docker-compose up -d
# GPU 加速部署(需要 NVIDIA GPU
docker-compose -f docker-compose.gpu.yml up -d
```
### 方式二:源码部署
#### 1. 克隆仓库
```bash
git clone https://github.com/JefferyHcool/BiliNote.git
@@ -62,7 +109,7 @@ cd BiliNote
mv .env.example .env
```
### 2. 启动后端FastAPI
#### 2. 启动后端FastAPI
```bash
cd backend
@@ -70,19 +117,20 @@ pip install -r requirements.txt
python main.py
```
### 3. 启动前端Vite + React
#### 3. 启动前端Vite + React
```bash
cd BiliNote_frontend
cd BillNote_frontend
pnpm install
pnpm dev
```
访问:`http://localhost:5173`
访问:`http://localhost:3015`
## ⚙️ 依赖说明
### 🎬 FFmpeg
本项目依赖 ffmpeg 用于音频处理与转码,必须安装:
本项目依赖 ffmpeg 用于音频处理与转码,源码部署时必须安装:
```bash
# Mac (brew)
brew install ffmpeg
@@ -94,6 +142,8 @@ sudo apt install ffmpeg
# 请从官网下载安装https://ffmpeg.org/download.html
```
> ⚠️ 若系统无法识别 ffmpeg请将其加入系统环境变量 PATH
>
> Docker 部署已内置 FFmpeg无需额外安装。
### 🚀 CUDA 加速(可选)
若你希望更快地执行音频转写任务,可使用具备 NVIDIA GPU 的机器,并启用 fast-whisper + CUDA 加速版本:
@@ -102,56 +152,48 @@ sudo apt install ffmpeg
### 🐳 使用 Docker 一键部署
确保你已安装 Docker 和 Docker Compose
确保你已安装 Docker,然后直接拉取预构建镜像运行
#### 1. 克隆本项目
```bash
git clone https://github.com/JefferyHcool/BiliNote.git
cd BiliNote
mv .env.example .env
# 拉取最新镜像
docker pull ghcr.io/jefferyhcool/bilinote:latest
# 运行容器
docker run -d -p 80:80 \
-v bilinote-data:/app/backend/data \
--name bilinote \
ghcr.io/jefferyhcool/bilinote:latest
```
#### 2. 启动 Docker Compose
``` bash
docker compose up --build
```
默认端口:
前端:http://localhost:${FRONTEND_PORT}
访问:`http://localhost`
后端http://localhost:${BACKEND_PORT}
也可以使用 docker-compose 本地构建:
.env 文件中可自定义端口与环境配置。
```bash
# 标准部署
docker-compose up -d
## ⚙️ 环境变量配置
> ⚠️ v.1.1.0 以后无需通过环境变量配置 AI
后端 `.env` 示例:
```ini
API_BASE_URL=http://localhost:8000
OUT_DIR=note_results
IMAGE_BASE_URL=/static/screenshots
MODEl_PROVIDER=openai
OPENAI_API_KEY=sk-xxxxxx
DEEP_SEEK_API_KEY=xxx
QWEN_API_KEY=xxx
# GPU 加速部署(需要 NVIDIA GPU
docker-compose -f docker-compose.gpu.yml up -d
```
## 🧠 TODO
- [ ] 支持抖音及快手等视频平台
- [x] 支持抖音及快手等视频平台
- [x] 支持前端设置切换 AI 模型切换、语音转文字模型
- [x] AI 摘要风格自定义(学术风、口语风、重点提取等)
- [ ] 笔记导出为 PDF / Word / Notion
- [x] 加入更多模型支持
- [x] 加入更多音频转文本模型支持
- [x] 基于 RAG 的笔记内容 AI 问答
- [ ] 笔记导出为 PDF / Word / Notion
### Contact and Join-联系和加入社区
- BiliNote 交流QQ群785367111
- BiliNote 交流微信群:
<img src="https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250501113553.png" alt="wechat" style="zoom:33%;" />
年会恢复更新以后放出最新社区地址
## 🔎代码参考
- 本项目中的 `抖音下载功能` 部分代码参考引用自:[Evil0ctal/Douyin_TikTok_Download_API](https://github.com/Evil0ctal/Douyin_TikTok_Download_API)
## 📜 License
@@ -160,4 +202,13 @@ MIT License
---
💬 你的支持与反馈是我持续优化的动力!欢迎 PR、提 issue、Star ⭐️
## Buy Me a Coffee / 捐赠
如果你觉得项目对你有帮助,考虑支持我一下吧
<div style='display:inline;'>
<img width='30%' src='https://common-1304618721.cos.ap-chengdu.myqcloud.com/8986c9eb29c356a0cfa3d470c23d3b6.jpg'/>
<img width='30%' src='https://common-1304618721.cos.ap-chengdu.myqcloud.com/2a049ea298b206bcd0d8b8da3219d6b.jpg'/>
</div>
## ⭐ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=JefferyHcool/BiliNote&type=Date)](https://www.star-history.com/#JefferyHcool/BiliNote&Date)

View File

@@ -1,20 +1,27 @@
FROM python:3.11-slim
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
RUN rm -f /etc/apt/sources.list && \
rm -rf /etc/apt/sources.list.d/* && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb https://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
echo "deb https://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb https://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
apt-get update && \
apt-get install -y ffmpeg && \
apt-get install -y --no-install-recommends ffmpeg curl && \
rm -rf /var/lib/apt/lists/*
# 确保 PATH 中包含 ffmpeg 路径(可选)
ENV PATH="/usr/bin:${PATH}"
ENV HF_ENDPOINT=https://hf-mirror.com
WORKDIR /app
# 先复制 requirements.txt 利用层缓存
COPY ./backend/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt
# 再复制应用代码(频繁变动不影响 pip 缓存层)
COPY ./backend /app
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
CMD ["python", "main.py"]

27
backend/Dockerfile.gpu Normal file
View File

@@ -0,0 +1,27 @@
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
RUN rm -f /etc/apt/sources.list && \
rm -rf /etc/apt/sources.list.d/* && \
echo "deb https://${APT_MIRROR}/ubuntu jammy main restricted universe multiverse" > /etc/apt/sources.list && \
echo "deb https://${APT_MIRROR}/ubuntu jammy-updates main restricted universe multiverse" >> /etc/apt/sources.list && \
echo "deb https://${APT_MIRROR}/ubuntu jammy-security main restricted universe multiverse" >> /etc/apt/sources.list && \
apt-get update && \
apt-get install -y --no-install-recommends ffmpeg python3-pip curl && \
rm -rf /var/lib/apt/lists/*
ENV HF_ENDPOINT=https://hf-mirror.com
WORKDIR /app
# 先复制 requirements.txt 利用层缓存
COPY ./backend/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt && \
pip install --no-cache-dir -i ${PIP_INDEX} 'transformers[torch]>=4.23'
# 再复制应用代码
COPY ./backend /app
CMD ["python3", "main.py"]

View File

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

View File

View File

@@ -21,15 +21,7 @@
"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"
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1"
},
{
"id": "Claude",
@@ -38,5 +30,29 @@
"logo": "Claude",
"api_key": "",
"base_url": "https://"
},
{
"id": "gemini",
"name": "Gemini",
"type": "built-in",
"logo": "Gemini",
"api_key": "",
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/"
},
{
"id": "groq",
"name": "Groq",
"type": "built-in",
"logo": "Groq",
"api_key": "",
"base_url": "https://api.groq.com/openai/v1"
},
{
"id": "ollama",
"name": "ollama",
"type": "built-in",
"logo": "Ollama",
"api_key": "",
"base_url": "http://127.0.0.1:11434/v1"
}
]

45
backend/app/db/engine.py Normal file
View File

@@ -0,0 +1,45 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from dotenv import load_dotenv
load_dotenv()
# 默认 SQLite如果想换 PostgreSQL 或 MySQL可以直接改 .env
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///bili_note.db")
# SQLite 需要特定连接参数,其他数据库不需要
engine_args = {}
if DATABASE_URL.startswith("sqlite"):
engine_args["connect_args"] = {"check_same_thread": False}
_pool_args = {}
if not DATABASE_URL.startswith("sqlite"):
_pool_args = {
"pool_size": int(os.getenv("DB_POOL_SIZE", "10")),
"max_overflow": int(os.getenv("DB_MAX_OVERFLOW", "20")),
"pool_pre_ping": True,
}
engine = create_engine(
DATABASE_URL,
echo=os.getenv("SQLALCHEMY_ECHO", "false").lower() == "true",
**engine_args,
**_pool_args,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_engine():
return engine
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,9 @@
from app.db.models.models import Model
from app.db.models.providers import Provider
from app.db.models.video_tasks import VideoTask
from app.db.engine import get_engine, Base
def init_db():
engine = get_engine()
Base.metadata.create_all(bind=engine)

View File

@@ -1,58 +1,67 @@
from app.db.sqlite_client import get_connection
from app.db.engine import get_db
from app.db.models.models import Model
def get_model_by_provider_and_name(provider_id: int, model_name: str):
db = next(get_db())
try:
model = db.query(Model).filter_by(provider_id=provider_id, model_name=model_name).first()
if model:
return {
"id": model.id,
"provider_id": model.provider_id,
"model_name": model.model_name,
"created_at": model.created_at,
}
return None
finally:
db.close()
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()
db = next(get_db())
try:
model = Model(provider_id=provider_id, model_name=model_name)
db.add(model)
db.commit()
db.refresh(model)
return {
"id": model.id,
"provider_id": model.provider_id,
"model_name": model.model_name,
"created_at": model.created_at,
}
finally:
db.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]
db = next(get_db())
try:
models = db.query(Model).filter_by(provider_id=provider_id).all()
return [{"id": m.id, "model_name": m.model_name} for m in models]
finally:
db.close()
# 删除某个模型
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()
db = next(get_db())
try:
model = db.query(Model).filter_by(id=model_id).first()
if model:
db.delete(model)
db.commit()
finally:
db.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]
db = next(get_db())
try:
models = db.query(Model).all()
return [
{"id": m.id, "provider_id": m.provider_id, "model_name": m.model_name}
for m in models
]
finally:
db.close()

View File

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String, DateTime, func, ForeignKey
from app.db.engine import Base
class Model(Base):
__tablename__ = "models"
id = Column(Integer, primary_key=True, autoincrement=True)
provider_id = Column(Integer, nullable=False)
model_name = Column(String, nullable=False)
created_at = Column(DateTime, server_default=func.now())

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, String, Integer, DateTime, func
from sqlalchemy.orm import declarative_base
from app.db.engine import Base
class Provider(Base):
__tablename__ = "providers"
id = Column(String, primary_key=True)
name = Column(String, nullable=False)
logo = Column(String, nullable=False)
type = Column(String, nullable=False)
api_key = Column(String, nullable=False)
base_url = Column(String, nullable=False)
enabled = Column(Integer, default=1)
created_at = Column(DateTime, server_default=func.now())

View File

@@ -0,0 +1,14 @@
from sqlalchemy import Column, Integer, String, DateTime, func
from sqlalchemy.orm import declarative_base
from app.db.engine import Base
class VideoTask(Base):
__tablename__ = "video_tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
video_id = Column(String, nullable=False)
platform = Column(String, nullable=False)
task_id = Column(String, unique=True, nullable=False)
created_at = Column(DateTime, server_default=func.now())

View File

@@ -1,220 +1,129 @@
import json
import os
from app.db.sqlite_client import get_connection
import sys
from app.db.models.providers import Provider
from app.utils.logger import get_logger
from app.db.engine import get_engine, Base, get_db
logger = get_logger(__name__)
def get_builtin_providers_path():
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
base_path = os.path.dirname(__file__)
return os.path.join(base_path, 'builtin_providers.json')
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')
db = next(get_db())
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
if db.query(Provider).count() > 0:
logger.info("Providers already exist, skipping seed.")
return
json_path = get_builtin_providers_path()
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}")
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)
db.add(Provider(
id=p['id'],
name=p['name'],
api_key=p['api_key'],
base_url=p['base_url'],
logo=p['logo'],
type=p['type'],
enabled=p.get('enabled', 1)
))
conn.commit()
db.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
)
""")
db.close()
def insert_provider(id: str, name: str, api_key: str, base_url: str, logo: str, type_: str, enabled: int = 1):
db = next(get_db())
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()
provider = Provider(id=id, name=name, api_key=api_key, base_url=base_url, logo=logo, type=type_, enabled=enabled)
db.add(provider)
db.commit()
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
finally:
db.close()
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")
db = next(get_db())
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}")
return db.query(Provider).filter_by(enabled=1).all()
finally:
db.close()
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,))
db = next(get_db())
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}")
return db.query(Provider).filter_by(name=name).first()
finally:
db.close()
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,))
def get_provider_by_id(id: str):
db = next(get_db())
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}")
return db.query(Provider).filter_by(id=id).first()
finally:
db.close()
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")
db = next(get_db())
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}")
return db.query(Provider).all()
finally:
db.close()
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()
db = next(get_db())
try:
cursor.execute(sql, values)
conn.commit()
conn.close()
logger.info(f"Provider updated successfully. id: {id}, updated_fields: {fields}")
provider = db.query(Provider).filter_by(id=id).first()
if not provider:
logger.warning(f"Provider {id} not found for update.")
return
for key, value in kwargs.items():
if hasattr(provider, key):
setattr(provider, key, value)
db.commit()
logger.info(f"Provider updated successfully. id: {id}, updated_fields: {list(kwargs.keys())}")
except Exception as e:
logger.error(f"Failed to update provider: {e}")
finally:
db.close()
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,))
def delete_provider(id: str):
db = next(get_db())
try:
conn.commit()
conn.close()
logger.info(f"Provider deleted successfully. id: {id}")
provider = db.query(Provider).filter_by(id=id).first()
if provider:
db.delete(provider)
db.commit()
logger.info(f"Provider deleted successfully. id: {id}")
except Exception as e:
logger.error(f"Failed to delete provider: {e}")
logger.error(f"Failed to delete provider: {e}")
finally:
db.close()

View File

@@ -1,78 +1,61 @@
from .sqlite_client import get_connection
from app.db.models.video_tasks import VideoTask
from app.db.engine import get_db
from app.utils.logger import get_logger
logger = get_logger(__name__)
def init_video_task_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 video_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id TEXT NOT NULL,
platform TEXT NOT NULL,
task_id TEXT NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
try:
conn.commit()
conn.close()
logger.info("video_tasks table created successfully.")
except Exception as e:
logger.error(f"Failed to create video_tasks table: {e}")
# 插入任务
def insert_video_task(video_id: str, platform: str, task_id: str):
db = next(get_db())
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO video_tasks (video_id, platform, task_id)
VALUES (?, ?, ?)
""", (video_id, platform, task_id))
conn.commit()
conn.close()
logger.info(f"Video task inserted successfully."
f"video_id: {video_id}"
f"platform: {platform}"
f"task_id: {task_id}")
task = VideoTask(video_id=video_id, platform=platform, task_id=task_id)
db.add(task)
db.commit()
db.refresh(task)
logger.info(f"Video task inserted successfully. video_id: {video_id}, platform: {platform}, task_id: {task_id}")
except Exception as e:
logger.error(f"Failed to insert video task: {e}")
finally:
db.close()
# 查询任务(最新一条)
def get_task_by_video(video_id: str, platform: str):
db = next(get_db())
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT task_id FROM video_tasks
WHERE video_id = ? AND platform = ?
ORDER BY created_at DESC
LIMIT 1
""", (video_id, platform))
result = cursor.fetchone()
conn.close()
if result is None:
task = (
db.query(VideoTask)
.filter_by(video_id=video_id, platform=platform)
.order_by(VideoTask.created_at.desc())
.first()
)
if task:
logger.info(f"Task found for video_id: {video_id} and platform: {platform}")
return task.task_id
else:
logger.info(f"No task found for video_id: {video_id} and platform: {platform}")
logger.info(f"Task found for video_id: {video_id} and platform: {platform}")
return result[0] if result else None
return None
except Exception as e:
logger.error(f"Failed to get task by video: {e}")
finally:
db.close()
# 删除任务
def delete_task_by_video(video_id: str, platform: str):
db = next(get_db())
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
DELETE FROM video_tasks
WHERE video_id = ? AND platform = ?
""", (video_id, platform))
conn.commit()
conn.close()
logger.info(f"Task deleted for video_id: {video_id} and platform: {platform}")
tasks = (
db.query(VideoTask)
.filter_by(video_id=video_id, platform=platform)
.all()
)
for task in tasks:
db.delete(task)
db.commit()
logger.info(f"Task(s) deleted for video_id: {video_id} and platform: {platform}")
except Exception as e:
logger.error(f"Failed to delete task by video: {e}")
logger.error(f"Failed to delete task by video: {e}")
finally:
db.close()

View File

@@ -8,6 +8,6 @@ def timeit(func):
result = func(*args, **kwargs)
end = time.perf_counter()
duration = end - start
print(f"⏱️ {func.__name__} executed in {duration:.4f} seconds")
print(f"{func.__name__} executed in {duration:.4f} seconds")
return result
return wrapper

View File

@@ -5,6 +5,7 @@ from typing import Optional, Union
from app.enmus.note_enums import DownloadQuality
from app.models.notes_model import AudioDownloadResult
from app.models.transcriber_model import TranscriptResult
from os import getenv
QUALITY_MAP = {
"fast": "32",
@@ -21,7 +22,8 @@ class Downloader(ABC):
@abstractmethod
def download(self, video_url: str, output_dir: str = None,
quality: DownloadQuality = "fast", need_video: Optional[bool] = False) -> AudioDownloadResult:
quality: DownloadQuality = "fast", need_video: Optional[bool] = False,
skip_download: bool = False) -> AudioDownloadResult:
'''
:param need_video:
@@ -36,3 +38,15 @@ class Downloader(ABC):
def download_video(self, video_url: str,
output_dir: Union[str, None] = None) -> str:
pass
def download_subtitles(self, video_url: str, output_dir: str = None,
langs: list = None) -> Optional[TranscriptResult]:
'''
尝试获取平台字幕(人工字幕或自动生成字幕)
:param video_url: 视频链接
:param output_dir: 输出路径
:param langs: 优先语言列表,如 ['zh-Hans', 'zh', 'en']
:return: TranscriptResult 或 None无字幕时
'''
return None

View File

@@ -1,12 +1,22 @@
import os
import json
import logging
from abc import ABC
from typing import Union, Optional
from typing import Union, Optional, List
from pathlib import Path
import yt_dlp
from app.downloaders.base import Downloader, DownloadQuality, QUALITY_MAP
from app.models.notes_model import AudioDownloadResult
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
from app.utils.path_helper import get_data_dir
from app.utils.url_parser import extract_video_id
logger = logging.getLogger(__name__)
# B站 cookies 文件路径
BILIBILI_COOKIES_FILE = os.getenv("BILIBILI_COOKIES_FILE", "cookies.txt")
class BilibiliDownloader(Downloader, ABC):
@@ -69,10 +79,19 @@ class BilibiliDownloader(Downloader, ABC):
"""
下载视频,返回视频文件路径
"""
if output_dir is None:
output_dir = get_data_dir()
os.makedirs(output_dir, exist_ok=True)
print("video_url",video_url)
video_id=extract_video_id(video_url, "bilibili")
video_path = os.path.join(output_dir, f"{video_id}.mp4")
if os.path.exists(video_path):
return video_path
# 检查是否已经存在
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
ydl_opts = {
@@ -101,4 +120,198 @@ class BilibiliDownloader(Downloader, ABC):
os.remove(video_path)
return f"视频文件已删除: {video_path}"
else:
return f"视频文件未找到: {video_path}"
return f"视频文件未找到: {video_path}"
def download_subtitles(self, video_url: str, output_dir: str = None,
langs: List[str] = None) -> Optional[TranscriptResult]:
"""
尝试获取B站视频字幕
:param video_url: 视频链接
:param output_dir: 输出路径
:param langs: 优先语言列表
:return: TranscriptResult 或 None
"""
if output_dir is None:
output_dir = get_data_dir()
if not output_dir:
output_dir = self.cache_data
os.makedirs(output_dir, exist_ok=True)
if langs is None:
langs = ['zh-Hans', 'zh', 'zh-CN', 'ai-zh', 'en', 'en-US']
video_id = extract_video_id(video_url, "bilibili")
ydl_opts = {
'writesubtitles': True,
'writeautomaticsub': True,
'subtitleslangs': langs,
'subtitlesformat': 'srt/json3/best', # 支持多种格式
'skip_download': True,
'outtmpl': os.path.join(output_dir, f'{video_id}.%(ext)s'),
'quiet': True,
}
# 添加 cookies 支持
cookies_path = Path(BILIBILI_COOKIES_FILE)
if not cookies_path.is_absolute():
# 相对于 backend 目录
cookies_path = Path(__file__).parent.parent.parent / BILIBILI_COOKIES_FILE
if cookies_path.exists():
ydl_opts['cookiefile'] = str(cookies_path)
logger.info(f"使用 cookies 文件: {cookies_path}")
else:
logger.warning(f"B站 cookies 文件不存在: {cookies_path},字幕获取可能失败")
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
# 查找下载的字幕文件
subtitles = info.get('requested_subtitles') or {}
if not subtitles:
logger.info(f"B站视频 {video_id} 没有可用字幕")
return None
# 按优先级查找字幕
detected_lang = None
sub_info = None
for lang in langs:
if lang in subtitles:
detected_lang = lang
sub_info = subtitles[lang]
break
# 如果按优先级没找到,取第一个可用的(排除弹幕)
if not detected_lang:
for lang, info_item in subtitles.items():
if lang != 'danmaku': # 排除弹幕
detected_lang = lang
sub_info = info_item
break
if not sub_info:
logger.info(f"B站视频 {video_id} 没有可用字幕(排除弹幕)")
return None
# 检查是否有内嵌数据yt-dlp 有时直接返回字幕内容)
if 'data' in sub_info and sub_info['data']:
logger.info(f"直接从返回数据解析字幕: {detected_lang}")
return self._parse_srt_content(sub_info['data'], detected_lang)
# 查找字幕文件
ext = sub_info.get('ext', 'srt')
subtitle_file = os.path.join(output_dir, f"{video_id}.{detected_lang}.{ext}")
if not os.path.exists(subtitle_file):
logger.info(f"字幕文件不存在: {subtitle_file}")
return None
# 根据格式解析字幕文件
if ext == 'json3':
return self._parse_json3_subtitle(subtitle_file, detected_lang)
else:
with open(subtitle_file, 'r', encoding='utf-8') as f:
return self._parse_srt_content(f.read(), detected_lang)
except Exception as e:
logger.warning(f"获取B站字幕失败: {e}")
return None
def _parse_srt_content(self, srt_content: str, language: str) -> Optional[TranscriptResult]:
"""
解析 SRT 格式字幕内容
:param srt_content: SRT 字幕文本内容
:param language: 语言代码
:return: TranscriptResult
"""
import re
try:
segments = []
# SRT 格式: 序号\n时间戳\n文本\n\n
pattern = r'(\d+)\n(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\n(.*?)(?=\n\n|\n\d+\n|$)'
matches = re.findall(pattern, srt_content, re.DOTALL)
for match in matches:
idx, start_time, end_time, text = match
text = text.strip()
if not text:
continue
# 转换时间格式 00:00:00,000 -> 秒
def time_to_seconds(t):
parts = t.replace(',', '.').split(':')
return float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])
segments.append(TranscriptSegment(
start=time_to_seconds(start_time),
end=time_to_seconds(end_time),
text=text
))
if not segments:
return None
full_text = ' '.join(seg.text for seg in segments)
logger.info(f"成功解析B站SRT字幕{len(segments)}")
return TranscriptResult(
language=language,
full_text=full_text,
segments=segments,
raw={'source': 'bilibili_subtitle', 'format': 'srt'}
)
except Exception as e:
logger.warning(f"解析SRT字幕失败: {e}")
return None
def _parse_json3_subtitle(self, subtitle_file: str, language: str) -> Optional[TranscriptResult]:
"""
解析 json3 格式字幕文件
:param subtitle_file: 字幕文件路径
:param language: 语言代码
:return: TranscriptResult
"""
try:
with open(subtitle_file, 'r', encoding='utf-8') as f:
data = json.load(f)
segments = []
events = data.get('events', [])
for event in events:
# json3 格式中时间单位是毫秒
start_ms = event.get('tStartMs', 0)
duration_ms = event.get('dDurationMs', 0)
# 提取文本
segs = event.get('segs', [])
text = ''.join(seg.get('utf8', '') for seg in segs).strip()
if text: # 只添加非空文本
segments.append(TranscriptSegment(
start=start_ms / 1000.0,
end=(start_ms + duration_ms) / 1000.0,
text=text
))
if not segments:
return None
full_text = ' '.join(seg.text for seg in segments)
logger.info(f"成功解析B站字幕{len(segments)}")
return TranscriptResult(
language=language,
full_text=full_text,
segments=segments,
raw={'source': 'bilibili_subtitle', 'file': subtitle_file}
)
except Exception as e:
logger.warning(f"解析字幕文件失败: {e}")
return None

View File

@@ -13,13 +13,14 @@ from app.downloaders.base import Downloader
from app.downloaders.douyin_helper.abogus import ABogus
from app.enmus.note_enums import DownloadQuality
from app.models.audio_model import AudioDownloadResult
from app.services.cookie_manager import CookieConfigManager
from app.utils.path_helper import get_data_dir
from dotenv import load_dotenv
load_dotenv()
DOUYIN_DOMAIN = "https://www.douyin.com"
cfm=CookieConfigManager()
def get_timestamp(unit: str = "milli"):
"""
根据给定的单位获取当前时间 (Get the current time based on the given unit)
@@ -112,7 +113,7 @@ class DouyinDownloader(Downloader):
def __init__(self, cookie=None):
super().__init__()
self.headers_config = DouyinConfig.HEADERS.copy()
self.headers_config["Cookie"] = os.getenv('DOUYIN_COOKIES')
self.headers_config["Cookie"] = cfm.get('douyin')
print(self.headers_config)
self.proxies_config = DouyinConfig.PROXIES.copy()
self.ttwid_config = DouyinConfig.TTWID.copy()
@@ -144,53 +145,59 @@ class DouyinDownloader(Downloader):
return ""
def gen_real_msToken(self) -> str:
payload = json.dumps(
{
"magic": self.ms_token_config["magic"],
"version": self.ms_token_config["version"],
"dataType": self.ms_token_config["dataType"],
"strData": self.ms_token_config["strData"],
"tspFromClient": get_timestamp(),
try:
payload = json.dumps(
{
"magic": self.ms_token_config["magic"],
"version": self.ms_token_config["version"],
"dataType": self.ms_token_config["dataType"],
"strData": self.ms_token_config["strData"],
"tspFromClient": get_timestamp(),
}
)
headers = {
"User-Agent": self.headers_config["User-Agent"],
"Content-Type": "application/json",
}
)
headers = {
"User-Agent": self.headers_config["User-Agent"],
"Content-Type": "application/json",
}
transport = httpx.HTTPTransport(retries=5)
with httpx.Client(transport=transport) as client:
try:
response = client.post(
self.ms_token_config["url"], content=payload, headers=headers
)
response.raise_for_status()
transport = httpx.HTTPTransport(retries=5)
with httpx.Client(transport=transport) as client:
try:
response = client.post(
self.ms_token_config["url"], content=payload, headers=headers
)
response.raise_for_status()
msToken = str(httpx.Cookies(response.cookies).get("msToken"))
if len(msToken) not in [120, 128]:
raise ValueError("响应内容:{0} Douyin msToken API 的响应内容不符合要求。".format(msToken))
msToken = str(httpx.Cookies(response.cookies).get("msToken"))
if len(msToken) not in [120, 128]:
raise ValueError("响应内容:{0} Douyin msToken API 的响应内容不符合要求。".format(msToken))
return msToken
except Exception as e:
raise ValueError("Douyin msToken API 请求失败:{0}".format(e))
return msToken
except Exception as e:
raise ValueError("Douyin msToken API 请求失败:{0}".format(e))
except Exception as e:
raise ValueError("Douyin msToken API{0}".format(e))
def fetch_video_info(self, video_url: str) -> json:
aweme_id = self.extract_video_id(video_url)
kwargs = self.headers_config
print("kwargs:", kwargs)
base_params = BaseRequestModel().model_dump()
base_params["msToken"] = self.gen_real_msToken()
base_params["aweme_id"] = aweme_id
bogus = ABogus()
ab_value = bogus.get_value(base_params)
a_bogus = quote(ab_value, safe='')
print(base_params)
query_str = urlencode(base_params)
full_url = f"{DOUYIN_DOMAIN}/aweme/v1/web/aweme/detail/?{query_str}&a_bogus={a_bogus}"
print("Request URL:", full_url)
try:
aweme_id = self.extract_video_id(video_url)
kwargs = self.headers_config
print("@kwargs:", kwargs)
base_params = BaseRequestModel().model_dump()
base_params["msToken"] = self.gen_real_msToken()
base_params["aweme_id"] = aweme_id
bogus = ABogus()
ab_value = bogus.get_value(base_params)
a_bogus = quote(ab_value, safe='')
print("@a_bogus:", a_bogus)
print(base_params)
query_str = urlencode(base_params)
full_url = f"{DOUYIN_DOMAIN}/aweme/v1/web/aweme/detail/?{query_str}&a_bogus={a_bogus}"
print("Request URL:", full_url)
response = requests.get(full_url, headers=kwargs)
print("Response JSON:", response.content)
@@ -207,55 +214,66 @@ class DouyinDownloader(Downloader):
quality: DownloadQuality = "fast",
need_video: Optional[bool] = False
) -> AudioDownloadResult:
print(
f"正在下载视频: {video_url},保存路径: {output_dir},质量: {quality}"
)
if output_dir is None:
output_dir = get_data_dir()
if not output_dir:
output_dir = self.cache_data
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
video_data = self.fetch_video_info(video_url)
output_path = output_path % {
"id": video_data['aweme_detail']['aweme_id'],
"ext": "mp3",
}
url = video_data['aweme_detail']['music']['play_url']['uri']
# 下载音频
audio_data = requests.get(url)
with open(output_path, 'wb') as f:
f.write(audio_data.content)
print(url)
tags = []
for tag in video_data['aweme_detail']['video_tag']:
if tag['tag_name']:
tags.append(tag['tag_name'])
return AudioDownloadResult(
file_path=output_path,
title=video_data['aweme_detail']['item_title'],
duration=video_data['aweme_detail']['video']['duration'],
cover_url=video_data['aweme_detail']['video']['cover_original_scale']['url_list'][0] if
video_data['aweme_detail']['video']['cover'] else video_data['video']['big_thumbs']['img_url'],
platform="douyin",
video_id=video_data['aweme_detail']['aweme_id'],
raw_info={
'tags': video_data['aweme_detail']['caption'] + ''.join(tags),
},
video_path=None # ❗音频下载不包含视频路径
)
def download_video(self, video_url: str, output_dir: Union[str, None] = None) -> str:
try:
print(
f"正在下载视频: {video_url},保存路径: {output_dir},质量: {quality}"
)
if output_dir is None:
output_dir = get_data_dir()
if not output_dir:
output_dir = self.cache_data
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
video_data = self.fetch_video_info(video_url)
output_path = output_path % {
"id": video_data['aweme_detail']['aweme_id'],
"ext": "mp3",
}
url = video_data['aweme_detail']['music']['play_url']['uri']
# 下载音频
audio_data = requests.get(url)
with open(output_path, 'wb') as f:
f.write(audio_data.content)
print(url)
tags = []
for tag in video_data['aweme_detail']['video_tag']:
if tag['tag_name']:
tags.append(tag['tag_name'])
return AudioDownloadResult(
file_path=output_path,
title=video_data['aweme_detail']['item_title'],
duration=video_data['aweme_detail']['video']['duration'],
cover_url=video_data['aweme_detail']['video']['cover_original_scale']['url_list'][0] if
video_data['aweme_detail']['video']['cover'] else video_data['video']['big_thumbs']['img_url'],
platform="douyin",
video_id=video_data['aweme_detail']['aweme_id'],
raw_info={
'tags': video_data['aweme_detail']['caption'] + ''.join(tags),
},
video_path=None # ❗音频下载不包含视频路径
)
except Exception as e:
raise e
def download_video(self, video_url: str, output_dir: Union[str, None] = None) -> str:
try:
if output_dir is None:
output_dir = get_data_dir()
if not output_dir:
output_dir = self.cache_data
os.makedirs(output_dir, exist_ok=True)
video_id = self.extract_video_id(video_url)
video_path = os.path.join(output_dir, f"{video_id}.mp4")
if os.path.exists(video_path):
return video_path
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
video_data = self.fetch_video_info(video_url)

View File

@@ -0,0 +1,97 @@
import os
import subprocess
from abc import ABC
from typing import Union, Optional
import requests
from app.downloaders.base import Downloader
from app.downloaders.kuaishou_helper.kuaishou import KuaiShou
from app.enmus.note_enums import DownloadQuality
from app.models.audio_model import AudioDownloadResult
from app.utils.path_helper import get_data_dir
class KuaiShouDownloader(Downloader, ABC):
def __init__(self):
super().__init__()
def download(
self,
video_url: str,
output_dir: Union[str, None] = None,
quality: str = "fast",
need_video: Optional[bool] = False
) -> AudioDownloadResult:
if output_dir is None:
output_dir = get_data_dir()
if not output_dir:
output_dir = self.cache_data
os.makedirs(output_dir, exist_ok=True)
ks = KuaiShou()
video_raw_info = ks.run(video_url)
print(video_raw_info)
photo_info = video_raw_info['visionVideoDetail']['photo']
video_id = photo_info['id']
title = photo_info['caption'].strip().replace('\n', '').replace(' ', '_')[:50]
mp4_path = os.path.join(output_dir, f"{video_id}.mp4")
mp3_path = os.path.join(output_dir, f"{video_id}.mp3")
if os.path.exists(mp3_path):
print(f"[已存在] 跳过下载: {mp3_path}")
return AudioDownloadResult(
file_path=mp3_path,
title=title,
duration=photo_info['duration'],
cover_url=photo_info['coverUrl'],
platform="kuaishou",
video_id=video_id,
raw_info={
'tags': ','.join(tag['name'] for tag in video_raw_info.get('tags', []) if tag.get('name'))
},
video_path=mp4_path
)
# 下载 mp4 视频
resp = requests.get(photo_info['photoUrl'], stream=True)
if resp.status_code == 200:
with open(mp4_path, "wb") as f:
for chunk in resp.iter_content(1024 * 1024):
f.write(chunk)
else:
raise Exception(f"视频下载失败: {resp.status_code}")
# 使用 ffmpeg 转换为 mp3
try:
subprocess.run([
"ffmpeg", "-y", "-i", mp4_path, "-vn", "-acodec", "libmp3lame", mp3_path
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError:
raise Exception("ffmpeg 转换 MP3 失败")
return AudioDownloadResult(
file_path=mp3_path,
title=photo_info['caption'],
duration=photo_info['duration'],
cover_url=photo_info['coverUrl'],
platform="kuaishou",
video_id=video_id,
raw_info={
'tags': ','.join(tag['name'] for tag in video_raw_info.get('tags', []) if tag.get('name'))
},
video_path=mp4_path
)
def download_video(
self,
video_url: str,
output_dir: Union[str, None] = None,
) -> str:
print('self.download(video_url, output_dir).video_path',self.download(video_url, output_dir).video_path)
return self.download(video_url, output_dir).video_path
if __name__ == '__main__':
ks = KuaiShouDownloader()
ks.download('https://v.kuaishou.com/2vBqX74 王宝强携手刘昊然、岳云鹏上演精彩名场面 全程高能 看一遍笑一遍 "唐探1900 "快成长计划 ...更多')

View File

@@ -0,0 +1,101 @@
import logging
import os
import re
import requests
from dotenv import load_dotenv
from app.services.cookie_manager import CookieConfigManager
from app.utils.logger import get_logger
KUAISHOU_API_BASE = 'https://www.kuaishou.com/graphql'
KUAISHOU_URL = "https://www.kuaishou.com/"
load_dotenv()
headers = {
'Accept-Language': 'zh-CN,zh;q=0.9',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
# 'Cookie': 'did=web_9e8cfa4403000587b9e7d67233e6b04c; didv=1719811812378; kpf=PC_WEB; clientid=3; kpn=KUAISHOU_VISION',
'Origin': 'https://www.kuaishou.com',
'Pragma': 'no-cache',
'Referer': 'https://www.kuaishou.com/',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
'accept': '*/*',
'content-type': 'application/json',
'sec-ch-ua': '"Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
# 'Cookie':cookies.strip()
}
logger = get_logger(__name__)
cfm=CookieConfigManager()
class KuaiShou:
def __init__(self):
self.header = headers.copy()
self.cookie = None
@staticmethod
def _extract_kuaishou_link(text):
url = re.findall('http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', text)
return url[0]
def get_photo_id(self, url):
response = requests.get(url, allow_redirects=True, headers=self.header)
real_url = response.url
# 提取short—video/后面的id
pattern = re.compile(r'short-video/(\w+)')
match = pattern.search(real_url)
return match.group().split('/')[1]
def get_temp_cookies(self):
is_exist = cfm.get('kuaishou')
print(is_exist)
if is_exist:
return is_exist
res = requests.get(url=KUAISHOU_URL, headers=self.header, allow_redirects=True)
cookie_string = '; '.join([f"{k}={v}" for k, v in res.cookies.get_dict().items()])
return cookie_string
def get_video_details(self, url, photo_id):
json_data = {
'operationName': 'visionVideoDetail',
"variables": {"photoId": photo_id, "page": "detail"},
"query": "query visionVideoDetail($photoId: String, $type: String, $page: String, $webPageArea: String) {\n visionVideoDetail(photoId: $photoId, type: $type, page: $page, webPageArea: $webPageArea) {\n status\n type\n author {\n id\n name\n following\n headerUrl\n __typename\n }\n photo {\n id\n duration\n caption\n likeCount\n realLikeCount\n coverUrl\n photoUrl\n liked\n timestamp\n expTag\n llsid\n viewCount\n videoRatio\n stereoType\n croppedPhotoUrl\n manifest {\n mediaType\n businessType\n version\n adaptationSet {\n id\n duration\n representation {\n id\n defaultSelect\n backupUrl\n codecs\n url\n height\n width\n avgBitrate\n maxBitrate\n m3u8Slice\n qualityType\n qualityLabel\n frameRate\n featureP2sp\n hidden\n disableAdaptive\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n tags {\n type\n name\n __typename\n }\n commentLimit {\n canAddComment\n __typename\n }\n llsid\n danmakuSwitch\n __typename\n }\n}\n"
}
response = requests.post(url=KUAISHOU_API_BASE, headers=self.header, json=json_data)
if response.status_code == 200:
response.raise_for_status()
return response.json()
else:
return None
def run(self, url):
real_url = self._extract_kuaishou_link(url)
if not real_url:
logger.error(f"快手视频 URL 解析失败 {url}")
cookies = self.get_temp_cookies()
if not cookies:
logger.error(f"快手视频 cookies 解析失败 {url},请考虑设置环境变量 KUAISHOU_COOKIES")
self.header['Cookie'] = cookies.strip()
photo_id = self.get_photo_id(real_url)
if photo_id is None:
logger.error(f"快手视频 ID 解析失败 {url}")
video_details = self.get_video_details(real_url, photo_id)
print(video_details)
if video_details is None:
logger.error(f"快手视频详情解析失败 {url}")
return video_details['data']
if __name__ == '__main__':
ks = KuaiShou()
ks.run(
'https://v.kuaishou.com/2vBqX74 王宝强携手刘昊然、岳云鹏上演精彩名场面 全程高能 看一遍笑一遍 "唐探1900 "快成长计划 ...更多')

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