Compare commits

..

194 Commits

Author SHA1 Message Date
Jianwu Huang
2cd43770eb Merge pull request #318 from linwinfan/feat/zustand-indexeddb 2026-05-05 14:34:18 +08:00
Jianwu Huang
e3134f2078 Merge pull request #333 from Lizhilin/fix/bilibili-cookie-injection 2026-05-05 14:32:32 +08:00
Jianwu Huang
118b7357c5 Merge pull request #336 from liang09255/fix-switch-0502 2026-05-05 14:32:22 +08:00
liang09255
c9ab763f1b fix(frontend): 修复供应商开关切换不能实时生效的问题 2026-05-02 21:51:33 +08:00
Lizhilin
c5e08e1ec6 fix: BilibiliDownloader 从 CookieConfigManager 读取 cookie 并注入 yt-dlp cookiefile 2026-04-28 23:20:34 +08:00
linwinfan
20fcf2c29c feat(frontend): migrate Zustand persist storage to IndexedDB
- Add idb-keyval dependency for IndexedDB support
- Configure persist middleware to use IndexedDB
- Improves persistence reliability in browser environments
2026-04-07 15:51:53 +08:00
Jianwu Huang
8fa3101f0f Merge pull request #311 from JefferyHcool/feature/optimize-build
fix(docker): 优化 Vite 配置以支持 Docker 构建环境
2026-03-23 18:55:35 +08:00
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
Jianwu Huang
1f52185539 Merge pull request #310 from JefferyHcool/feature/optimize-build
fix(ci): 移除 pnpm install 的 --frozen-lockfile 标志
2026-03-23 18:52:56 +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
Jianwu Huang
c9ee3e6957 Merge pull request #309 from JefferyHcool/feature/optimize-build
fix(frontend): 修复 ESM 模式下 __dirname 未定义的问题
2026-03-23 18:47:42 +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
Jianwu Huang
274e5d25a8 Merge pull request #308 from JefferyHcool/feature/optimize-build
fix(docker): 修复前端构建时缺少 .env 文件的问题
2026-03-23 18:43:58 +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
Jianwu Huang
e7db5124ea Merge pull request #307 from JefferyHcool/feature/optimize-build
fix(docker): 修复 apt-get 安装失败问题
2026-03-23 18:39:15 +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
Jianwu Huang
198f01d079 Merge pull request #306 from JefferyHcool/feature/optimize-build
chore: 删除 ffmpeg 二进制文件并更新 .gitignore
2026-03-23 18:32:54 +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
7da4f9587b Merge pull request #305 from JefferyHcool/feature/optimize-build
Feature/optimize build
2026-03-23 18:26:34 +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
139 changed files with 32222 additions and 2036 deletions

View File

@@ -1,321 +1,35 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Git 和 IDE
.git
.github
.idea/
.vscode/
.DS_Store
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
BiliNote/pnpm-lock.yaml
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Tauri 构建产物(非常大)
BillNote_frontend/src-tauri/target
BillNote_frontend/src-tauri/bin
# Coverage directory used by tools like istanbul
coverage
*.lcov
# 运行时数据
backend/data
backend/static
backend/models
backend/logs
backend/uploads
backend/*.db
backend/note_results
backend/bin/
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
# 依赖和构建缓存
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
.BiliNote-dev/*
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
build/
*.tar
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
.idea/
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
# 环境文件
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
/backend/data/*
/backend/static/*
/backend/note_tasks.db
/backend/bin/
/backend/logs/
/backend/note_results
/backend/models
/backend/.idea/*
/backend/bili_note.db
/backend/uploads/*
/BiliNote_frontend/.idea/*
.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/*

8
.gitignore vendored
View File

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

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,25 +1,23 @@
# === 前端构建阶段 ===
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
# --- 阶段2使用 nginx 作为静态服务器 ---
FROM nginx:1.25-alpine
# 删除默认配置(可选)
RUN rm -rf /etc/nginx/conf.d/default.conf
COPY ./BillNote_frontend/deploy/default.conf /etc/nginx/conf.d/default.conf
# 拷贝构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
COPY --from=builder /app/dist /usr/share/nginx/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",
@@ -24,6 +25,7 @@
"@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",
@@ -31,10 +33,16 @@
"clsx": "^2.1.1",
"fuse.js": "^7.1.0",
"github-markdown-css": "^5.8.1",
"idb-keyval": "^6.2.2",
"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",
@@ -61,6 +69,7 @@
"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",

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,55 +1,71 @@
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,HashRouter, 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 Prompt from '@/pages/SettingPage/Prompt.tsx'
import AboutPage from '@/pages/SettingPage/about.tsx'
import Downloader from '@/pages/SettingPage/Downloader.tsx'
import DownloaderForm from '@/components/Form/DownloaderForm/Form.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 (
<>
<HashRouter>
<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 />} />
<BrowserRouter>
<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" element={<Transcriber />}></Route>*/}
{/*<Route path="prompt" element={<Prompt />}></Route>*/}
<Route path="download" element={<Downloader />}>
<Route path=":id" element={<DownloaderForm />} />
</Route>
<Route path="about" element={<AboutPage />}></Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</HashRouter>
</Routes>
</Suspense>
</BrowserRouter>
</>
)
}

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

@@ -36,7 +36,7 @@ const DownloaderForm = () => {
setLoading(true) // 🔁 切换平台时显示 loading
try {
const res = await getDownloaderCookie(id)
const cookie = res?.data?.data?.cookie || ''
const cookie = res?.cookie || ''
form.reset({ cookie }) // ✅ 正确重置表单值
} catch (e) {
toast.error('加载 Cookie 失败: ' + e)

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

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

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

@@ -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,24 +7,39 @@ 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 { 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">
@@ -33,7 +48,22 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
</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)}>
@@ -49,26 +79,91 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
</div>
</header>
<ScrollArea className="flex-1 overflow-auto">
<div className=' p-4' >{NoteForm}</div>
<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">
<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 className="">{History}</div>
<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

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

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

@@ -1,167 +1,211 @@
"use client"
'use client'
import { useEffect, useState } from "react"
import { Copy, Download } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Badge } from "@/components/ui/badge"
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
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
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
}: NoteHeaderProps) {
const [copied, setCopied] = useState(false)
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)
useEffect(() => {
let timer: NodeJS.Timeout
if (copied) {
timer = setTimeout(() => setCopied(false), 2000)
}
return () => clearTimeout(timer)
}, [copied])
const styleName = noteStyles.find((v) => v.value === style)?.label || style
const handleCopy = () => {
onCopy()
setCopied(true)
}
const reversedMarkdown: VersionNote[] =
Array.isArray(currentTask?.markdown) ? [...currentTask!.markdown].reverse() : []
const styleName = noteStyles.find(v => v.value === style)?.label || style
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, "-")
}
const reversedMarkdown: VersionNote[] = Array.isArray(currentTask?.markdown)
? [...currentTask!.markdown].reverse()
: []
return (
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-3 border-b bg-white/95 py-2 px-4 backdrop-blur-sm">
{/* 左侧区域:版本 + 标签 + 创建时间 */}
<div className="flex flex-wrap items-center gap-3">
{isMultiVersion && (
<Select value={currentVerId} onValueChange={setCurrentVerId}>
<SelectTrigger className="h-8 w-[160px] text-sm">
<div className="flex items-center">
{(() => {
const idx = currentTask?.markdown.findIndex(v => v.ver_id === currentVerId)
return idx !== -1 ? `版本(${currentVerId.slice(0, 6)}` : ''
})()}
</div>
</SelectTrigger>
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, '-')
}
<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>
)}
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>
<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>
<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>
)}
{createAt && (
<div className="text-sm text-muted-foreground">
: {formatDate(createAt)}
</div>
)}
</div>
<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>
{/* 右侧操作按钮 */}
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleCopy} variant="ghost" size="sm" className="h-8 px-2">
<Copy className="mr-1.5 h-4 w-4" />
<span className="text-sm">{copied ? "已复制" : "复制"}</span>
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</TooltipProvider>
{createAt && (
<div className="text-muted-foreground text-sm">: {formatDate(createAt)}</div>
)}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownload} variant="ghost" size="sm" className="h-8 px-2">
<Download className="mr-1.5 h-4 w-4" />
<span className="text-sm"> Markdown</span>
</Button>
</TooltipTrigger>
<TooltipContent> Markdown </TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={
() => {
setShowTranscribe(!showTranscribe)
}
} variant="ghost" size="sm" className="h-8 px-2">
{/*<Download className="mr-1.5 h-4 w-4" />*/}
<span className="text-sm"></span>
</Button>
</TooltipTrigger>
<TooltipContent ></TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
)
{/* 右侧操作按钮 */}
<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,7 +1,7 @@
import { useState, useEffect } 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, ArrowRight,Play,ExternalLink } from 'lucide-react'
import { Copy, Download, ArrowRight, Play, ExternalLink } from 'lucide-react'
import { toast } from 'react-hot-toast'
import Error from '@/components/Lottie/error.tsx'
import Loading from '@/components/Lottie/Loading.tsx'
@@ -16,12 +16,14 @@ 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 { FC } from 'react'
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
import { useTaskStore } from '@/store/taskStore'
import { noteStyles } from '@/constant/note.ts'
import { MarkdownHeader } from '@/pages/HomePage/components/MarkdownHeader.tsx'
import TranscriptViewer from "@/pages/HomePage/components/transcriptViewer.tsx";
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
@@ -44,23 +46,252 @@ const steps = [
{ label: '保存完成', key: 'SUCCESS' },
]
const MarkdownViewer: FC<MarkdownViewerProps> = ({ 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 [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 (!currentTask) return
if (!isMultiVersion) {
setCurrentVerId('') // 清空旧版本 ID
@@ -69,12 +300,17 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ status }) => {
setCreateTime(currentTask.createdAt)
setSelectedContent(currentTask?.markdown)
} else {
const latestVerId = currentTask.markdown[currentTask.markdown.length - 1]?.ver_id
setCurrentVerId(latestVerId) // 重置为最新版本
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])
}, [currentTask?.id, taskStatus])
useEffect(() => {
if (!currentTask || !isMultiVersion) return;
if (!currentTask || !isMultiVersion) return
const currentVer = currentTask.markdown.find(v => v.ver_id === currentVerId)
if (currentVer) {
@@ -94,7 +330,33 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ status }) => {
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 task = getCurrentTask()
const name = task?.audioMeta.title || 'note'
@@ -109,332 +371,135 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ status }) => {
if (status === 'loading') {
return (
<div className="flex h-screen w-full flex-col items-center justify-center space-y-4 text-neutral-500">
<StepBar steps={steps} currentStep={taskStatus} />
<Loading className="h-5 w-5" />
<div className="text-center text-sm">
<p className="text-lg font-bold"></p>
<p className="mt-2 text-xs text-neutral-500"></p>
</div>
<div className="flex h-screen w-full flex-col items-center justify-center space-y-4 text-neutral-500">
<StepBar steps={steps} currentStep={taskStatus} />
<Loading className="h-5 w-5" />
<div className="text-center text-sm">
<p className="text-lg font-bold"></p>
<p className="mt-2 text-xs text-neutral-500"></p>
</div>
</div>
)
}
if (status === 'idle') {
return (
<div className="flex h-screen w-full flex-col items-center justify-center space-y-3 text-neutral-500">
<Idle />
<div className="text-center">
<p className="text-lg font-bold"></p>
<p className="mt-2 text-xs text-neutral-500">YouTube </p>
</div>
<div className="flex h-screen w-full flex-col items-center justify-center space-y-3 text-neutral-500">
<Idle />
<div className="text-center">
<p className="text-lg font-bold">"生成笔记"</p>
<p className="mt-2 text-xs text-neutral-500">YouTube </p>
</div>
</div>
)
}
if (status === 'failed' && !isMultiVersion) {
return (
<div className="flex h-screen w-full flex-col items-center justify-center gap-4 space-y-3">
<Error />
<div className="text-center">
<p className="text-lg font-bold text-red-500"></p>
<p className="mt-2 mb-2 text-xs text-red-400"></p>
<Button onClick={() => retryTask(currentTask.id)} size="lg"></Button>
</div>
<div className="flex h-screen w-full flex-col items-center justify-center gap-4 space-y-3">
<Error />
<div className="text-center">
<p className="text-lg font-bold text-red-500"></p>
<p className="mt-2 mb-2 text-xs text-red-400"></p>
<Button onClick={() => retryTask(currentTask.id)} size="lg">
</Button>
</div>
</div>
)
}
return (
<div className="flex h-screen w-full flex-col overflow-hidden">
<MarkdownHeader
currentTask={currentTask}
isMultiVersion={isMultiVersion}
currentVerId={currentVerId}
setCurrentVerId={setCurrentVerId}
modelName={modelName}
style={style}
noteStyles={noteStyles}
onCopy={handleCopy}
onDownload={handleDownload}
createAt={createTime}
showTranscribe={showTranscribe}
setShowTranscribe={setShowTranscribe}
/>
<div className="flex 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}
/>
{/* 中间内容区域:滚动容器 */}
<div className="flex-1 flex overflow-hidden bg-white py-2">
{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 className="flex flex-1 overflow-hidden bg-white py-2">
{selectedContent && selectedContent !== 'loading' && selectedContent !== 'empty' ? (
<>
<ScrollArea className="w-full ">
<div className={"w-full px-2 markdown-body"}>
<ReactMarkdown
remarkPlugins={[gfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
// Headings with improved styling and anchor links
h1: ({ children, ...props }) => (
<h1
className="scroll-m-20 text-3xl font-extrabold tracking-tight text-primary lg:text-4xl my-6"
{...props}
>
{children}
</h1>
),
h2: ({ children, ...props }) => (
<h2
className="scroll-m-20 border-b pb-2 text-2xl font-semibold tracking-tight text-primary first:mt-0 mt-10 mb-4"
{...props}
>
{children}
</h2>
),
h3: ({ children, ...props }) => (
<h3
className="scroll-m-20 text-xl font-semibold tracking-tight text-primary mt-8 mb-4"
{...props}
>
{children}
</h3>
),
h4: ({ children, ...props }) => (
<h4
className="scroll-m-20 text-lg font-semibold tracking-tight text-primary mt-6 mb-2"
{...props}
>
{children}
</h4>
),
// Paragraphs with better line height
p: ({ children, ...props }) => (
<p className="leading-7 [&:not(:first-child)]:mt-6" {...props}>
{children}
</p>
),
// Enhanced links with special handling for "原片" links
a: ({ href, children, ...props }) => {
const isOriginLink = typeof children[0] === 'string' && (children[0] as string).startsWith('原片 @')
if (isOriginLink) {
const timeMatch = (children[0] as string).match(/原片 @ (\d{2}:\d{2})/)
const timeText = timeMatch ? timeMatch[1] : '原片'
return (
<span className="origin-link inline-flex my-2">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors"
{...props}
>
<Play className="h-3.5 w-3.5" />
<span>{timeText}</span>
</a>
</span>
)
}
// Default link styling with external indicator
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary underline underline-offset-4 hover:text-primary/80 inline-flex items-center gap-0.5"
{...props}
>
{children}
{href?.startsWith('http') && <ExternalLink className="h-3 w-3 inline-block ml-0.5" />}
</a>
)
},
// Enhanced image with zoom capability
img: ({ node, ...props }) => (
<div className="my-8 flex justify-center">
<Zoom>
<img
{...props}
className="rounded-lg shadow-md max-w-full cursor-zoom-in object-cover transition-all hover:shadow-lg"
style={{ maxHeight: '500px' }}
/>
</Zoom>
</div>
),
// Better strong/bold text
strong: ({ children, ...props }) => (
<strong className="font-bold text-primary" {...props}>
{children}
</strong>
),
// Enhanced list items with support for "fake headings"
li: ({ children, ...props }) => {
const rawText = String(children)
const isFakeHeading = /^(\*\*.+\*\*)$/.test(rawText.trim())
if (isFakeHeading) {
return (
<div className="text-lg font-bold my-4 text-primary">
{children}
</div>
)
}
return (
<li className="my-1" {...props}>
{children}
</li>
)
},
// Enhanced unordered lists
ul: ({ children, ...props }) => (
<ul className="my-6 ml-6 list-disc [&>li]:mt-2" {...props}>
{children}
</ul>
),
// Enhanced ordered lists
ol: ({ children, ...props }) => (
<ol className="my-6 ml-6 list-decimal [&>li]:mt-2" {...props}>
{children}
</ol>
),
// Enhanced blockquotes
blockquote: ({ children, ...props }) => (
<blockquote
className="mt-6 border-l-4 border-primary/20 pl-4 italic text-muted-foreground"
{...props}
>
{children}
</blockquote>
),
// Enhanced code blocks with syntax highlighting and copy button
code: ({ inline, className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
const codeContent = String(children).replace(/\n$/, '')
if (!inline && match) {
return (
<div className="group relative my-6 overflow-hidden rounded-lg border bg-muted shadow-sm">
<div className="flex items-center justify-between bg-muted px-4 py-1.5 text-sm font-medium text-muted-foreground">
<div>{match[1].toUpperCase()}</div>
<button
onClick={() => {
navigator.clipboard.writeText(codeContent)
toast.success('代码已复制')
}}
className="flex items-center gap-1 rounded-md bg-background/80 px-2 py-1 text-xs font-medium hover:bg-background transition-colors"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
<SyntaxHighlighter
style={codeStyle}
language={match[1]}
PreTag="div"
className="!m-0 !bg-muted !p-0"
customStyle={{
margin: 0,
padding: '1rem',
background: 'transparent',
fontSize: '0.9rem',
}}
{...props}
>
{codeContent}
</SyntaxHighlighter>
</div>
)
}
// Inline code styling
return (
<code
className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm"
{...props}
>
{children}
</code>
)
},
// Enhanced tables
table: ({ children, ...props }) => (
<div className="my-6 w-full overflow-y-auto">
<table className="w-full border-collapse text-sm" {...props}>
{children}
</table>
</div>
),
// Table headers
th: ({ children, ...props }) => (
<th
className="border border-muted-foreground/20 px-4 py-2 text-left font-medium [&[align=center]]:text-center [&[align=right]]:text-right"
{...props}
>
{children}
</th>
),
// Table cells
td: ({ children, ...props }) => (
<td
className="border border-muted-foreground/20 px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right"
{...props}
>
{children}
</td>
),
// Horizontal rule
hr: ({ ...props }) => (
<hr className="my-8 border-muted-foreground/20" {...props} />
),
}}
>
{selectedContent}
</ReactMarkdown>
</div>
</ScrollArea>
{
showTranscribe && (
<div className={'ml-2 w-2/4'}>
<TranscriptViewer/>
</div>
)
}
</>
) : (
<div className="flex h-full w-full items-center justify-center">
<div className="w-[300px] flex-col justify-items-center">
<div className="bg-primary-light mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<ArrowRight className="text-primary h-8 w-8" />
</div>
<p className="mb-2 text-neutral-600">"生成笔记"</p>
<p className="text-xs text-neutral-500">YouTube等视频网站</p>
<>
{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>
</div>
)}
</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>
)
}

View File

@@ -7,13 +7,13 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form.tsx'
import { useEffect } from 'react'
import { useEffect,useState } from 'react'
import { useForm, useWatch } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Info, Loader2, Plus } from 'lucide-react'
import { message, Alert } from 'antd'
import { Alert, AlertDescription } from '@/components/ui/alert.tsx'
import { generateNote } from '@/services/note.ts'
import { uploadFile } from '@/services/upload.ts'
import { useTaskStore } from '@/store/taskStore'
@@ -37,11 +37,13 @@ import {
import { Input } from '@/components/ui/input.tsx'
import { Textarea } from '@/components/ui/textarea.tsx'
import { noteStyles, noteFormats, videoPlatforms } from '@/constant/note.ts'
import { fetchModels } from '@/services/model.ts'
import { useNavigate } from 'react-router-dom'
/* -------------------- 校验 Schema -------------------- */
const formSchema = z
.object({
video_url: z.string(),
video_url: z.string().optional(),
platform: z.string().nonempty('请选择平台'),
quality: z.enum(['fast', 'medium', 'slow']),
screenshot: z.boolean().optional(),
@@ -51,28 +53,36 @@ const formSchema = z
style: z.string().nonempty('请选择笔记生成风格'),
extras: z.string().optional(),
video_understanding: z.boolean().optional(),
video_interval: z.coerce.number().min(1).max(30).default(4).optional(),
video_interval: z.coerce.number().min(1).max(30).default(6).optional(),
grid_size: z
.tuple([z.coerce.number().min(1).max(10), z.coerce.number().min(1).max(10)])
.default([3, 3])
.default([2, 2])
.optional(),
})
.superRefine(({ video_url, platform }, ctx) => {
if (platform === 'local' || platform === 'douyin') {
if (platform === 'local') {
if (!video_url) {
ctx.addIssue({ code: 'custom', message: '本地视频路径不能为空', path: ['video_url'] })
}
} else {
try {
const url = new URL(video_url)
if (!['http:', 'https:'].includes(url.protocol)) throw new Error()
} catch {
ctx.addIssue({ code: 'custom', message: '请输入正确的视频链接', path: ['video_url'] })
}
else {
if (!video_url) {
ctx.addIssue({ code: 'custom', message: '视频链接不能为空', path: ['video_url'] })
}
else {
try {
const url = new URL(video_url)
if (!['http:', 'https:'].includes(url.protocol))
throw new Error()
}
catch {
ctx.addIssue({ code: 'custom', message: '请输入正确的视频链接', path: ['video_url'] })
}
}
}
})
type NoteFormValues = z.infer<typeof formSchema>
export type NoteFormValues = z.infer<typeof formSchema>
/* -------------------- 可复用子组件 -------------------- */
const SectionHeader = ({ title, tip }: { title: string; tip?: string }) => (
@@ -118,6 +128,9 @@ const CheckboxGroup = ({
/* -------------------- 主组件 -------------------- */
const NoteForm = () => {
const navigate = useNavigate();
const [isUploading, setIsUploading] = useState(false)
const [uploadSuccess, setUploadSuccess] = useState(false)
/* ---- 全局状态 ---- */
const { addPendingTask, currentTaskId, setCurrentTask, getCurrentTask, retryTask } =
useTaskStore()
@@ -131,8 +144,8 @@ const NoteForm = () => {
quality: 'medium',
model_name: modelList[0]?.model_name || '',
style: 'minimal',
video_interval: 4,
grid_size: [3, 3],
video_interval: 6,
grid_size: [2, 2],
format: [],
},
})
@@ -143,6 +156,9 @@ const NoteForm = () => {
const videoUnderstandingEnabled = useWatch({ control: form.control, name: 'video_understanding' })
const editing = currentTask && currentTask.id
const goModelAdd = () => {
navigate("/settings/model");
};
/* ---- 副作用 ---- */
useEffect(() => {
loadEnabledModels()
@@ -150,14 +166,33 @@ const NoteForm = () => {
return
}, [])
useEffect(() => {
const currentTask = getCurrentTask()
const { formData } = currentTask || {}
if (!currentTask) return
const { formData } = currentTask
console.log('currentTask.formData.platform:', formData.platform)
form.reset({
...formData,
extras: formData?.extras || '',
platform: formData.platform || 'bilibili',
video_url: formData.video_url || '',
model_name: formData.model_name || modelList[0]?.model_name || '',
style: formData.style || 'minimal',
quality: formData.quality || 'medium',
extras: formData.extras || '',
screenshot: formData.screenshot ?? false,
link: formData.link ?? false,
video_understanding: formData.video_understanding ?? false,
video_interval: formData.video_interval ?? 6,
grid_size: formData.grid_size ?? [2, 2],
format: formData.format ?? [],
})
}, [currentTaskId])
}, [
// 当下面任意一个变了,就重新 reset
currentTaskId,
// modelList 用来兜底 model_name
modelList.length,
// 还要加上 formData 的各字段,或者直接 currentTask
currentTask?.formData,
])
/* ---- 帮助函数 ---- */
const isGenerating = () => !['SUCCESS', 'FAILED', undefined].includes(getCurrentTask()?.status)
@@ -165,16 +200,24 @@ const NoteForm = () => {
const handleFileUpload = async (file: File, cb: (url: string) => void) => {
const formData = new FormData()
formData.append('file', file)
setIsUploading(true)
setUploadSuccess(false)
try {
const { data } = await uploadFile(formData)
if (data.code === 0) cb(data.data.url)
const data = await uploadFile(formData)
cb(data.url)
setUploadSuccess(true)
} catch (err) {
console.error('上传失败:', err)
message.error('上传失败,请重试')
// message.error('上传失败,请重试')
} finally {
setIsUploading(false)
}
}
const onSubmit = async (values: NoteFormValues) => {
console.log('Not even go here')
const payload: NoteFormValues = {
...values,
provider_id: modelList.find(m => m.model_name === values.model_name)!.provider_id,
@@ -185,10 +228,14 @@ const NoteForm = () => {
return
}
message.success('已提交任务')
const { data } = await generateNote(payload)
// message.success('已提交任务')
const data = await generateNote(payload)
addPendingTask(data.task_id, values.platform, payload)
}
const onInvalid = (errors: FieldErrors<NoteFormValues>) => {
console.warn('表单校验失败:', errors)
// message.error('请完善所有必填项后再提交')
}
const handleCreateNew = () => {
// 🔁 这里清空当前任务状态
// 比如调用 resetCurrentTask() 或者 navigate 到一个新页面
@@ -222,7 +269,7 @@ const NoteForm = () => {
return (
<div className="h-full w-full">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form onSubmit={form.handleSubmit(onSubmit, onInvalid)} className="space-y-4">
{/* 顶部按钮 */}
<FormButton></FormButton>
@@ -258,7 +305,7 @@ const NoteForm = () => {
))}
</SelectContent>
</Select>
<FormMessage />
<FormMessage style={{ display: 'none' }} />
</FormItem>
)}
/>
@@ -275,7 +322,7 @@ const NoteForm = () => {
) : (
<Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} />
)}
<FormMessage />
<FormMessage style={{ display: 'none' }} />
</FormItem>
)}
/>
@@ -310,10 +357,16 @@ const NoteForm = () => {
input.click()
}}
>
<p className="text-center text-sm text-gray-500">
<br />
<span className="text-xs text-gray-400"></span>
</p>
{isUploading ? (
<p className="text-center text-sm text-blue-500"></p>
) : uploadSuccess ? (
<p className="text-center text-sm text-green-500"></p>
) : (
<p className="text-center text-sm text-gray-500">
<br />
<span className="text-xs text-gray-400"></span>
</p>
)}
</div>
</>
)}
@@ -323,35 +376,50 @@ const NoteForm = () => {
/>
<div className="grid grid-cols-2 gap-2">
{/* 模型选择 */}
<FormField
className="w-full"
control={form.control}
name="model_name"
render={({ field }) => (
<FormItem>
<SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" />
<Select
value={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="w-full min-w-0 truncate">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{modelList.map(m => (
<SelectItem key={m.id} value={m.model_name}>
{m.model_name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{
modelList.length>0?( <FormField
className="w-full"
control={form.control}
name="model_name"
render={({ field }) => (
<FormItem>
<SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" />
<Select
onOpenChange={()=>{
loadEnabledModels()
}}
value={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="w-full min-w-0 truncate">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{modelList.map(m => (
<SelectItem key={m.id} value={m.model_name}>
{m.model_name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>): (
<FormItem>
<SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" />
<Button type={'button'} variant={
'outline'
} onClick={()=>{goModelAdd()}}></Button>
<FormMessage />
</FormItem>
)
}
{/* 笔记风格 */}
<FormField
className="w-full"
@@ -445,17 +513,11 @@ const NoteForm = () => {
)}
/>
</div>
<Alert
closable
type="error"
message={
<div>
<strong></strong>
<p>使</p>
</div>
}
className="text-sm"
/>
<Alert variant="warning" className="text-sm">
<AlertDescription>
<strong></strong>使
</AlertDescription>
</Alert>
</div>
{/* 笔记格式 */}

View File

@@ -14,7 +14,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip.tsx'
import LazyImage from "@/components/LazyImage.tsx";
import {FC, useState ,useEffect } from 'react'
import {FC, useState, useEffect, useMemo} from 'react'
interface NoteHistoryProps {
onSelect: (taskId: string) => void
@@ -24,12 +24,14 @@ 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 = new Fuse(tasks, {
const fuse = useMemo(() => new Fuse(tasks, {
keys: ['audioMeta.title'],
threshold: 0.4 // 匹配精度(越低越严格)
})
}), [tasks])
useEffect(() => {
const timer = setTimeout(() => {
if (rawSearch === '') return
@@ -76,16 +78,15 @@ const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
<div className="flex flex-col gap-2 overflow-hidden">
{filteredTasks.map(task => (
<div
onClick={() => onSelect(task.id)}
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')}
>
{/* 封面图 */}
{task.platform === 'local' ? (
@@ -101,7 +102,7 @@ const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
src={
task.audioMeta.cover_url
? `/api/image_proxy?url=${encodeURIComponent(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

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

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

@@ -26,7 +26,7 @@ export default function AboutPage() {
height={50}
className="rounded-lg"
/>
<h1 className="text-4xl font-bold">BiliNote v1.5.0</h1>
<h1 className="text-4xl font-bold">BiliNote v2.0.0</h1>
</div>
<p className="text-muted-foreground mb-6 text-xl italic">
AI AI

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

@@ -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
@@ -14,12 +13,13 @@ export const generateNote = async (data: {
extras?: string
video_understand?: boolean
video_interval?: number
grid_size:Array<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)
}
@@ -30,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 // 抛出错误以便调用方处理
}
@@ -49,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)
@@ -65,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)
@@ -76,34 +75,36 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
getProviderById: id => get().provider.find(p => p.id === id),
updateProvider: async (provider: IProvider) => {
try {
const existing = get().provider.find(p => p.id === provider.id)
const merged = { ...existing, ...provider }
const data = {
...provider,
api_key: provider.apiKey,
base_url: provider.baseUrl,
}
const res = await updateProviderById(data)
if (res.data.code === 0) {
const item = res.data.data
console.log('Provider ', item)
await get().fetchProviderList()
...merged,
api_key: merged.apiKey,
base_url: merged.baseUrl,
}
// 拦截器已解包:成功时直接返回 data 部分
await updateProviderById(data)
await get().fetchProviderList()
} catch (error) {
console.error('Error fetching provider:', error)
console.error('Error updating provider:', error)
}
},
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,7 +1,9 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { persist, createJSONStorage } from 'zustand/middleware'
import { delete_task, generateNote } from '@/services/note.ts'
import { v4 as uuidv4 } from 'uuid'
import toast from 'react-hot-toast'
import { get, set, del } from 'idb-keyval'
export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILD'
@@ -157,14 +159,19 @@ export const useTaskStore = create<TaskStore>()(
return get().tasks.find(task => task.id === currentTaskId) || null
},
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({
task_id: id,
...newFormData,
task_id: id,
})
set(state => ({
@@ -205,6 +212,18 @@ export const useTaskStore = create<TaskStore>()(
}),
{
name: 'task-storage',
storage: createJSONStorage(() => ({
getItem: async (name: string): Promise<string | null> => {
const value = await get(name)
return value ?? null
},
setItem: async (name: string, value: string): Promise<void> => {
await set(name, value)
},
removeItem: async (name: string): Promise<void> => {
await del(name)
},
})),
}
)
)

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'
const baseURL=import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
const request = axios.create({
baseURL: baseURL+'/api',
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import toast from 'react-hot-toast'
// 统一响应类型
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,14 +1,19 @@
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 port = env.FRONTEND_PORT || 3015
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://127.0.0.1:8483'
const port = parseInt(env.VITE_FRONTEND_PORT || '3015', 10)
return {
base: './',
@@ -18,9 +23,21 @@ export default defineConfig(({ mode }) => {
'@': 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: port,
allowedHosts: true, // 允许任意域名访问
proxy: {
'/api': {
target: apiBaseUrl,

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"]

117
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.6.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,30 +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)
@@ -56,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
@@ -64,7 +109,7 @@ cd BiliNote
mv .env.example .env
```
### 2. 启动后端FastAPI
#### 2. 启动后端FastAPI
```bash
cd backend
@@ -72,7 +117,7 @@ pip install -r requirements.txt
python main.py
```
### 3. 启动前端Vite + React
#### 3. 启动前端Vite + React
```bash
cd BillNote_frontend
@@ -80,11 +125,12 @@ pnpm install
pnpm dev
```
访问:`http://localhost:5173`
访问:`http://localhost:3015`
## ⚙️ 依赖说明
### 🎬 FFmpeg
本项目依赖 ffmpeg 用于音频处理与转码,必须安装:
本项目依赖 ffmpeg 用于音频处理与转码,源码部署时必须安装:
```bash
# Mac (brew)
brew install ffmpeg
@@ -96,6 +142,8 @@ sudo apt install ffmpeg
# 请从官网下载安装https://ffmpeg.org/download.html
```
> ⚠️ 若系统无法识别 ffmpeg请将其加入系统环境变量 PATH
>
> Docker 部署已内置 FFmpeg无需额外安装。
### 🚀 CUDA 加速(可选)
若你希望更快地执行音频转写任务,可使用具备 NVIDIA GPU 的机器,并启用 fast-whisper + CUDA 加速版本:
@@ -104,24 +152,43 @@ sudo apt install ffmpeg
### 🐳 使用 Docker 一键部署
确保你已安装 Docker 和 Docker Compose
确保你已安装 Docker,然后直接拉取预构建镜像运行
[docker 部署](https://github.com/JefferyHcool/bilinote-deploy/blob/master/README.md)
```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
```
## 🧠 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/36a9778f60a9e15395e89b52abd4ac8.jpg" alt="wechat" style="zoom:33%;" />
年会恢复更新以后放出最新社区地址

View File

@@ -1,23 +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}"
# 设置 Hugging Face 镜像源环境变量
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,11 +1,15 @@
from fastapi import FastAPI
from .routers import note, provider, model, config
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,69 @@
from app.db.sqlite_client import get_connection
from app.db.engine import get_db
from app.db.models.models import Model
from app.db.models.providers import Provider
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).join(Provider, Model.provider_id == Provider.id).filter(Provider.enabled == 1).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,14 +1,13 @@
import json
import os
import sys
from app.db.sqlite_client import get_connection
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
@@ -16,212 +15,115 @@ def get_builtin_providers_path():
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 = get_builtin_providers_path()
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,18 +1,44 @@
import os
import json
import logging
import tempfile
from abc import ABC
from typing import Union, Optional
from typing import Union, Optional, List
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
from app.services.cookie_manager import CookieConfigManager
logger = logging.getLogger(__name__)
class BilibiliDownloader(Downloader, ABC):
def __init__(self):
super().__init__()
self._cookie_mgr = CookieConfigManager()
self._cookie = self._cookie_mgr.get('bilibili')
self._cookiefile = self._write_netscape_cookie_file()
def _write_netscape_cookie_file(self) -> Optional[str]:
"""将 Cookie 写入 Netscape 格式临时文件,返回文件路径(供 yt-dlp cookiefile 使用)"""
if not self._cookie:
logger.warning("B站 Cookie 未配置,下载可能失败")
return None
lines = ["# Netscape HTTP Cookie File\n"]
for pair in self._cookie.split("; "):
if "=" in pair:
key, value = pair.split("=", 1)
lines.append(f".bilibili.com\tTRUE\t/\tFALSE\t0\t{key}\t{value}\n")
tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8')
tmp.writelines(lines)
tmp.close()
logger.info("已生成 B站 Netscape Cookie 文件: %s (条目: %d)", tmp.name, len(lines) - 1)
return tmp.name
def download(
self,
@@ -32,6 +58,7 @@ class BilibiliDownloader(Downloader, ABC):
ydl_opts = {
'format': 'bestaudio[ext=m4a]/bestaudio/best',
'outtmpl': output_path,
'http_headers': {'Referer': 'https://www.bilibili.com'},
'postprocessors': [
{
'key': 'FFmpegExtractAudio',
@@ -42,6 +69,8 @@ class BilibiliDownloader(Downloader, ABC):
'noplaylist': True,
'quiet': False,
}
if self._cookiefile:
ydl_opts['cookiefile'] = self._cookiefile
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
@@ -88,10 +117,13 @@ class BilibiliDownloader(Downloader, ABC):
ydl_opts = {
'format': 'bv*[ext=mp4]/bestvideo+bestaudio/best',
'outtmpl': output_path,
'http_headers': {'Referer': 'https://www.bilibili.com'},
'noplaylist': True,
'quiet': False,
'merge_output_format': 'mp4', # 确保合并成 mp4
}
if self._cookiefile:
ydl_opts['cookiefile'] = self._cookiefile
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
@@ -111,4 +143,191 @@ 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,
}
# 通过 CookieConfigManager 注入 B站 CookieNetscape cookiefile
if self._cookiefile:
ydl_opts['cookiefile'] = self._cookiefile
ydl_opts['http_headers'] = {'Referer': 'https://www.bilibili.com'}
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

@@ -145,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)
@@ -208,46 +214,49 @@ 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)
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")
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'])
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 # ❗音频下载不包含视频路径
)
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:

View File

@@ -0,0 +1,25 @@
from typing import Union, Optional
import requests
from app.downloaders.base import Downloader
from app.enmus.note_enums import DownloadQuality
from app.models.audio_model import AudioDownloadResult
url='https://www.xiaoyuzhoufm.com/_next/data/5Pvt_oGntgdyBD_XgwBaB/podcast/62382c1103bea1ebfffa1c00.json?id=62382c1103bea1ebfffa1c00'
header ={
'user-agent':'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'
}
response = requests.get(url, headers=header)
print(response.json())
class Xiaoyuzhoufm_download(Downloader):
def download(
self,
video_url: str,
output_dir: Union[str, None] = None,
quality: DownloadQuality = "fast",
need_video:Optional[bool]=False
) -> AudioDownloadResult:
pass

View File

@@ -1,14 +1,19 @@
import os
import logging
from abc import ABC
from typing import Union, Optional
from typing import Union, Optional, List
import yt_dlp
from app.downloaders.base import Downloader, DownloadQuality
from app.downloaders.youtube_subtitle import YouTubeSubtitleFetcher
from app.models.notes_model import AudioDownloadResult
from app.models.transcriber_model import TranscriptResult
from app.utils.path_helper import get_data_dir
from app.utils.url_parser import extract_video_id
logger = logging.getLogger(__name__)
class YoutubeDownloader(Downloader, ABC):
def __init__(self):
@@ -20,12 +25,13 @@ class YoutubeDownloader(Downloader, ABC):
video_url: str,
output_dir: Union[str, None] = None,
quality: DownloadQuality = "fast",
need_video:Optional[bool]=False
need_video: Optional[bool] = False,
skip_download: bool = False,
) -> AudioDownloadResult:
if output_dir is None:
output_dir = get_data_dir()
if not output_dir:
output_dir=self.cache_data
output_dir = self.cache_data
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
@@ -37,15 +43,17 @@ class YoutubeDownloader(Downloader, ABC):
'quiet': False,
}
if skip_download:
ydl_opts['skip_download'] = True
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
info = ydl.extract_info(video_url, download=not skip_download)
video_id = info.get("id")
title = info.get("title")
duration = info.get("duration", 0)
cover_url = info.get("thumbnail")
ext = info.get("ext", "m4a") # 兜底用 m4a
ext = info.get("ext", "m4a")
audio_path = os.path.join(output_dir, f"{video_id}.{ext}")
print('os.path.join(output_dir, f"{video_id}.{ext}")',os.path.join(output_dir, f"{video_id}.{ext}"))
return AudioDownloadResult(
file_path=audio_path,
@@ -54,8 +62,8 @@ class YoutubeDownloader(Downloader, ABC):
cover_url=cover_url,
platform="youtube",
video_id=video_id,
raw_info={'tags':info.get('tags')}, #全部返回会报错
video_path=None # ❗音频下载不包含视频路径
raw_info={'tags': info.get('tags')},
video_path=None,
)
def download_video(
@@ -92,3 +100,24 @@ class YoutubeDownloader(Downloader, ABC):
raise FileNotFoundError(f"视频文件未找到: {video_path}")
return video_path
def download_subtitles(self, video_url: str, output_dir: str = None,
langs: List[str] = None) -> Optional[TranscriptResult]:
"""
通过 YouTube InnerTube API 直接获取字幕(优先人工字幕,其次自动生成)。
比 yt_dlp 方式更轻量,无需写临时文件到磁盘。
:param video_url: 视频链接
:param output_dir: 未使用(保留接口兼容)
:param langs: 优先语言列表
:return: TranscriptResult 或 None
"""
if langs is None:
langs = ['zh-Hans', 'zh', 'zh-CN', 'zh-TW', 'en', 'en-US', 'ja']
video_id = extract_video_id(video_url, "youtube")
fetcher = YouTubeSubtitleFetcher()
print(
f"尝试获取字幕video_id={video_id}, langs={langs}"
)
return fetcher.fetch_subtitles(video_id, langs)

View File

@@ -0,0 +1,98 @@
"""
通过 youtube-transcript-api 获取 YouTube 字幕。
优先人工字幕,其次自动生成字幕。不依赖 yt_dlp无需下载任何文件。
"""
from typing import Optional, List
from youtube_transcript_api import YouTubeTranscriptApi
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
from app.utils.logger import get_logger
logger = get_logger(__name__)
class YouTubeSubtitleFetcher:
"""通过 youtube-transcript-api 获取 YouTube 字幕。"""
def __init__(self):
self._api = YouTubeTranscriptApi()
def fetch_subtitles(
self,
video_id: str,
langs: Optional[List[str]] = None,
) -> Optional[TranscriptResult]:
if langs is None:
langs = ["zh-Hans", "zh", "zh-CN", "zh-TW", "en", "en-US", "ja"]
try:
# 1. 列出所有可用字幕
transcript_list = self._api.list(video_id)
available = []
for t in transcript_list:
available.append(
f"{t.language_code}({'auto' if t.is_generated else 'manual'})"
)
logger.info(f"可用字幕轨道: {', '.join(available)}")
# 2. 按优先级查找:先人工字幕,再自动字幕
transcript = None
try:
transcript = transcript_list.find_manually_created_transcript(langs)
logger.info(f"选中人工字幕: {transcript.language_code} ({transcript.language})")
except Exception:
try:
transcript = transcript_list.find_generated_transcript(langs)
logger.info(f"选中自动字幕: {transcript.language_code} ({transcript.language})")
except Exception:
# 都没匹配,取第一个可用的
for t in transcript_list:
transcript = t
source = "auto" if t.is_generated else "manual"
logger.info(f"使用首个可用字幕: {t.language_code} ({source})")
break
if not transcript:
logger.info(f"YouTube 视频 {video_id} 没有任何可用字幕")
return None
# 3. 获取字幕内容
fetched = transcript.fetch()
segments = []
for snippet in fetched:
text = snippet.get("text", "").strip() if isinstance(snippet, dict) else str(snippet).strip()
if not text:
continue
start = snippet.get("start", 0) if isinstance(snippet, dict) else 0
duration = snippet.get("duration", 0) if isinstance(snippet, dict) else 0
segments.append(TranscriptSegment(
start=float(start),
end=float(start) + float(duration),
text=text,
))
if not segments:
logger.warning(f"YouTube 字幕内容为空: {video_id}")
return None
full_text = " ".join(seg.text for seg in segments)
logger.info(f"成功获取 YouTube 字幕,共 {len(segments)}")
return TranscriptResult(
language=transcript.language_code,
full_text=full_text,
segments=segments,
raw={
"source": "youtube_transcript_api",
"language": transcript.language,
"language_code": transcript.language_code,
"is_generated": transcript.is_generated,
},
)
except Exception as e:
logger.warning(f"YouTube 字幕获取失败: {e}")
return None

View File

@@ -0,0 +1,21 @@
import enum
class ProviderErrorEnum(enum.Enum):
CONNECTION_TEST_FAILED = (200101, "供应商连接测试失败")
SAVE_FAILED = (200102, "供应商保存失败")
CREATE_FAILED = (200103, "供应商创建失败")
NOT_FOUND = (200104, "供应商不存在/未保存")
WRONG_PARAMETER = (200105, "API / API 地址不正确")
UNKNOW_ERROR = (200106, "未知错误")
def __init__(self, code, message):
self.code = code
self.message = message
class NoteErrorEnum(enum.Enum):
PLATFORM_NOT_SUPPORTED = (300101 ,"选择的平台不受支持")
def __init__(self, code, message):
self.code = code
self.message = message

View File

View File

@@ -0,0 +1,6 @@
# exceptions/biz_exception.py
class BizException(Exception):
def __init__(self, code: int, message: str = "业务异常"):
self.code = code
self.message = message

View File

@@ -0,0 +1,33 @@
# middlewares/exception_handler.py
from fastapi import Request
from fastapi import FastAPI
from app.enmus.exception import NoteErrorEnum
from app.exceptions.biz_exception import BizException
from app.exceptions.note import NoteError
from app.exceptions.provider import ProviderError
from app.utils.logger import get_logger
from app.utils.response import ResponseWrapper as R
import traceback
logger = get_logger(__name__)
def register_exception_handlers(app: FastAPI):
@app.exception_handler(BizException)
async def biz_exception_handler(request: Request, exc: BizException):
logger.error(f"BizException: {exc.code} - {exc.message}")
return R.error(code=exc.code, msg=str(exc.message))
@app.exception_handler(NoteError)
async def note_exception_handler(request: Request, exc: NoteError):
logger.error(f"NoteError: {exc.code} - {exc.message}")
return R.error(code=exc.code, msg=str(exc.message))
@app.exception_handler(ProviderError)
async def provider_exception_handler(request: Request, exc: ProviderError):
logger.error(f"供应商模块错误: {exc.code} - {exc.message}")
return R.error(code=exc.code, msg=str(exc.message))
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
logger.error(f"系统异常: {str(exc)}\n{traceback.format_exc()}")
return R.error(code=500000, msg="系统异常")

View File

@@ -0,0 +1,9 @@
# exceptions.py
from app.enmus.exception import ProviderErrorEnum
class NoteError(Exception):
def __init__(self, message: str,code: ProviderErrorEnum) -> None:
super().__init__(message)
self.code=code
self.message = message

View File

@@ -0,0 +1,12 @@
# exceptions.py
from app.enmus.exception import ProviderErrorEnum
class ProviderError(Exception):
def __init__(self, message: str,code: ProviderErrorEnum) -> None:
super().__init__(message)
self.code=code
self.message = message

View File

@@ -18,12 +18,12 @@ BASE_PROMPT = '''
- **不要**将输出包裹在代码块中(例如:```` ```markdown ```````` ``` ````)。
请注意,在生成 Markdown 时避免将编号标题如“1. **内容**”)写成有序列表的格式,以免解析错误。
- 如果要加粗并保留编号,应使用 `1\. **内容**`(加反斜杠),防止被误解析为有序列表。
- 如果要加粗并保留编号,应使用 `1\\. **内容**`(加反斜杠),防止被误解析为有序列表。
- 或者使用 `## 1. 内容` 的形式作为标题。
请确保以下格式 **不会出现误渲染**
`1. **xxx**`
`1\. **xxx**` 或 `## 1. xxx`
`1. **xxx**`
`1\\. **xxx**` 或 `## 1. xxx`
视频分段(格式:开始时间 - 内容):
@@ -66,4 +66,13 @@ SCREENSHOT='''
8. **Screenshot placeholders**: If a section involves **visual demonstrations, code walkthroughs, UI interactions**, or any content where visuals aid understanding, insert a screenshot cue at the end of that section:
- Format: `*Screenshot-[mm:ss]`
- Only use it when truly helpful.
'''
'''
MERGE_PROMPT = '''
你将收到多个来自同一视频的 Markdown 笔记片段,请合并成一份完整笔记:
- 只做合并与去重,不要发明新内容
- 保持原有标题层级与 Markdown 结构
- 保留所有 *Content-[mm:ss] 与 *Screenshot-[mm:ss] 标记
- 保持中文输出,专有名词保留英文
- 不要使用代码块包裹输出
'''

View File

@@ -2,6 +2,9 @@ from typing import Optional, Union
from openai import OpenAI
from app.utils.logger import get_logger
logging= get_logger(__name__)
class OpenAICompatibleProvider:
def __init__(self, api_key: str, base_url: str, model: Union[str, None]=None):
self.client = OpenAI(api_key=api_key, base_url=base_url)
@@ -15,8 +18,14 @@ class OpenAICompatibleProvider:
def test_connection(api_key: str, base_url: str) -> bool:
try:
client = OpenAI(api_key=api_key, base_url=base_url)
client.models.list()
model = client.models.list()
# for segment in model:
# print(segment)
# print(model)
logging.info("连通性测试成功")
return True
except Exception as e:
print(f"Error connecting to OpenAI API: {e}")
logging.info(f"连通性测试失败:{e}")
# print(f"Error connecting to OpenAI API: {e}")
return False

View File

@@ -0,0 +1,161 @@
from dataclasses import dataclass
from typing import Callable, List, Optional
@dataclass
class ChunkPayload:
segments: list
image_urls: list
class RequestChunker:
def __init__(self, message_builder: Callable, max_bytes: int, size_estimator: Optional[Callable] = None):
self.message_builder = message_builder
self.max_bytes = max_bytes
self.size_estimator = size_estimator
def estimate(self, messages) -> int:
if self.size_estimator:
return self.size_estimator(messages)
import json
return len(json.dumps(messages, ensure_ascii=False).encode("utf-8"))
def _messages_size(self, segments, image_urls, **kwargs) -> int:
messages = self.message_builder(segments, image_urls, **kwargs)
return self.estimate(messages)
def _get_text(self, segment) -> str:
if isinstance(segment, dict):
return segment.get("text", "")
return getattr(segment, "text", "")
def _make_segment(self, segment, text: str):
if isinstance(segment, dict):
new_seg = dict(segment)
new_seg["text"] = text
return new_seg
if hasattr(segment, "__dict__"):
data = dict(segment.__dict__)
data["text"] = text
return type(segment)(**data)
return type(segment)(segment.start, segment.end, text)
def _split_segment_to_fit(self, segment, **kwargs):
text = self._get_text(segment)
if not text:
raise ValueError("empty segment cannot be split")
lo, hi = 1, len(text)
best = None
while lo <= hi:
mid = (lo + hi) // 2
candidate = self._make_segment(segment, text[:mid])
size = self._messages_size([candidate], [], **kwargs)
if size <= self.max_bytes:
best = mid
lo = mid + 1
else:
hi = mid - 1
if best is None:
raise ValueError("single segment too large to fit request")
head = self._make_segment(segment, text[:best])
tail = self._make_segment(segment, text[best:])
return head, tail
def chunk(self, segments: list, image_urls: list, **kwargs) -> List[ChunkPayload]:
segments = list(segments or [])
image_urls = list(image_urls or [])
if not segments and not image_urls:
return []
chunks: List[ChunkPayload] = []
seg_idx = 0
while seg_idx < len(segments):
batch_segments = []
while seg_idx < len(segments):
candidate = batch_segments + [segments[seg_idx]]
size = self._messages_size(candidate, [], **kwargs)
if size <= self.max_bytes:
batch_segments = candidate
seg_idx += 1
continue
if not batch_segments:
head, tail = self._split_segment_to_fit(segments[seg_idx], **kwargs)
segments[seg_idx] = head
segments.insert(seg_idx + 1, tail)
continue
break
if not batch_segments:
raise ValueError("unable to fit any content into chunk")
chunks.append(ChunkPayload(segments=batch_segments, image_urls=[]))
if not image_urls:
return chunks
if not chunks:
chunks = [ChunkPayload(segments=[], image_urls=[])]
if not segments:
for image in image_urls:
appended = False
for chunk in chunks[-1:]:
candidate_images = chunk.image_urls + [image]
if self._messages_size(chunk.segments, candidate_images, **kwargs) <= self.max_bytes:
chunk.image_urls = candidate_images
appended = True
break
if appended:
continue
if self._messages_size([], [image], **kwargs) > self.max_bytes:
raise ValueError("single image payload exceeds max_bytes")
chunks.append(ChunkPayload(segments=[], image_urls=[image]))
return chunks
chunk_count = len(chunks)
total_images = len(image_urls)
for idx, image in enumerate(image_urls):
preferred_idx = min(chunk_count - 1, (idx * chunk_count) // total_images)
placed = False
for chunk_idx in range(preferred_idx, len(chunks)):
chunk = chunks[chunk_idx]
candidate_images = chunk.image_urls + [image]
if self._messages_size(chunk.segments, candidate_images, **kwargs) <= self.max_bytes:
chunk.image_urls = candidate_images
placed = True
break
if placed:
continue
if self._messages_size([], [image], **kwargs) > self.max_bytes:
raise ValueError("single image payload exceeds max_bytes")
chunks.append(ChunkPayload(segments=[], image_urls=[image]))
return chunks
def group_texts_by_budget(self, texts: List[str], build_messages: Callable, **kwargs) -> List[List[str]]:
groups: List[List[str]] = []
idx = 0
while idx < len(texts):
group: List[str] = []
while idx < len(texts):
candidate = group + [texts[idx]]
try:
messages = build_messages(candidate, [], **kwargs)
except TypeError:
messages = build_messages(candidate, **kwargs)
size = self.estimate(messages)
if size <= self.max_bytes:
group = candidate
idx += 1
continue
if not group:
raise ValueError("single text block exceeds max_bytes")
break
groups.append(group)
return groups

View File

@@ -1,8 +1,16 @@
from app.gpt.base import GPT
from app.gpt.prompt_builder import generate_base_prompt
from app.models.gpt_model import GPTSource
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT, LINK
import os
import hashlib
import json
import time
from datetime import datetime, timezone
from pathlib import Path
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT, LINK, MERGE_PROMPT
from app.gpt.utils import fix_markdown
from app.gpt.request_chunker import RequestChunker
from app.models.transcriber_model import TranscriptSegment
from datetime import timedelta
from typing import List
@@ -14,8 +22,13 @@ class UniversalGPT(GPT):
self.model = model
self.temperature = temperature
self.screenshot = False
self.screenshot = False
self.link = False
self.max_request_bytes = int(os.getenv("OPENAI_MAX_REQUEST_BYTES", str(45 * 1024 * 1024)))
self.checkpoint_dir = Path(os.getenv("NOTE_OUTPUT_DIR", "note_results"))
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
# 初始化时缓存重试配置,避免每次请求重复读取环境变量
self._max_retry_attempts = max(1, int(os.getenv("OPENAI_RETRY_ATTEMPTS", "3")))
self._retry_base_backoff = float(os.getenv("OPENAI_RETRY_BACKOFF_SECONDS", "1.5"))
def _format_time(self, seconds: float) -> str:
return str(timedelta(seconds=int(seconds)))[2:]
@@ -41,7 +54,7 @@ class UniversalGPT(GPT):
)
# ⛳ 组装 content 数组,支持 text + image_url 混合
content = [{"type": "text", "text": content_text}]
content: List[dict] = [{"type": "text", "text": content_text}]
video_img_urls = kwargs.get('video_img_urls', [])
for url in video_img_urls:
@@ -53,7 +66,7 @@ class UniversalGPT(GPT):
}
})
# 正确格式:整体包在一个 message 里role + content array
# 正确格式:整体包在一个 message 里role + content array
messages = [{
"role": "user",
"content": content
@@ -64,23 +77,231 @@ class UniversalGPT(GPT):
def list_models(self):
return self.client.models.list()
def _estimate_messages_bytes(self, messages: list) -> int:
import json
return len(json.dumps(messages, ensure_ascii=False).encode("utf-8"))
def _build_merge_messages(self, partials: list) -> list:
merge_text = MERGE_PROMPT + "\n\n" + "\n\n---\n\n".join(partials)
return [{
"role": "user",
"content": [{"type": "text", "text": merge_text}]
}]
def _checkpoint_path(self, checkpoint_key: str) -> Path:
safe_key = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in checkpoint_key)
return self.checkpoint_dir / f"{safe_key}.gpt.checkpoint.json"
def _build_source_signature(self, source: GPTSource) -> str:
payload = {
"model": self.model,
"temperature": self.temperature,
"max_request_bytes": self.max_request_bytes,
"title": source.title,
"tags": source.tags,
"format": source._format,
"style": source.style,
"extras": source.extras,
"video_img_urls": source.video_img_urls or [],
"segments": [
{
"start": getattr(seg, "start", None),
"end": getattr(seg, "end", None),
"text": getattr(seg, "text", "")
}
for seg in source.segment
],
}
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _load_checkpoint(self, checkpoint_key: str, source_signature: str) -> dict | None:
path = self._checkpoint_path(checkpoint_key)
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("source_signature") != source_signature:
path.unlink(missing_ok=True)
return None
return data
except Exception:
path.unlink(missing_ok=True)
return None
def _save_checkpoint(self, checkpoint_key: str, source_signature: str, partials: list, phase: str) -> None:
path = self._checkpoint_path(checkpoint_key)
data = {
"version": 1,
"source_signature": source_signature,
"phase": phase,
"partials": partials,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
tmp_path = path.with_suffix(".tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp_path.replace(path)
def _clear_checkpoint(self, checkpoint_key: str) -> None:
self._checkpoint_path(checkpoint_key).unlink(missing_ok=True)
@staticmethod
def _is_insufficient_quota_error(exc: Exception) -> bool:
raw = str(exc)
return (
"insufficient_user_quota" in raw
or "预扣费额度失败" in raw
or "insufficient quota" in raw.lower()
)
@staticmethod
def _is_retryable_error(exc: Exception) -> bool:
raw = str(exc).lower()
retryable_tokens = (
"error code: 524",
"bad_response_status_code",
"timed out",
"timeout",
"rate limit",
"error code: 429",
"error code: 500",
"error code: 502",
"error code: 503",
"error code: 504",
"apiconnectionerror",
"connection error",
"service unavailable",
)
if any(token in raw for token in retryable_tokens):
return True
status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
return status in {408, 409, 429, 500, 502, 503, 504, 524}
def _chat_completion_create(self, messages: list):
last_exc = None
for attempt in range(self._max_retry_attempts):
try:
return self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=self.temperature
)
except Exception as exc:
last_exc = exc
if attempt == self._max_retry_attempts - 1 or not self._is_retryable_error(exc):
raise
sleep_seconds = self._retry_base_backoff * (2 ** attempt)
time.sleep(sleep_seconds)
if last_exc is not None:
raise last_exc
raise RuntimeError("chat completion failed without exception")
def _merge_partials(self, partials: list, checkpoint_key: str | None, source_signature: str | None) -> str:
def build_messages(texts, *_args, **_kwargs):
return self._build_merge_messages(texts)
merge_chunker = RequestChunker(
lambda *_args, **_kwargs: [],
self.max_request_bytes,
self._estimate_messages_bytes
)
current_partials = list(partials)
while len(current_partials) > 1:
groups = merge_chunker.group_texts_by_budget(current_partials, build_messages)
new_partials = []
for group_idx, group in enumerate(groups):
messages = build_messages(group)
try:
response = self._chat_completion_create(messages)
except Exception as exc:
if checkpoint_key and source_signature:
self._save_checkpoint(checkpoint_key, source_signature, current_partials, "merge")
raise
new_partials.append(response.choices[0].message.content.strip())
if checkpoint_key and source_signature:
remaining_partials = []
for remaining_group in groups[group_idx + 1:]:
remaining_partials.extend(remaining_group)
resumable_partials = new_partials + remaining_partials
self._save_checkpoint(checkpoint_key, source_signature, resumable_partials, "merge")
current_partials = new_partials
return current_partials[0]
def summarize(self, source: GPTSource) -> str:
self.screenshot = source.screenshot
self.link = source.link
source.segment = self.ensure_segments_type(source.segment)
checkpoint_key = source.checkpoint_key
source_signature = self._build_source_signature(source) if checkpoint_key else None
messages = self.create_messages(
source.segment,
title=source.title,
tags=source.tags,
video_img_urls=source.video_img_urls,
_format=source._format,
style=source.style,
extras=source.extras
)
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.7
)
return response.choices[0].message.content.strip()
def message_builder(segments, image_urls, **kwargs):
return self.create_messages(segments, video_img_urls=image_urls, **kwargs)
chunker = RequestChunker(message_builder, self.max_request_bytes, self._estimate_messages_bytes)
try:
chunks = chunker.chunk(
source.segment,
source.video_img_urls or [],
title=source.title,
tags=source.tags,
_format=source._format,
style=source.style,
extras=source.extras
)
except ValueError:
chunks = chunker.chunk(
source.segment,
[],
title=source.title,
tags=source.tags,
_format=source._format,
style=source.style,
extras=source.extras
)
partials = []
if checkpoint_key and source_signature:
checkpoint = self._load_checkpoint(checkpoint_key, source_signature)
if checkpoint and isinstance(checkpoint.get("partials"), list):
partials = checkpoint["partials"]
if len(partials) > len(chunks):
partials = []
for chunk in chunks[len(partials):]:
messages = self.create_messages(
chunk.segments,
title=source.title,
tags=source.tags,
video_img_urls=chunk.image_urls,
_format=source._format,
style=source.style,
extras=source.extras
)
try:
response = self._chat_completion_create(messages)
except Exception as exc:
if checkpoint_key and source_signature:
self._save_checkpoint(checkpoint_key, source_signature, partials, "summarize")
raise
partials.append(response.choices[0].message.content.strip())
if checkpoint_key and source_signature:
self._save_checkpoint(checkpoint_key, source_signature, partials, "summarize")
if len(partials) == 1:
if checkpoint_key:
self._clear_checkpoint(checkpoint_key)
return partials[0]
merged = self._merge_partials(partials, checkpoint_key, source_signature)
if checkpoint_key:
self._clear_checkpoint(checkpoint_key)
return merged

View File

@@ -11,5 +11,5 @@ class AudioDownloadResult:
platform: str # 平台,如 "bilibili"
video_id: str # 唯一视频ID
raw_info: dict # yt-dlp 的原始 info 字典
video_path: Optional[str] = None # 新增字段:可选视频文件路径
video_path: Optional[str] = None # 新增字段:可选视频文件路径

View File

@@ -15,4 +15,5 @@ class GPTSource:
extras: Optional[str] = None
_format: Optional[list] = None
video_img_urls: Optional[list] = None
checkpoint_key: Optional[str] = None

101
backend/app/routers/chat.py Normal file
View File

@@ -0,0 +1,101 @@
from fastapi import APIRouter, BackgroundTasks
from pydantic import BaseModel
from app.services.chat_service import chat as chat_service
from app.services.vector_store import VectorStoreManager
from app.utils.logger import get_logger
from app.utils.response import ResponseWrapper as R
logger = get_logger(__name__)
router = APIRouter()
# 索引状态追踪: task_id -> "indexing" | "indexed" | "failed"
_index_status: dict[str, str] = {}
class IndexRequest(BaseModel):
task_id: str
class ChatMessage(BaseModel):
role: str
content: str
class AskRequest(BaseModel):
task_id: str
question: str
history: list[ChatMessage] = []
provider_id: str
model_name: str
def _do_index(task_id: str):
"""后台执行索引任务。"""
try:
_index_status[task_id] = "indexing"
store = VectorStoreManager()
store.index_task(task_id)
_index_status[task_id] = "indexed"
logger.info(f"索引完成: {task_id}")
except Exception as e:
_index_status[task_id] = "failed"
logger.error(f"索引失败: {task_id}, {e}")
@router.post("/chat/index")
def index_task(data: IndexRequest, background_tasks: BackgroundTasks):
"""触发后台索引,立即返回。"""
if _index_status.get(data.task_id) == "indexing":
return R.success(msg="正在索引中")
# 如果已经索引过,直接返回
store = VectorStoreManager()
if store.is_indexed(data.task_id):
_index_status[data.task_id] = "indexed"
return R.success(msg="已完成索引")
_index_status[data.task_id] = "indexing"
background_tasks.add_task(_do_index, data.task_id)
return R.success(msg="开始索引")
@router.get("/chat/status")
def chat_status(task_id: str):
"""返回索引状态idle / indexing / indexed / failed。"""
try:
# 优先检查内存状态
status = _index_status.get(task_id)
if status:
return R.success(data={"status": status, "indexed": status == "indexed"})
# 内存没有记录,检查持久化
store = VectorStoreManager()
indexed = store.is_indexed(task_id)
if indexed:
_index_status[task_id] = "indexed"
return R.success(data={"status": "indexed" if indexed else "idle", "indexed": indexed})
except Exception as e:
logger.error(f"查询索引状态失败: {e}")
return R.success(data={"status": "idle", "indexed": False})
@router.post("/chat/ask")
def ask_question(data: AskRequest):
"""基于笔记内容的 RAG 问答。"""
try:
history = [{"role": m.role, "content": m.content} for m in data.history]
result = chat_service(
task_id=data.task_id,
question=data.question,
history=history,
provider_id=data.provider_id,
model_name=data.model_name,
)
return R.success(data=result)
except ValueError as e:
return R.error(msg=str(e))
except Exception as e:
logger.error(f"Chat 问答失败: {e}", exc_info=True)
return R.error(msg=f"问答失败: {str(e)}")

View File

@@ -1,12 +1,23 @@
from fastapi import APIRouter, HTTPException
import os
import platform
from pathlib import Path
from fastapi import APIRouter, HTTPException, BackgroundTasks
from pydantic import BaseModel
from typing import Optional
from app.utils.response import ResponseWrapper as R
from app.utils.logger import get_logger
from app.utils.path_helper import get_model_dir
from app.services.cookie_manager import CookieConfigManager
from app.services.transcriber_config_manager import TranscriberConfigManager
from ffmpeg_helper import ensure_ffmpeg_or_raise
logger = get_logger(__name__)
router = APIRouter()
cookie_manager = CookieConfigManager()
transcriber_config_manager = TranscriberConfigManager()
class CookieUpdateRequest(BaseModel):
@@ -27,4 +38,211 @@ def get_cookie(platform: str):
@router.post("/update_downloader_cookie")
def update_cookie(data: CookieUpdateRequest):
cookie_manager.set(data.platform, data.cookie)
return {"message": "Cookie updated successfully"}
return R.success(
)
class TranscriberConfigRequest(BaseModel):
transcriber_type: str
whisper_model_size: Optional[str] = None
AVAILABLE_TRANSCRIBER_TYPES = [
{"value": "fast-whisper", "label": "Faster Whisper本地"},
{"value": "bcut", "label": "必剪(在线)"},
{"value": "kuaishou", "label": "快手(在线)"},
{"value": "groq", "label": "Groq在线"},
{"value": "mlx-whisper", "label": "MLX Whisper仅macOS"},
]
WHISPER_MODEL_SIZES = ["tiny", "base", "small", "medium", "large-v3", "large-v3-turbo"]
@router.get("/transcriber_config")
def get_transcriber_config():
from app.transcriber.transcriber_provider import MLX_WHISPER_AVAILABLE
config = transcriber_config_manager.get_config()
return R.success(data={
**config,
"available_types": AVAILABLE_TRANSCRIBER_TYPES,
"whisper_model_sizes": WHISPER_MODEL_SIZES,
"mlx_whisper_available": MLX_WHISPER_AVAILABLE,
})
@router.post("/transcriber_config")
def update_transcriber_config(data: TranscriberConfigRequest):
config = transcriber_config_manager.update_config(
transcriber_type=data.transcriber_type,
whisper_model_size=data.whisper_model_size,
)
return R.success(data=config)
# ---- Whisper 模型下载状态 & 下载触发 ----
# 用于跟踪正在进行的下载任务
_downloading: dict[str, str] = {} # model_size -> status ("downloading" | "done" | "failed")
def _check_whisper_model_exists(model_size: str, subdir: str = "whisper") -> bool:
"""检查指定 whisper 模型是否已下载到本地。"""
model_dir = get_model_dir(subdir)
model_path = os.path.join(model_dir, f"whisper-{model_size}")
return Path(model_path).exists()
@router.get("/transcriber_models_status")
def get_transcriber_models_status():
"""返回所有 whisper 模型的下载状态。"""
statuses = []
for size in WHISPER_MODEL_SIZES:
downloaded = _check_whisper_model_exists(size, "whisper")
download_status = _downloading.get(size)
statuses.append({
"model_size": size,
"downloaded": downloaded,
"downloading": download_status == "downloading",
})
# 也检查 mlx-whisper仅 macOS
mlx_available = platform.system() == "Darwin"
mlx_statuses = []
if mlx_available:
for size in WHISPER_MODEL_SIZES:
mlx_key = f"mlx-{size}"
model_dir = get_model_dir("mlx-whisper")
model_path = os.path.join(model_dir, f"mlx-community/whisper-{size}")
downloaded = Path(model_path).exists()
mlx_statuses.append({
"model_size": size,
"downloaded": downloaded,
"downloading": _downloading.get(mlx_key) == "downloading",
})
return R.success(data={
"whisper": statuses,
"mlx_whisper": mlx_statuses,
"mlx_available": mlx_available,
})
class ModelDownloadRequest(BaseModel):
model_size: str
transcriber_type: str = "fast-whisper" # "fast-whisper" 或 "mlx-whisper"
def _do_download_whisper(model_size: str):
"""后台下载 faster-whisper 模型。"""
from app.transcriber.whisper import MODEL_MAP
from modelscope import snapshot_download
try:
_downloading[model_size] = "downloading"
model_dir = get_model_dir("whisper")
model_path = os.path.join(model_dir, f"whisper-{model_size}")
if Path(model_path).exists():
_downloading[model_size] = "done"
return
repo_id = MODEL_MAP.get(model_size)
if not repo_id:
_downloading[model_size] = "failed"
return
logger.info(f"开始下载 whisper 模型: {model_size}")
snapshot_download(repo_id, local_dir=model_path)
logger.info(f"whisper 模型下载完成: {model_size}")
_downloading[model_size] = "done"
except Exception as e:
logger.error(f"whisper 模型下载失败: {model_size}, {e}")
_downloading[model_size] = "failed"
def _do_download_mlx_whisper(model_size: str):
"""后台下载 mlx-whisper 模型。"""
key = f"mlx-{model_size}"
try:
_downloading[key] = "downloading"
from huggingface_hub import snapshot_download as hf_download
model_dir = get_model_dir("mlx-whisper")
model_name = f"mlx-community/whisper-{model_size}"
model_path = os.path.join(model_dir, model_name)
if Path(model_path).exists():
_downloading[key] = "done"
return
logger.info(f"开始下载 mlx-whisper 模型: {model_size}")
hf_download(model_name, local_dir=model_path, local_dir_use_symlinks=False)
logger.info(f"mlx-whisper 模型下载完成: {model_size}")
_downloading[key] = "done"
except Exception as e:
logger.error(f"mlx-whisper 模型下载失败: {model_size}, {e}")
_downloading[key] = "failed"
@router.post("/transcriber_download")
def download_transcriber_model(data: ModelDownloadRequest, background_tasks: BackgroundTasks):
"""触发后台下载指定的 whisper 模型。"""
if data.model_size not in WHISPER_MODEL_SIZES:
return R.error(msg=f"不支持的模型大小: {data.model_size}")
if data.transcriber_type == "mlx-whisper":
if platform.system() != "Darwin":
return R.error(msg="MLX Whisper 仅支持 macOS")
key = f"mlx-{data.model_size}"
if _downloading.get(key) == "downloading":
return R.success(msg="模型正在下载中")
background_tasks.add_task(_do_download_mlx_whisper, data.model_size)
else:
if _downloading.get(data.model_size) == "downloading":
return R.success(msg="模型正在下载中")
background_tasks.add_task(_do_download_whisper, data.model_size)
return R.success(msg="模型下载已开始")
@router.get("/sys_health")
async def sys_health():
try:
ensure_ffmpeg_or_raise()
return R.success()
except EnvironmentError:
return R.error(msg="系统未安装 ffmpeg 请先进行安装")
@router.get("/sys_check")
async def sys_check():
return R.success()
@router.get("/deploy_status")
async def deploy_status():
"""返回部署监控所需的所有状态信息"""
import torch
import os
# CUDA 状态
cuda_available = torch.cuda.is_available()
cuda_info = {
"available": cuda_available,
"version": torch.version.cuda if cuda_available else None,
"gpu_name": torch.cuda.get_device_name(0) if cuda_available else None,
}
# Whisper 模型状态(从配置文件读取,与前端设置同步)
transcriber_cfg = transcriber_config_manager.get_config()
model_size = transcriber_cfg["whisper_model_size"]
transcriber_type = transcriber_cfg["transcriber_type"]
# FFmpeg 状态
try:
ensure_ffmpeg_or_raise()
ffmpeg_ok = True
except:
ffmpeg_ok = False
return R.success(data={
"backend": {"status": "running", "port": int(os.getenv("BACKEND_PORT", 8483))},
"cuda": cuda_info,
"whisper": {"model_size": model_size, "transcriber_type": transcriber_type},
"ffmpeg": {"available": ffmpeg_ok},
})

View File

@@ -19,18 +19,33 @@ def model_list():
return R.success(modelService.get_all_models(True),msg="获取模型列表成功")
except Exception as e:
return R.error(e)
@router.get("/models/delete/{model_id}")
def delete_model(model_id: int):
try:
success = modelService.delete_model_by_id(model_id)
if success:
return R.success(msg="模型删除成功")
else:
return R.error("模型不存在或删除失败")
except Exception as e:
return R.error(f"删除模型失败: {e}")
@router.get("/model_list/{provider_id}")
def model_list(provider_id):
try:
return R.success(modelService.get_all_models_by_id(provider_id))
except Exception as e:
return R.error(e)
return R.success(modelService.get_all_models_by_id(provider_id))
@router.post("/models")
def create_model(data: CreateModelRequest):
success = ModelService.add_new_model(data.provider_id, data.model_name)
if not success:
raise R.error("模型添加失败")
return R.error("模型添加失败")
return R.success(msg="模型添加成功")
@router.get("/model_enable/{provider_id}")
def get_enabled_models_by_provider(provider_id: str):
try:
models = modelService.get_enabled_models_by_provider(provider_id)
return R.success(models, msg="获取启用模型成功")
except Exception as e:
return R.error(f"获取启用模型失败: {e}")

View File

@@ -2,6 +2,7 @@
import json
import os
import uuid
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
@@ -10,8 +11,11 @@ from pydantic import BaseModel, validator, field_validator
from dataclasses import asdict
from app.db.video_task_dao import get_task_by_video
from app.enmus.exception import NoteErrorEnum
from app.enmus.note_enums import DownloadQuality
from app.exceptions.note import NoteError
from app.services.note import NoteGenerator, logger
from app.services.task_serial_executor import task_serial_executor
from app.utils.response import ResponseWrapper as R
from app.utils.url_parser import extract_video_id
from app.validators.video_url_validator import is_supported_video_url
@@ -54,12 +58,13 @@ class VideoRequest(BaseModel):
if parsed.scheme in ("http", "https"):
# 是网络链接,继续用原有平台校验
if not is_supported_video_url(url):
raise ValueError("暂不支持该视频平台或链接格式无效")
raise NoteError(code=NoteErrorEnum.PLATFORM_NOT_SUPPORTED.code,
message=NoteErrorEnum.PLATFORM_NOT_SUPPORTED.message)
return v
NOTE_OUTPUT_DIR = "note_results"
NOTE_OUTPUT_DIR = os.getenv("NOTE_OUTPUT_DIR", "note_results")
UPLOAD_DIR = "uploads"
@@ -74,11 +79,12 @@ def run_note_task(task_id: str, video_url: str, platform: str, quality: Download
_format: list = None, style: str = None, extras: str = None, video_understanding: bool = False,
video_interval=0, grid_size=[]
):
try:
if not model_name or not provider_id:
raise HTTPException(status_code=400, detail="请选择模型和提供者")
note = NoteGenerator().generate(
if not model_name or not provider_id:
raise HTTPException(status_code=400, detail="请选择模型和提供者")
def _execute_note_task():
return NoteGenerator().generate(
video_url=video_url,
platform=platform,
quality=quality,
@@ -89,22 +95,33 @@ def run_note_task(task_id: str, video_url: str, platform: str, quality: Download
_format=_format,
style=style,
extras=extras,
screenshot=screenshot
, video_understanding=video_understanding,
screenshot=screenshot,
video_understanding=video_understanding,
video_interval=video_interval,
grid_size=grid_size
grid_size=grid_size,
)
logger.info(f"Note generated: {task_id}")
save_note_to_file(task_id, note)
logger.info(f"任务进入执行队列 (task_id={task_id})")
note = task_serial_executor.run(_execute_note_task)
logger.info(f"Note generated: {task_id}")
if not note or not note.markdown:
logger.warning(f"任务 {task_id} 执行失败,跳过保存")
return
save_note_to_file(task_id, note)
# 自动建立向量索引(用于 AI 问答),失败不影响笔记生成
try:
from app.services.vector_store import VectorStoreManager
VectorStoreManager().index_task(task_id)
except Exception as e:
save_note_to_file(task_id, {"error": str(e)})
logger.warning(f"向量索引失败(不影响笔记): {e}")
@router.post('/delete_task')
def delete_task(data: RecordRequest):
try:
NoteGenerator().delete_note(video_id=data.video_id, platform=data.platform)
# TODO: 待持久化完成
# NoteGenerator().delete_note(video_id=data.video_id, platform=data.platform)
return R.success(msg='删除成功')
except Exception as e:
return R.error(msg=e)
@@ -135,17 +152,17 @@ def generate_note(data: VideoRequest, background_tasks: BackgroundTasks):
# msg='笔记已生成,请勿重复发起',
#
# )
if data.task_id:
# 如果传了task_id说明是重试
task_id = data.task_id
# 更新之前的状态
NoteGenerator.update_task_status(task_id, TaskStatus.PENDING)
logger.info(f"重试模式,复用已有 task_id={task_id}")
else:
# 正常新建任务
task_id = str(uuid.uuid4())
# 统一先写入 PENDING表示已进入队列等待串行执行
NoteGenerator()._update_status(task_id, TaskStatus.PENDING)
background_tasks.add_task(run_note_task, task_id, data.video_url, data.platform, data.quality, data.link,
data.screenshot, data.model_name, data.provider_id, data.format, data.style,
data.extras, data.video_understanding, data.video_interval, data.grid_size)
@@ -233,7 +250,7 @@ async def image_proxy(request: Request, url: str):
resp.aiter_bytes(),
media_type=content_type,
headers={
"Cache-Control": "public, max-age=86400", # 缓存一天
"Cache-Control": "public, max-age=86400", # 缓存一天
"Content-Type": content_type,
}
)

View File

@@ -2,6 +2,7 @@ from typing import Optional
from fastapi import APIRouter
from pydantic import BaseModel
from app.exceptions.provider import ProviderError
from app.models.model_config import ModelConfig
from app.services.model import ModelService
from app.utils.response import ResponseWrapper as R
@@ -9,7 +10,7 @@ from app.services.provider import ProviderService
router = APIRouter()
# 新增 type 字段
# 新增 type 字段
class ProviderRequest(BaseModel):
name: str
api_key: str
@@ -18,9 +19,7 @@ class ProviderRequest(BaseModel):
type: str
class TestRequest(BaseModel):
api_key: str
base_url:str
id: str
class ProviderUpdateRequest(BaseModel):
id: str
name: Optional[str] = None
@@ -33,14 +32,14 @@ class ProviderUpdateRequest(BaseModel):
@router.post("/add_provider")
def add_provider(data: ProviderRequest):
try:
ProviderService.add_provider(
res = ProviderService.add_provider(
name=data.name,
api_key=data.api_key,
base_url=data.base_url,
logo=data.logo,
type_=data.type
)
return R.success(msg='添加模型供应商成功')
return R.success(msg='添加模型供应商成功',data=res)
except Exception as e:
return R.error(msg=e)
@@ -78,23 +77,19 @@ def update_provider(data: ProviderUpdateRequest):
):
return R.error(msg='请至少填写一个参数')
ProviderService.update_provider(
updated_provider =ProviderService.update_provider(
id=data.id,
data=dict(data)
)
return R.success(msg='更新模型供应商成功')
if updated_provider:
return R.success(msg='更新模型供应商成功', data=updated_provider)
else:
return R.error(msg='更新模型供应商失败')
except Exception as e:
print(e)
return R.error(msg=e)
return R.error(msg=str(e))
@router.post('/connect_test')
def gpt_connect_test(data:TestRequest):
try:
res= ModelService().connect_test(data.api_key,data.base_url)
if not res:
return R.error(msg='连接失败')
return R.success(msg='连接成功')
except Exception as e:
print(e)
return R.error(msg=e)
def gpt_connect_test(data: TestRequest):
ModelService().connect_test(data.id)
return R.success(msg='连接成功')

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