Compare commits

..

203 Commits

Author SHA1 Message Date
黄建武
5985828721 chore: 添加环境变量配置示例
- 新增 .env.example 文件,包含项目所需的环境变量配置
- 包括端口配置、生产环境配置、FFMPEG 配置和 transcriber配置等
- 为开发和生产环境提供了默认配置值
2025-07-13 10:22:14 +08:00
Jianwu Huang
52d351e429 Merge pull request #187 from dfyuik/master 2025-07-13 10:06:47 +08:00
yangyuguang
ab8cdc416a feat: 添加硅基流动(SiliconFlow)支持和错误处理优化
## 主要更新

### 新增功能
- 新增 SiliconFlow_provider.py 专用提供商
- 添加硅基流动 API 集成文档
- 实现 Cherry Studio 风格的连接测试

### 错误处理优化
- 修复前端 Form.tsx 错误显示问题
- 改进 universal_gpt.py 异常处理逻辑
- 统一 URL 格式处理,避免路径重复

### 兼容性改进
- 优化 OpenAI 兼容提供商 URL 处理
- 增强模型列表获取的容错性
- 添加详细的调试日志

### 安全性提升
- 更新 .gitignore 保护敏感信息
- 移除示例配置文件

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 09:53:39 +08:00
Jianwu Huang
880f745718 Merge pull request #176 from Paper-Dragon/feat_docker_gpu_support
Add GPU support with Docker enhancements
2025-07-05 17:21:12 +08:00
Paper-Dragon
1ce8b41bde Add GPU support with Docker enhancements
- Introduced a `Dockerfile.gpu` for GPU-enabled backend setup.
- Added `docker-compose.gpu.yml` to utilize GPU resources via NVIDIA.
- Fixed Nginx configuration for GPU backend port changes.
2025-07-04 00:16:39 +08:00
Jianwu Huang
f667e9460b fix:修复 cpu 核心锁死问题
fix cpu 核心锁死问题
2025-07-03 10:28:33 +08:00
Jianwu Huang
5f346f1b04 Merge pull request #172 from Karasukaigan/master
fix: 修复多个前后端错误与警告
2025-07-02 15:13:35 +08:00
Karasukaigan
b813d83246 fix: 修复B站短链接无法解析的问题
增加了对b23.tv短链接的解析。
2025-07-02 15:03:03 +08:00
Karasukaigan
564eee2682 fix: 隐藏多余错误提示 2025-07-02 04:34:54 +08:00
Karasukaigan
8fecf293bb fix: 优化Schema校验逻辑
修复了以下问题:
1. 当视频链接为空时,原本的校验逻辑会导致首次点击生成笔记时报错“Required”而不是“视频链接不能为空”。
2. 当选择抖音时无法判断URL是否合法,即使填入“123”也能触发后面的逻辑。
2025-07-02 04:05:04 +08:00
Karasukaigan
ce76b78b34 fix: 缓解Ant Design与React版本兼容性问题
Ant Design v5与React 19+存在兼容性问题,出现报错:[antd: compatible] antd v5 support React is 16 ~ 18.

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

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

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

321
.dockerignore Normal file
View File

@@ -0,0 +1,321 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
.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
# Coverage directory used by tools like istanbul
coverage
*.lcov
# 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/
*.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/*

View File

@@ -1,29 +1,24 @@
###
# @Author: 思诺特 jefferyhcool@gmail.com
# @Date: 2025-04-14 08:49:59
# @LastEditors: 思诺特 jefferyhcool@gmail.com
# @LastEditTime: 2025-04-26 19:56:50
# @FilePath: \BiliNote\.env.example
# @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
###
# 通用端口配置
BACKEND_PORT=8001
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
BACKEND_HOST=0.0.0.0# 默认为 0.0.0.0,表示监听所有 IP 地址 不建议动
APP_PORT=3015# docker 部署时用
# 前端访问后端用 (开发环境使用)
VITE_API_BASE_URL=http://127.0.0.1:8483
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=base
GROQ_TRANSCRIBER_MODEL=whisper-large-v3-turbo# groq提供的faster-whisper 默认为 whisper-large-v3-turbo

49
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,49 @@
---
name: Bug report
about: 上报一些bug
title: "[BUG]"
labels: bug
assignees: JefferyHcool
---
---
name: 🐛 Bug 反馈
about: 提交一个 Bug 报告,帮助我们改进
title: "[Bug] "
labels: bug
assignees: ''
---
**版本说明**
请说明的你的版本号
**部署方式**
使用的是什么方式部署代码环境部署docker部署桌面端在线预览
**描述问题**
清晰、简明地描述你遇到的问题是什么。
**复现步骤**
复现该问题的步骤:
1. 进入页面 '...'
2. 点击 '...'
3. 滚动到 '...'
4. 出现错误
**预期行为**
清晰、简明地描述你本来预期发生的行为。
**截图**
如果适用,请添加截图以帮助说明问题。
**桌面端(请补充以下信息)**
- 操作系统:例如 Windows / macOS / Ubuntu
- 浏览器:例如 Chrome、Safari
**其他补充信息**
请补充任何其他相关信息。

View File

@@ -0,0 +1,29 @@
---
name: 新增功能建议
about: 一些新的功能建议
title: "[FEATHURE]"
labels: enhancement
assignees: JefferyHcool
---
---
name: ✨ 功能请求
about: 提出一个新的功能建议
title: "[Feature] "
labels: enhancement
assignees: ''
---
**这个功能请求是否与某个问题相关?请描述**
清晰简要地描述问题是什么。例如:每次遇到 [...] 都让我感到很沮丧。
**描述你希望实现的解决方案**
清晰简要地描述你希望发生的事情。
**描述你考虑过的备选方案**
清晰简要地描述你考虑过的其他解决方案或功能。
**其他补充信息**
请在此添加关于功能请求的其他上下文或截图。

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

@@ -0,0 +1,67 @@
# .github/workflows/release.yml
name: Build Desktop App (Python Backend + Tauri Frontend)
on:
push:
tags:
- 'v*' # 发布 tag 时触发
workflow_dispatch:
jobs:
build:
strategy:
matrix:
platform: [macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout Code
uses: actions/checkout@v3
# 设置 Python 环境
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
# 安装 Python 依赖并执行你的 build.sh
- 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
# 设置 Node 环境 + 安装前端依赖
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Enable Corepack + Install pnpm
working-directory: BillNote_frontend
run: |
corepack enable
pnpm install
# 设置 Rust 环境
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
# 打包 Tauri 应用
- name: Build Tauri App
working-directory: BillNote_frontend
run: pnpm tauri build
# 可选:上传构建产物
- name: Upload Desktop Bundle
uses: actions/upload-artifact@v4
with:
name: app-${{ matrix.platform }}
path: BillNote_frontend/src-tauri/target/release/bundle/

57
.gitignore vendored
View File

@@ -190,7 +190,7 @@ cover/
# Translations
*.mo
*.pot
.idea/
# Django stuff:
*.log
local_settings.py
@@ -308,6 +308,7 @@ cython_debug/
# PyPI configuration file
.pypirc
# 项目特定的私人信息和敏感文件
/backend/data/*
/backend/static/*
/backend/note_tasks.db
@@ -316,4 +317,56 @@ cython_debug/
/backend/note_results
/backend/models
/backend/.idea/*
/backend/bili_note.db
/backend/bili_note.db
/backend/uploads/*
/backend/.idea/*
/backend/config/*
/BiliNote_frontend/.idea/*
/BiliNote_frontend/src-tauri/bin/
# 额外需要忽略的敏感文件和目录
/backend/test_env/
/backend/test_*.py
/backend/venv/
/backend/get-pip.py
/backend/fonts/
*.db
*.sqlite
*.sqlite3
*_backup.sql
# API测试脚本可能包含敏感信息
test_siliconflow.py
test_*.py
# 模型文件和缓存(通常很大,且可能有版权问题)
/backend/models/
whisper*/
*.bin
*.safetensors
*.gguf
# 用户生成内容
/backend/uploads/*
/backend/data/*
/backend/note_results/*
/backend/static/screenshots/*
/backend/static/cover/*
# 临时测试环境
test_env/
*_env/
# 配置文件可能包含API keys
/backend/config/*
config.json
settings.json
# 备份文件
*.bak
*.bak2
*_backup.*
# 输出和缓存目录
/backend/data/output_frames/*
/backend/data/grid_output/*

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
# === 前端构建阶段 ===
FROM node:18-alpine AS build
FROM node:18-alpine AS builder
# 安装 pnpm
RUN npm install -g pnpm
@@ -13,20 +13,13 @@ COPY ./BillNote_frontend /app
# 安装依赖并构建
RUN pnpm install && pnpm run build
# === nginx 运行阶段 ===
FROM nginx:alpine
# --- 阶段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 ./BillNote_frontend/deploy/default.conf.template /etc/nginx/templates/default.conf.template
# 拷贝构建产物
COPY --from=build /app/dist /usr/share/nginx/html
# 拷贝启动脚本
COPY ./BillNote_frontend/deploy/start.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 80
# 使用启动脚本启动容器
CMD ["/start.sh"]
COPY --from=builder /app/dist /usr/share/nginx/html

3850
BillNote_frontend/bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -21,37 +21,52 @@
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.1.3",
"@tauri-apps/plugin-shell": "~2.2.2",
"@uiw/react-markdown-preview": "^5.1.3",
"antd": "^5.24.8",
"axios": "^1.8.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fuse.js": "^7.1.0",
"github-markdown-css": "^5.8.1",
"katex": "^0.16.21",
"katex": "^0.16.22",
"lottie-react": "^2.4.1",
"lucide-react": "^0.487.0",
"markdown-navbar": "^1.4.3",
"markmap-common": "^0.18.9",
"markmap-lib": "^0.18.11",
"markmap-toolbar": "^0.18.10",
"markmap-view": "^0.18.10",
"next-themes": "^0.4.6",
"pinyin-match": "^1.2.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.55.0",
"react-hot-toast": "^2.5.2",
"react-markdown": "^10.1.0",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^8.0.7",
"react-medium-image-zoom": "^5.2.14",
"react-resizable-panels": "^2.1.8",
"react-router-dom": "^7.5.1",
"react-syntax-highlighter": "^15.6.1",
"remark-gfm": "1.0.0",
"rehype-katex": "^6.0.2",
"remark-gfm": "3.0.1",
"remark-math": "^5.1.1",
"sonner": "^2.0.3",
"tailwind-merge": "^3.1.0",
"tailwindcss": "^4.1.3",
"tw-animate-css": "^1.2.5",
"uuid": "^11.1.0",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@tailwindcss/postcss": "^4.1.3",
"@tauri-apps/cli": "^2.5.0",
"@types/node": "^22.14.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "BiliNote",
"version": "1.8.1",
"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

@@ -5,21 +5,42 @@ import SettingPage from './pages/SettingPage/index.tsx'
import { BrowserRouter, Navigate, Routes } from 'react-router-dom'
import { Route } from 'react-router-dom'
import Index from '@/pages/Index.tsx'
import NotFoundPage from '@/pages/NotFoundPage' //
import 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 { useEffect } from 'react'
import { systemCheck } from '@/services/system.ts'
import { useCheckBackend } from '@/hooks/useCheckBackend.ts'
import BackendInitDialog from '@/components/BackendInitDialog'
function App() {
useTaskPolling(3000) // 每 3 秒轮询一次
const steps = [
{ label: '解析链接', key: 'PARSING', icon: <Downloading /> },
{ label: '下载音频', key: 'DOWNLOADING' },
{ label: '转写文字', key: 'TRANSCRIBING' },
{ label: '总结内容', key: 'SUMMARIZING' },
{ label: '保存完成', key: 'SUCCESS' },
]
const { loading, initialized } = useCheckBackend()
// 在后端初始化完成后执行系统检查
useEffect(() => {
if (initialized) {
systemCheck()
}
}, [initialized])
// 如果后端还未初始化,显示初始化对话框
if (!initialized) {
return (
<>
<BackendInitDialog open={loading} />
</>
)
}
// 后端已初始化,渲染主应用
return (
<>
<BrowserRouter>
@@ -30,10 +51,12 @@ function App() {
<Route index element={<Navigate to="model" replace />} />
<Route path="model" element={<Model />}>
<Route path="new" element={<ProviderForm isCreate />} />
{/*<Route index element={<Navigate to="openai" replace />} />*/}
<Route path=":id" element={<ProviderForm />} />
</Route>
<Route path="transcriber" elment={<Transcriber />}></Route>
<Route path="download" element={<Downloader />}>
<Route path=":id" element={<DownloaderForm />} />
</Route>
<Route path="about" element={<AboutPage />}></Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
@@ -44,4 +67,4 @@ function App() {
)
}
export default App
export default App

View File

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

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ import { useParams, useNavigate } from 'react-router-dom'
import { useProviderStore } from '@/store/providerStore'
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { testConnection, fetchModels } from '@/services/model.ts'
import { testConnection, fetchModels, deleteModelById } from '@/services/model.ts'
import {
Select,
SelectContent,
@@ -26,6 +26,9 @@ import {
} from '@/components/ui/select.tsx' // ⚡新增 fetchModels
import { ModelSelector } from '@/components/Form/modelForm/ModelSelector.tsx'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert.tsx'
import { Tags } from 'lucide-react'
import { Tag } from 'antd'
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,22 @@ 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
}
} catch (error) {
toast.error('测试连通性异常')
setTesting(true)
await testConnection({
id
})
toast.success('测试连通性成功 🎉')
} catch (error: any) {
console.error('连接测试失败:', error)
const errorMsg = error?.response?.data?.msg || error?.message || '未知错误'
toast.error(`连接失败: ${errorMsg}`)
// toast.error('测试连通性异常')
} finally {
setTesting(false)
}
@@ -162,18 +193,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 +301,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 (
<>
<Tag onClose={()=>{
handelDelete(model.id)
}} key={model.id} closable color={'blue'}>
{model.model_name}
</Tag></>
)
})
}
</div>
{/*<ModelSelector providerId={id!} />*/}
{/*<datalist id="model-options">*/}
{/* {modelOptions.map(model => (*/}
{/* <option key={model.id + '1'} value={model.id} />*/}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { FC } from 'react'
import Lottie from 'lottie-react'
import error from '@/assets/Lottie/error.json'
import error from '@/assets/Lottie/Error.json'
const Error: FC = () => {
return (

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,54 @@
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

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

View File

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

View File

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

View File

@@ -26,11 +26,11 @@ export const useTaskPolling = (interval = 3000) => {
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,7 +47,7 @@ export const useTaskPolling = (interval = 3000) => {
}
} catch (e) {
console.error('❌ 任务轮询失败:', e)
toast.error(`生成失败 ${e.message || e}`)
// toast.error(`生成失败 ${e.message || e}`)
updateTaskContent(task.id, { status: 'FAILED' })
// removeTask(task.id)
}

View File

@@ -2,10 +2,11 @@
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
html,body{
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}
/* 修改滚动条轨道颜色 */
::-webkit-scrollbar {
width: 8px; /* 控制滚动条的宽度 */

View File

@@ -9,7 +9,9 @@ import {
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable'
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
import logo from '@/assets/icon.svg'
interface IProps {
NoteForm: React.ReactNode
Preview: React.ReactNode
@@ -19,59 +21,57 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
const [, setShowSettings] = useState(false)
return (
<div className="flex h-screen flex-col overflow-hidden bg-white">
<div className="flex flex-1">
{/* 左侧部分Header + 表单 */}
<aside className="flex w-[340px] flex-col border-r border-neutral-200 bg-white">
{/* Header */}
<header className="flex h-16 items-center justify-between px-6">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-2xl">
<img src="/icon.svg" alt="logo" className="h-full w-full object-contain" />
<div className="flex h-screen flex-col overflow-hidden">
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
{/* 左表单 */}
<ResizablePanel defaultSize={23} minSize={10} maxSize={35}>
<aside className="flex h-full flex-col overflow-hidden border-r border-neutral-200 bg-white">
<header className="flex h-16 items-center justify-between px-6">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-2xl">
<img src={logo} alt="logo" className="h-full w-full object-contain" />
</div>
<div className="text-2xl font-bold text-gray-800">BiliNote</div>
</div>
<div className="text-2xl font-bold text-gray-800">BiliNote</div>
</div>
<div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger onClick={() => setShowSettings(true)}>
<Link to={'/settings'}>
<SlidersHorizontal className="text-muted-foreground hover:text-primary cursor-pointer" />
</Link>
</TooltipTrigger>
<TooltipContent>
<span></span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</header>
<div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger onClick={() => setShowSettings(true)}>
<Link to={'/settings'}>
<SlidersHorizontal className="text-muted-foreground hover:text-primary cursor-pointer" />
</Link>
</TooltipTrigger>
<TooltipContent>
<span></span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</header>
<ScrollArea className="flex-1 overflow-auto">
<div className=' p-4' >{NoteForm}</div>
</ScrollArea>
</aside>
</ResizablePanel>
{/* 表单内容 */}
<div className="flex-1 overflow-auto p-4">
{/*<NoteForm />*/}
{NoteForm}
</div>
</aside>
<aside className="flex h-full w-[300px] flex-col border-r border-neutral-200 bg-white">
{/* Header */}
<ResizableHandle />
{/* 表单内容 */}
{/*<NoteForm />*/}
{History}
</aside>
{/* 中间历史 */}
<ResizablePanel defaultSize={16} minSize={10} maxSize={30}>
<aside className="flex h-full flex-col overflow-hidden border-r border-neutral-200 bg-white">
<ScrollArea className="flex-1 overflow-auto">
<div className="">{History}</div>
</ScrollArea>
</aside>
</ResizablePanel>
{/* 右侧预览区域 */}
<main className="h-screen flex-1 overflow-hidden bg-white p-6">
{/*<Outlet />*/}
{Preview}
</main>
</div>
<ResizableHandle />
{/* 页脚 */}
{/*<footer className="h-12 bg-white shadow-inner flex items-center justify-center text-sm text-neutral-600">*/}
{/* © 2025 BiliNote. All rights reserved.*/}
{/*</footer>*/}
{/* 右边预览 */}
<ResizablePanel defaultSize={61} minSize={30}>
<main className="flex h-full flex-col overflow-hidden bg-white p-6">{Preview}</main>
</ResizablePanel>
</ResizablePanelGroup>
</div>
)
}

View File

@@ -7,6 +7,8 @@ import {
import { Link, Outlet } from 'react-router-dom'
import { SlidersHorizontal } from 'lucide-react'
import React from 'react'
import logo from '@/assets/icon.svg'
interface ISettingLayoutProps {
Menu: React.ReactNode
}
@@ -25,7 +27,7 @@ const SettingLayout = ({ Menu }: ISettingLayoutProps) => {
<header className="flex h-16 items-center justify-between px-6">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-2xl">
<img src="/icon.svg" alt="logo" className="h-full w-full object-contain" />
<img src={logo} alt="logo" className="h-full w-full object-contain" />
</div>
<div className="text-2xl font-bold text-gray-800">BiliNote</div>
</div>

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ const History = () => {
<Clock className="h-4 w-4 text-neutral-500" />
<h2 className="text-base font-medium text-neutral-900"></h2>
</div>
<ScrollArea className="h-[800px] w-full">
<ScrollArea className="w-full sm:h-[480px] md:h-[720px] lg:h-[92%]">
{/*<div className="w-full flex-1 overflow-y-auto">*/}
<NoteHistory onSelect={setCurrentTask} selectedId={currentTaskId} />
{/*</div>*/}

View File

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

View File

@@ -1,19 +1,39 @@
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import { Button } from '@/components/ui/button.tsx'
import { Copy, Download, FileText, ArrowRight } from 'lucide-react'
import { toast } from 'sonner' // 你可以换成自己的通知组件
import { Copy, Download, ArrowRight, Play, ExternalLink } from 'lucide-react'
import { toast } from 'react-hot-toast'
import Error from '@/components/Lottie/error.tsx'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { solarizedlight as codeStyle } from 'react-syntax-highlighter/dist/cjs/styles/prism'
import 'github-markdown-css/github-markdown-light.css'
import { FC } from 'react'
import Loading from '@/components/Lottie/Loading.tsx'
import Idle from '@/components/Lottie/Idle.tsx'
import { useTaskStore } from '@/store/taskStore'
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
interface MarkdownViewerProps {
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { atomDark as codeStyle } from 'react-syntax-highlighter/dist/esm/styles/prism'
import Zoom from 'react-medium-image-zoom'
import 'react-medium-image-zoom/dist/styles.css'
import gfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
import 'github-markdown-css/github-markdown-light.css'
import { 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 MarkmapEditor from '@/pages/HomePage/components/MarkmapComponent.tsx'
interface VersionNote {
ver_id: string
content: string
style: string
model_name: string
created_at?: string
}
interface MarkdownViewerProps {
content: string | VersionNote[]
status: 'idle' | 'loading' | 'success' | 'failed'
}
@@ -25,36 +45,102 @@ const steps = [
{ label: '保存完成', key: 'SUCCESS' },
]
const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
const MarkdownViewer: FC<MarkdownViewerProps> = ({ status }) => {
const [copied, setCopied] = useState(false)
const [currentVerId, setCurrentVerId] = useState<string>('')
const [selectedContent, setSelectedContent] = useState<string>('')
const [modelName, setModelName] = useState<string>('')
const [style, setStyle] = useState<string>('')
const [createTime, setCreateTime] = useState<string>('')
const baseURL = String(import.meta.env.VITE_API_BASE_URL).replace('/api','') || ''
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 [viewMode, setViewMode] = useState<'map' | 'preview'>('preview')
const svgRef = useRef<SVGSVGElement>(null)
// 多版本内容处理
useEffect(() => {
if (!currentTask) return
if (!isMultiVersion) {
setCurrentVerId('') // 清空旧版本 ID
setModelName(currentTask.formData.model_name)
setStyle(currentTask.formData.style)
setCreateTime(currentTask.createdAt)
setSelectedContent(currentTask?.markdown)
} else {
const latestVersion = [...currentTask.markdown].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)[0]
if (latestVersion) {
setCurrentVerId(latestVersion.ver_id)
}
}
}, [currentTask?.id, taskStatus])
useEffect(() => {
if (!currentTask || !isMultiVersion) return
const currentVer = currentTask.markdown.find(v => v.ver_id === currentVerId)
if (currentVer) {
setModelName(currentVer.model_name)
setStyle(currentVer.style)
setCreateTime(currentVer.created_at || '')
setSelectedContent(currentVer.content)
}
}, [currentVerId, currentTask?.id])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content)
await navigator.clipboard.writeText(selectedContent)
setCopied(true)
toast.success('已复制到剪贴板')
setTimeout(() => setCopied(false), 2000)
} catch (e) {
toast.error(`复制失败${e}`)
toast.error('复制失败', e)
toast.error('复制失败')
}
}
const alertButton = {
id: 'alert',
title: '测试警告',
content: '⚠️',
onClick: () => alert('你点击了自定义按钮!'),
}
const exportButton = {
id: 'export',
title: '导出思维导图',
content: '⤓',
onClick: () => {
const svgEl = svgRef.current
if (!svgEl) return
// 同上面的序列化逻辑
const serializer = new XMLSerializer()
const source = serializer.serializeToString(svgEl)
const blob = new Blob(['<?xml version="1.0" encoding="UTF-8"?>', source], {
type: 'image/svg+xml;charset=utf-8',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'mindmap.svg'
a.click()
URL.revokeObjectURL(url)
},
}
const handleDownload = () => {
const currentTask = getCurrentTask()
const currentTaskName = currentTask?.audioMeta.title
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
const task = getCurrentTask()
const name = task?.audioMeta.title || 'note'
const blob = new Blob([selectedContent], { type: 'text/markdown;charset=utf-8' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `${currentTaskName}.md`
link.download = `${name}.md`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
if (status === 'loading') {
return (
<div className="flex h-screen w-full flex-col items-center justify-center space-y-4 text-neutral-500">
@@ -66,30 +152,29 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
</div>
</div>
)
} else if (status === 'idle') {
}
if (status === 'idle') {
return (
<div className="flex h-screen w-full flex-col items-center justify-center space-y-3 text-neutral-500">
<Idle></Idle>
<Idle />
<div className="text-center">
<p className="text-lg font-bold"></p>
<p className="mt-2 text-xs text-neutral-500">YouTube </p>
<p className="mt-2 text-xs text-neutral-500">YouTube </p>
</div>
</div>
)
} else if (status === 'failed') {
}
if (status === 'failed' && !isMultiVersion) {
return (
<div className="flex h-screen w-full flex-col items-center justify-center gap-4 space-y-3">
<Error /> {/* 你可以换成 Failed 动画 */}
<Error />
<div className="text-center">
<p className="text-lg font-bold text-red-500"></p>
<p className="mt-2 mb-2 text-xs text-red-400"></p>
<Button
onClick={() => {
retryTask(currentTask.id)
}}
size="lg"
>
<Button onClick={() => retryTask(currentTask.id)} size="lg">
</Button>
</div>
@@ -98,98 +183,307 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
}
return (
<div className="flex h-full w-full flex-col">
{/* 顶部操作栏 */}
<div className="mb-4 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-xl font-semibold text-neutral-900">
<FileText className="text-primary h-5 w-5" />
</h2>
<div className="flex items-center gap-2">
<Button onClick={handleCopy} variant="outline" size="sm">
<Copy className="mr-1 h-4 w-4" />
{copied ? '已复制' : '复制'}
</Button>
<Button onClick={handleDownload} variant="outline" size="sm">
<Download className="mr-1 h-4 w-4" />
Markdown
</Button>
<div className="flex h-screen w-full flex-col overflow-hidden">
<MarkdownHeader
currentTask={currentTask}
isMultiVersion={isMultiVersion}
currentVerId={currentVerId}
setCurrentVerId={setCurrentVerId}
modelName={modelName}
style={style}
noteStyles={noteStyles}
onCopy={handleCopy}
onDownload={handleDownload}
createAt={createTime}
showTranscribe={showTranscribe}
setShowTranscribe={setShowTranscribe}
viewMode={viewMode}
setViewMode={setViewMode}
/>
{viewMode === 'map' ? (
<div className="flex w-full flex-1 overflow-hidden bg-white">
<div className={'w-full'}>
<MarkmapEditor
value={selectedContent}
onChange={() => {}}
height="100%" // 根据需求可以设定百分比或固定高度
/>
</div>
</div>
</div>
{/* 滚动容器 */}
<div className="overflow-y-auto">
{(content && content != 'loading') || content != 'empty' ? (
<div className="markdown-body flex-1 bg-white">
{' '}
<ReactMarkdown
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
const codeContent = String(children).replace(/\n$/, '')
if (!inline && match) {
return (
<div className="group relative">
<SyntaxHighlighter
style={codeStyle}
language={match[1]}
PreTag="div"
) : (
<div className="flex flex-1 overflow-hidden bg-white py-2">
{selectedContent && selectedContent !== 'loading' && selectedContent !== 'empty' ? (
<>
<ScrollArea className="w-full">
<div className={'markdown-body w-full px-2'}>
<ReactMarkdown
remarkPlugins={[gfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
// Headings with improved styling and anchor links
h1: ({ children, ...props }) => (
<h1
className="text-primary my-6 scroll-m-20 text-3xl font-extrabold tracking-tight lg:text-4xl"
{...props}
>
{codeContent}
</SyntaxHighlighter>
<button
onClick={() => {
navigator.clipboard.writeText(codeContent)
toast.success('代码已复制')
}}
className="absolute top-2 right-2 hidden items-center gap-1 rounded border border-gray-300 bg-white/70 px-2 py-1 text-xs shadow-sm transition group-hover:flex hover:bg-white"
{children}
</h1>
),
h2: ({ children, ...props }) => (
<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}
>
<Copy className="h-3 w-3" />
</button>
</div>
)
}
{children}
</h2>
),
h3: ({ children, ...props }) => (
<h3
className="text-primary mt-8 mb-4 scroll-m-20 text-xl font-semibold tracking-tight"
{...props}
>
{children}
</h3>
),
h4: ({ children, ...props }) => (
<h4
className="text-primary mt-6 mb-2 scroll-m-20 text-lg font-semibold tracking-tight"
{...props}
>
{children}
</h4>
),
return (
<code className="rounded bg-gray-100 px-1 py-0.5 text-sm" {...props}>
{children}
</code>
)
},
}}
>
{content}
</ReactMarkdown>
</div>
) : (
<div className="flex h-screen w-full items-center justify-center">
<div className="w-[300px] flex-col justify-items-center">
<div className="bg-primary-light mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<ArrowRight className="text-primary h-8 w-8" />
// 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 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>
)
}
// Default link styling with external indicator
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>
)
},
// Enhanced image with zoom capability
img: ({ node, ...props }) =>{
let src = baseURL +props.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>
)},
// Better strong/bold text
strong: ({ children, ...props }) => (
<strong className="text-primary font-bold" {...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-primary my-4 text-lg font-bold">{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="border-primary/20 text-muted-foreground mt-6 border-l-4 pl-4 italic"
{...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 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>
)
}
// Inline code styling
return (
<code
className="bg-muted relative rounded 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-muted-foreground/20 border 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-muted-foreground/20 border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right"
{...props}
>
{children}
</td>
),
// Horizontal rule
hr: ({ ...props }) => (
<hr className="border-muted-foreground/20 my-8" {...props} />
),
}}
>
{selectedContent}
</ReactMarkdown>
</div>
</ScrollArea>
{showTranscribe && (
<div className={'ml-2 w-2/4'}>
<TranscriptViewer />
</div>
)}
</>
) : (
<div className="flex h-full w-full items-center justify-center">
<div className="w-[300px] flex-col justify-items-center">
<div className="bg-primary-light mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<ArrowRight className="text-primary h-8 w-8" />
</div>
<p className="mb-2 text-neutral-600">"生成笔记"</p>
<p className="text-xs text-neutral-500">YouTube等视频网站</p>
</div>
<p className="mb-2 text-neutral-600">"生成笔记"</p>
<p className="text-xs text-neutral-500">YouTube等视频网站</p>
</div>
</div>
)}
</div>
{/*<div className="markdown-body flex-1 overflow-y-auto bg-white">*/}
{/* {content ? (*/}
{/* */}
{/* ) : (*/}
{/* <>*/}
{/* <div className="w-16 h-16 bg-primary-light rounded-full flex items-center justify-center mb-4">*/}
{/* <ArrowRight className="h-8 w-8 text-primary" />*/}
{/* </div>*/}
{/* <p className="text-neutral-600 mb-2">输入视频链接并点击"生成笔记"按钮</p>*/}
{/* <p className="text-xs text-neutral-500">支持哔哩哔哩、YouTube、腾讯视频和爱奇艺</p>*/}
{/* </>*/}
{/* )}*/}
{/*</div>*/}
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,118 @@
import React, { useEffect, useRef, useState } from 'react'
import { Markmap } from 'markmap-view'
import { transformer } from '@/lib/markmap.ts'
import { Toolbar, ToolbarButton } from 'markmap-toolbar'
import 'markmap-toolbar/dist/style.css'
export interface MarkmapEditorProps {
/** 要渲染的 Markdown 文本 */
value: string
/** 内容变化时的回调 */
onChange: (value: string) => void
/** Toolbar 上要展示的 item id 列表,默认使用 Toolbar.defaultItems */
toolbarItems?: string[]
/** 自定义按钮列表,会依次注册 */
customButtons?: ToolbarButton[]
/** 容器 SVG 的高度,默认为 600px */
height?: string
}
export default function MarkmapEditor({
value,
onChange,
toolbarItems,
customButtons = [],
height = '600px',
}: MarkmapEditorProps) {
const svgRef = useRef<SVGSVGElement>(null)
const mmRef = useRef<Markmap>()
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()
}
}
// 初始化 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">
{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

@@ -1,3 +1,4 @@
/* NoteForm.tsx ---------------------------------------------------- */
import {
Form,
FormControl,
@@ -6,8 +7,26 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form.tsx'
import { useEffect } from 'react'
import { Input } from '@/components/ui/input.tsx'
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 { generateNote } from '@/services/note.ts'
import { uploadFile } from '@/services/upload.ts'
import { useTaskStore } from '@/store/taskStore'
import { useModelStore } from '@/store/modelStore'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip.tsx'
import { Checkbox } from '@/components/ui/checkbox.tsx'
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
import { Button } from '@/components/ui/button.tsx'
import {
Select,
SelectContent,
@@ -15,273 +34,290 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select.tsx'
import { Button } from '@/components/ui/button.tsx'
import { Checkbox } from '@/components/ui/checkbox.tsx'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Info, Clock, Loader2 } from 'lucide-react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip.tsx'
import { generateNote } from '@/services/note.ts'
import { useTaskStore } from '@/store/taskStore'
import NoteHistory from '@/pages/HomePage/components/NoteHistory.tsx'
import { useModelStore } from '@/store/modelStore'
import { Alert } from 'antd'
import { Input } from '@/components/ui/input.tsx'
import { Textarea } from '@/components/ui/textarea.tsx'
// ✅ 定义表单 schema
const formSchema = z.object({
video_url: z.string().url('请输入正确的视频链接'),
platform: z.string().nonempty('请选择平台'),
quality: z.enum(['fast', 'medium', 'slow'], {
required_error: '请选择音频质量',
}),
screenshot: z.boolean().optional(),
link: z.boolean().optional(),
model_name: z.string().nonempty('请选择模型'),
format: z.array(z.string()).default([]), // ✨ 确保默认是空数组
style: z.string().nonempty('请选择笔记生成风格'),
extras: z.string().optional(),
})
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().optional(),
platform: z.string().nonempty('请选择平台'),
quality: z.enum(['fast', 'medium', 'slow']),
screenshot: z.boolean().optional(),
link: z.boolean().optional(),
model_name: z.string().nonempty('请选择模型'),
format: z.array(z.string()).default([]),
style: z.string().nonempty('请选择笔记生成风格'),
extras: z.string().optional(),
video_understanding: z.boolean().optional(),
video_interval: z.coerce.number().min(1).max(30).default(4).optional(),
grid_size: z
.tuple([z.coerce.number().min(1).max(10), z.coerce.number().min(1).max(10)])
.default([3, 3])
.optional(),
})
.superRefine(({ video_url, platform }, ctx) => {
if (platform === 'local' && !video_url) {
ctx.addIssue({ code: 'custom', message: '本地视频路径不能为空', path: ['video_url'] })
} else if (!video_url) {
ctx.addIssue({ code: 'custom', message: '视频链接不能为空', path: ['video_url'] })
} else if (platform === 'local') {
// 对于本地文件只检查是否为空不验证URL格式
return
} 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>
const noteFormats = [
{
label: '目录',
value: 'toc',
},
{ label: '原片跳转', value: 'link' },
{ label: '原片截图', value: 'screenshot' },
{ label: 'AI总结', value: 'summary' },
]
const noteStyles = [
{
label: '精简',
value: 'minimal', // 简洁、快速呈现要点
},
{
label: '详细',
value: 'detailed', // 详细记录,包含时间戳、关键点
},
{
label: '教程',
value: 'tutorial', // 详细记录,包含时间戳、关键点
},
{
label: '学术',
value: 'academic', // 适合学术报告,正式且结构化
},
{
label: '小红书',
value: 'xiaohongshu', // 适合社交平台分享,亲切、口语化
},
{
label: '生活向',
value: 'life_journal', // 记录个人生活感悟,情感化表达
},
{
label: '任务导向',
value: 'task_oriented', // 强调任务、目标,适合工作和待办事项
},
{
label: '商业风格',
value: 'business', // 适合商业报告、会议纪要,正式且精准
},
{
label: '会议纪要',
value: 'meeting_minutes', // 适合商业报告、会议纪要,正式且精准
},
]
/* -------------------- 可复用子组件 -------------------- */
const SectionHeader = ({ title, tip }: { title: string; tip?: string }) => (
<div className="my-3 flex items-center justify-between">
<h2 className="block">{title}</h2>
{tip && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
</TooltipTrigger>
<TooltipContent className="text-xs">{tip}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)
const CheckboxGroup = ({
value = [],
onChange,
disabledMap,
}: {
value?: string[]
onChange: (v: string[]) => void
disabledMap: Record<string, boolean>
}) => (
<div className="flex flex-wrap space-x-1.5">
{noteFormats.map(({ label, value: v }) => (
<label key={v} className="flex items-center space-x-2">
<Checkbox
checked={value.includes(v)}
disabled={disabledMap[v]}
onCheckedChange={checked =>
onChange(checked ? [...value, v] : value.filter(x => x !== v))
}
/>
<span>{label}</span>
</label>
))}
</div>
)
/* -------------------- 主组件 -------------------- */
const NoteForm = () => {
useTaskStore(state => state.tasks)
const setCurrentTask = useTaskStore(state => state.setCurrentTask)
const currentTaskId = useTaskStore(state => state.currentTaskId)
const getCurrentTask = useTaskStore(state => state.getCurrentTask)
const loadEnabledModels = useModelStore(state => state.loadEnabledModels)
const modelList = useModelStore(state => state.modelList)
const showFeatureHint = useModelStore(state => state.showFeatureHint)
const setShowFeatureHint = useModelStore(state => state.setShowFeatureHint)
const navigate = useNavigate();
const [isUploading, setIsUploading] = useState(false)
const [uploadSuccess, setUploadSuccess] = useState(false)
/* ---- 全局状态 ---- */
const { addPendingTask, currentTaskId, setCurrentTask, getCurrentTask, retryTask } =
useTaskStore()
const { loadEnabledModels, modelList, showFeatureHint, setShowFeatureHint } = useModelStore()
/* ---- 表单 ---- */
const form = useForm<NoteFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
video_url: '',
platform: 'bilibili',
quality: 'medium', // 默认中等质量
screenshot: false,
model_name: modelList[0]?.model_name || '', // 确保有值
format: [], // 初始化为空数组
style: 'minimal', // 默认选择精简风格
extras: '', // 初始化为空字符串
quality: 'medium',
model_name: modelList[0]?.model_name || '',
style: 'minimal',
video_interval: 4,
grid_size: [3, 3],
format: [],
},
})
const currentTask = getCurrentTask()
const onClose = () => {
setShowFeatureHint(false)
}
const isGenerating = () => {
console.log('🚀 isGenerating', getCurrentTask()?.status)
return getCurrentTask()?.status === 'PENDING'
}
/* ---- 派生状态(只 watch 一次,提高性能) ---- */
const platform = useWatch({ control: form.control, name: 'platform' }) as string
const videoUnderstandingEnabled = useWatch({ control: form.control, name: 'video_understanding' })
const editing = currentTask && currentTask.id
const onSubmit = async (data: NoteFormValues) => {
console.log('🎯 提交内容:', data)
const payload = {
video_url: data.video_url,
platform: data.platform,
quality: data.quality,
model_name: data.model_name,
provider_id: modelList.find(model => model.model_name === data.model_name).provider_id,
format: data.format,
style: data.style,
extras: data.extras,
}
const res = await generateNote(payload)
const taskId = res.data.task_id
useTaskStore.getState().addPendingTask(taskId, data.platform, payload)
}
const goModelAdd = () => {
navigate("/settings/model");
};
/* ---- 副作用 ---- */
useEffect(() => {
loadEnabledModels()
return
}, [])
useEffect(() => {
if (!currentTask) return
const { formData } = currentTask
console.log('currentTask.formData.platform:', formData.platform)
form.reset({
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 ?? 4,
grid_size: formData.grid_size ?? [3, 3],
format: formData.format ?? [],
})
}, [
// 当下面任意一个变了,就重新 reset
currentTaskId,
// modelList 用来兜底 model_name
modelList.length,
// 还要加上 formData 的各字段,或者直接 currentTask
currentTask?.formData,
])
/* ---- 帮助函数 ---- */
const isGenerating = () => !['SUCCESS', 'FAILED', undefined].includes(getCurrentTask()?.status)
const generating = isGenerating()
const handleFileUpload = async (file: File, cb: (url: string) => void) => {
const formData = new FormData()
formData.append('file', file)
setIsUploading(true)
setUploadSuccess(false)
try {
const data = await uploadFile(formData)
cb(data.url)
setUploadSuccess(true)
} catch (err) {
console.error('上传失败:', err)
// 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,
task_id: currentTaskId || '',
}
if (currentTaskId) {
retryTask(currentTaskId, payload)
return
}
// 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 到一个新页面
setCurrentTask(null)
}
const FormButton = () => {
const label = generating ? '正在生成…' : editing ? '重新生成' : '生成笔记'
return (
<div className="flex gap-2">
<Button
type="submit"
className={!editing ? 'w-full' : 'w-2/3' + ' bg-primary'}
disabled={generating}
>
{generating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{label}
</Button>
{editing && (
<Button type="button" variant="outline" className="w-1/3" onClick={handleCreateNew}>
<Plus className="mr-2 h-4 w-4" />
</Button>
)}
</div>
)
}
/* -------------------- 渲染 -------------------- */
return (
<div className="flex h-full flex-col">
<div className="h-full w-full">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-2">
<div className="my-3 flex items-center justify-between">
<h2 className="block"></h2>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">YouTube等平台</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<form onSubmit={form.handleSubmit(onSubmit, onInvalid)} className="space-y-4">
{/* 顶部按钮 */}
<FormButton></FormButton>
<div className="flex gap-2">
{/* 平台选择 */}
<FormField
control={form.control}
name="platform"
render={({ field }) => (
<FormItem>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-32">
<SelectValue placeholder="选择平台" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bilibili"></SelectItem>
<SelectItem value="youtube">Youtube</SelectItem>
{/*<SelectItem value="local">本地视频</SelectItem>*/}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* 视频链接 & 平台 */}
<SectionHeader title="视频链接" tip="支持 B 站、YouTube 等平台" />
<div className="flex gap-2">
{/* 平台选择 */}
{/* 视频地址 */}
<FormField
control={form.control}
name="video_url"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="视频链接" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/*<p className="text-xs text-neutral-500">*/}
{/* 支持哔哩哔哩视频链接,例如:*/}
{/* https://www.bilibili.com/video/BV1vc25YQE9X/*/}
{/*</p>*/}
<FormField
control={form.control}
name="quality"
name="platform"
render={({ field }) => (
<FormItem>
<div className="my-3 flex items-center justify-between">
<h2 className="block"></h2>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
</TooltipTrigger>
<TooltipContent>
<p className="max-w-[200px] text-xs"></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select
disabled={!!editing}
value={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择质量" />
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fast"></SelectItem>
<SelectItem value="medium"></SelectItem>
<SelectItem value="slow"></SelectItem>
{videoPlatforms?.map(p => (
<SelectItem key={p.value} value={p.value}>
<div className="flex items-center justify-center gap-2">
<div className="h-4 w-4">{p.logo()}</div>
<span>{p.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/*<FormDescription className="text-xs text-neutral-500">*/}
{/* 质量越高,下载体积越大,速度越慢*/}
{/*</FormDescription>*/}
<FormMessage />
<FormMessage style={{ display: 'none' }} />
</FormItem>
)}
/>
{/* 链接输入 / 上传框 */}
<FormField
control={form.control}
name="model_name"
name="video_url"
render={({ field }) => (
<FormItem>
<div className="my-3 flex items-center justify-between">
<h2 className="block"></h2>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
</TooltipTrigger>
<TooltipContent>
<p className="max-w-[200px] text-xs"></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择配置好的模型" />
</SelectTrigger>
</FormControl>
<SelectContent>
{modelList.map(item => {
return <SelectItem value={item.model_name}>{item.model_name}</SelectItem>
})}
</SelectContent>
</Select>
{/*<FormDescription className="text-xs text-neutral-500">*/}
{/* 质量越高,下载体积越大,速度越慢*/}
{/*</FormDescription>*/}
<FormMessage />
<FormItem className="flex-1">
{platform === 'local' ? (
<>
<Input disabled={!!editing} placeholder="请输入本地视频路径" {...field} />
</>
) : (
<Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} />
)}
<FormMessage style={{ display: 'none' }} />
</FormItem>
)}
/>
@@ -289,128 +325,236 @@ const NoteForm = () => {
<FormField
control={form.control}
name="style"
name="video_url"
render={({ field }) => (
<FormItem>
<div className="my-3 flex items-center justify-between">
<h2 className="block"></h2>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
</TooltipTrigger>
<TooltipContent>
<p className="max-w-[200px] text-xs"></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择笔记风格" />
</SelectTrigger>
</FormControl>
<SelectContent>
{noteStyles.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormItem className="flex-1">
{platform === 'local' && (
<>
<div
className="hover:border-primary mt-2 flex h-40 cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-gray-300 transition-colors"
onDragOver={e => {
e.preventDefault()
e.stopPropagation()
}}
onDrop={e => {
e.preventDefault()
const file = e.dataTransfer.files?.[0]
if (file) handleFileUpload(file, field.onChange)
}}
onClick={() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'video/*'
input.onchange = e => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) handleFileUpload(file, field.onChange)
}
input.click()
}}
>
{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>
</>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-2">
{/* 模型选择 */}
{
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"
control={form.control}
name="style"
render={({ field }) => (
<FormItem>
<SectionHeader title="笔记风格" tip="选择生成笔记的呈现风格" />
<Select
value={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="w-full min-w-0 truncate">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{noteStyles.map(({ label, value }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* 视频理解 */}
<SectionHeader title="视频理解" tip="将视频截图发给多模态模型辅助分析" />
<div className="flex flex-col gap-2">
<FormField
control={form.control}
name="video_understanding"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel></FormLabel>
<Checkbox
checked={videoUnderstandingEnabled}
onCheckedChange={v => form.setValue('video_understanding', v)}
/>
</div>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
{/* 采样间隔 */}
<FormField
control={form.control}
name="video_interval"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Input disabled={!videoUnderstandingEnabled} type="number" {...field} />
<FormMessage />
</FormItem>
)}
/>
{/* 拼图大小 */}
<FormField
control={form.control}
name="grid_size"
render={({ field }) => (
<FormItem>
<FormLabel> × </FormLabel>
<div className="flex items-center space-x-2">
<Input
disabled={!videoUnderstandingEnabled}
type="number"
value={field.value?.[0] || 3}
onChange={e => field.onChange([+e.target.value, field.value?.[1] || 3])}
className="w-16"
/>
<span>x</span>
<Input
disabled={!videoUnderstandingEnabled}
type="number"
value={field.value?.[1] || 3}
onChange={e => field.onChange([field.value?.[0] || 3, +e.target.value])}
className="w-16"
/>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<Alert
closable
type="error"
message={
<div>
<strong></strong>
<p>使</p>
</div>
}
className="text-sm"
/>
</div>
{/* 笔记格式 */}
<FormField
control={form.control}
name="format"
render={({ field }) => (
<FormItem>
<div className="my-3 flex items-center justify-between">
<h2 className="block"></h2>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
</TooltipTrigger>
<TooltipContent>
<p className="text-xs"></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<div className="flex flex-wrap space-x-1.5">
{noteFormats.map(item => (
<label key={item.value} className="flex items-center space-x-2">
<Checkbox
checked={field.value?.includes(item.value)}
onCheckedChange={checked => {
const currentValue = field.value ?? [] // ✨ 保底是数组
if (checked) {
field.onChange([...currentValue, item.value])
} else {
field.onChange(currentValue.filter(v => v !== item.value))
}
}}
/>
<span>{item.label}</span>
</label>
))}
</div>
</FormControl>
<FormField
control={form.control}
name="extras"
render={({ field }) => (
<FormItem>
<div className="my-3 flex items-center justify-between">
<h2 className="block"></h2>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Prompt最后 </p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Textarea placeholder={'笔记需要罗列出 xxx 关键点'} {...field} />
{/*<FormDescription className="text-xs text-neutral-500">*/}
{/* 质量越高,下载体积越大,速度越慢*/}
{/*</FormDescription>*/}
<FormMessage />
</FormItem>
)}
<SectionHeader title="笔记格式" tip="选择要包含的笔记元素" />
<CheckboxGroup
value={field.value}
onChange={field.onChange}
disabledMap={{
link: platform === 'local',
screenshot: !videoUnderstandingEnabled,
}}
/>
<FormMessage />
</FormItem>
)}
/>
<div className={'flex w-full items-center gap-2 py-1.5'}>
{/* 提交按钮 */}
<Button type="submit" className="bg-primary w-full" disabled={isGenerating()}>
{isGenerating() && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isGenerating() ? '正在生成…' : '生成笔记'}
</Button>
</div>
{/* 备注 */}
<FormField
control={form.control}
name="extras"
render={({ field }) => (
<FormItem>
<SectionHeader title="备注" tip="可在 Prompt 结尾附加自定义说明" />
<Textarea placeholder="笔记需要罗列出 xxx 关键点…" {...field} />
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
{/* 添加一些额外的说明或功能介绍 */}
{/*<div className="bg-primary-light mt-6 rounded-lg p-4"></div>*/}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import request from '@/utils/request'
export const systemCheck=async()=>{
return await request.get('/sys_health')
}

View File

@@ -0,0 +1,9 @@
import request from '@/utils/request' // 你项目里封装好的axios或者fetch
export const uploadFile = (formData: FormData) => {
return request.post('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
}

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,67 +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 {
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,
{
@@ -85,17 +117,30 @@ export const useModelStore = create<ModelStore>()(
],
}))
} else {
console.error('新增模型失败')
console.error('新增模型失败', res.msg)
}
} catch (error) {
console.error('添加模型出错', error)
}
},
// 设置选中的模型
setSelectedModel: modelId => set({ selectedModel: modelId }),
// 删除模型
deleteModel: async (modelId: number) => {
try {
await deleteModelById(modelId)
// 删除后更新本地状态(可选)
set((state) => ({
models: state.models.filter((model) => model.id !== modelId.toString())
}))
} catch (error) {
console.error('删除模型失败', error)
}
},
// 清空
clearModels: () => set({ models: [], selectedModel: '' }),
// 切换选中模型
setSelectedModel: (modelId: string) => set({ selectedModel: modelId }),
// 清空
clearModels: () => set({ models: [], selectedModel: '', modelList: [] }),
}))
)
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,9 +7,11 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd() + '/../')
const apiBaseUrl = env.VITE_API_BASE_URL
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://localhost:8000'
const port = env.VITE_FRONTEND_PORT || 3015
return {
base: './',
plugins: [react(), tailwindcss()],
resolve: {
alias: {
@@ -18,13 +20,18 @@ export default defineConfig(({ mode }) => {
},
server: {
host: '0.0.0.0',
port: 5173,
port: port,
proxy: {
'/api': {
target: apiBaseUrl,
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, '/api'),
},
'/static': {
target: apiBaseUrl,
changeOrigin: true,
rewrite: path => path.replace(/^\/static/, '/static'),
},
},
},
}

View File

@@ -3,7 +3,7 @@
<p align="center">
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
</p>
<h1 align="center" > BiliNote v1.1.1</h1>
<h1 align="center" > BiliNote v1.8.1</h1>
</div>
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
@@ -22,21 +22,26 @@
## ✨ 项目简介
BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、YouTube 等视频链接,自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转等功能。
## 🚀 体验地址
[https://www.bilinote.app](https://www.bilinote.app) 维护中 暂不用
注意:由于 项目部署在 Cloudflare Pages访问速度可能存在一些问题请耐心等待。
BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、YouTube、抖音等视频链接,自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转等功能。
## 📝 使用文档
详细文档可以查看[这里](https://docs.bilinote.app/)
## 体验地址
可以通过访问 [这里](https://www.bilinote.app/) 进行体验,速度略慢,不支持长视频。
## 📦 Windows 打包版
本项目提供了 Windows 系统的 exe 文件,可在[release](https://github.com/JefferyHcool/BiliNote/releases/tag/v1.0.1) 进行下载。**注意一定要在没有中文路径的环境下运行。**
本项目提供了 Windows 系统的 exe 文件,可在[release](https://github.com/JefferyHcool/BiliNote/releases/tag/v1.1.1)进行下载。**注意一定要在没有中文路径的环境下运行。**
## 🔧 功能特性
- 支持多平台Bilibili、YouTube后续会加入更多平台
- 支持多平台Bilibili、YouTube、本地视频、抖音(后续会加入更多平台)
- 支持返回笔记格式选择
- 支持笔记风格选择
- 支持多模态视频理解
- 支持多版本记录保留
- 支持自行配置 GPT 大模型
- 本地模型音频转写(支持 Fast-Whisper
- GPT 大模型总结视频内容(支持 OpenAI、DeepSeek、Qwen
- GPT 大模型总结视频内容
- 自动生成结构化 Markdown 笔记
- 可选插入截图(自动截取)
- 可选内容跳转链接(关联原视频)
@@ -44,10 +49,10 @@ BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、Y
## 📸 截图预览
![screenshot](./doc/image1.png)
![screenshot](./doc/image2.png)
![screenshot](./doc/image3.png)
![screenshot](./doc/image.png)
![screenshot](./doc/image4.png)
![screenshot](./doc/image5.png)
## 🚀 快速开始
@@ -70,7 +75,7 @@ python main.py
### 3. 启动前端Vite + React
```bash
cd BiliNote_frontend
cd BillNote_frontend
pnpm install
pnpm dev
```
@@ -101,57 +106,11 @@ sudo apt install ffmpeg
确保你已安装 Docker 和 Docker Compose
#### 1. 克隆本项目
```bash
git clone https://github.com/JefferyHcool/BiliNote.git
cd BiliNote
mv .env.example .env
```
#### 2. 启动 Docker Compose
``` bash
docker compose up --build
```
默认端口:
前端http://localhost:${FRONTEND_PORT}
后端http://localhost:${BACKEND_PORT}
.env 文件中可自定义端口与环境配置。
## ⚙️ 环境变量配置
> ⚠️ v.1.1.0 以后无需通过环境变量配置 AI
后端 `.env` 示例:
```ini
API_BASE_URL=http://localhost:8000
OUT_DIR=note_results
IMAGE_BASE_URL=/static/screenshots
MODEl_PROVIDER=openai
OPENAI_API_KEY=sk-xxxxxx
DEEP_SEEK_API_KEY=xxx
QWEN_API_KEY=xxx
```
## Changelog
### v1.1.0
- #### Added
- 新增 AI 笔记风格选择
- 新增 AI 笔记返回格式选择
- 添加 AI 自定义笔记备注 Prompt
- 添加任务失败重试
- 添加全局设置页,可在设置页进行模型设置
- #### Optimize
- 优化前端样式,优化用户体验
- 增加生成中间产物,可用于失败后加快生成速度
- #### Fix
- 修复视频截图视频过早删除错误
[docker 部署](https://github.com/JefferyHcool/bilinote-deploy/blob/master/README.md)
## 🧠 TODO
- [ ] 支持抖音及快手等视频平台
- [x] 支持抖音及快手等视频平台
- [x] 支持前端设置切换 AI 模型切换、语音转文字模型
- [x] AI 摘要风格自定义(学术风、口语风、重点提取等)
- [ ] 笔记导出为 PDF / Word / Notion
@@ -162,7 +121,12 @@ QWEN_API_KEY=xxx
- BiliNote 交流QQ群785367111
- BiliNote 交流微信群:
<img src="https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250424091751.png" alt="wechat" style="zoom:33%;" />
<img src="doc/wechat.png" alt="wechat" style="zoom:33%;" />
## 🔎代码参考
- 本项目中的 `抖音下载功能` 部分代码参考引用自:[Evil0ctal/Douyin_TikTok_Download_API](https://github.com/Evil0ctal/Douyin_TikTok_Download_API)
## 📜 License
@@ -171,4 +135,13 @@ MIT License
---
💬 你的支持与反馈是我持续优化的动力!欢迎 PR、提 issue、Star ⭐️
## Buy Me a Coffee / 捐赠
如果你觉得项目对你有帮助,考虑支持我一下吧
<div style='display:inline;'>
<img width='30%' src='https://common-1304618721.cos.ap-chengdu.myqcloud.com/8986c9eb29c356a0cfa3d470c23d3b6.jpg'/>
<img width='30%' src='https://common-1304618721.cos.ap-chengdu.myqcloud.com/2a049ea298b206bcd0d8b8da3219d6b.jpg'/>
</div>
## ⭐ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=JefferyHcool/BiliNote&type=Date)](https://www.star-history.com/#JefferyHcool/BiliNote&Date)

View File

@@ -13,6 +13,9 @@ RUN rm -f /etc/apt/sources.list && \
# 确保 PATH 中包含 ffmpeg 路径(可选)
ENV PATH="/usr/bin:${PATH}"
# 设置 Hugging Face 镜像源环境变量
ENV HF_ENDPOINT=https://hf-mirror.com
WORKDIR /app
COPY ./backend /app
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt

15
backend/Dockerfile.gpu Normal file
View File

@@ -0,0 +1,15 @@
FROM nvidia/cuda:12.6.0-cudnn-runtime-ubuntu24.04
RUN apt update && \
apt install -y ffmpeg python3-pip && \
apt clean all
# 设置 Hugging Face 镜像源环境变量
ENV HF_ENDPOINT=https://hf-mirror.com
WORKDIR /app
COPY ./backend /app
RUN pip install --no-cache-dir -i https://pypi.mirrors.ustc.edu.cn/simple -r requirements.txt --break-system-packages
RUN pip install --no-cache-dir -i https://pypi.mirrors.ustc.edu.cn/simple transformers[torch]>=4.23 --break-system-packages
CMD ["python3", "main.py"]

View File

@@ -1,10 +1,14 @@
from fastapi import FastAPI
from .routers import note, provider,model
from .routers import note, provider, model, config
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")
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"
}
]

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

@@ -0,0 +1,36 @@
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}
engine = create_engine(
DATABASE_URL,
echo=os.getenv("SQLALCHEMY_ECHO", "false").lower() == "true",
**engine_args
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_engine():
return engine
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,18 @@
import os
from abc import ABC
from typing import Union, Optional
import random
import time
import logging
import yt_dlp
# 导入youtube-dl作为备选
import youtube_dl
from app.downloaders.base import Downloader, DownloadQuality, QUALITY_MAP
from app.models.notes_model import AudioDownloadResult
from app.utils.path_helper import get_data_dir
from app.utils.url_parser import extract_video_id
class BilibiliDownloader(Downloader, ABC):
@@ -27,7 +33,19 @@ class BilibiliDownloader(Downloader, ABC):
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
# 常见浏览器 User-Agent 列表
user_agents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.42',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/113.0'
]
# 随机选择一个User-Agent
user_agent = random.choice(user_agents)
# 尝试使用yt-dlp
ydl_opts = {
'format': 'bestaudio[ext=m4a]/bestaudio/best',
'outtmpl': output_path,
@@ -40,21 +58,75 @@ class BilibiliDownloader(Downloader, ABC):
],
'noplaylist': True,
'quiet': False,
# 添加重试和连接设置
'retries': 10, # 重试10次
'fragment_retries': 10, # 片段下载重试10次
'socket_timeout': 30, # 套接字超时时间30秒
'extractor_retries': 5, # 提取器重试5次
'nocheckcertificate': True, # 不检查SSL证书
'http_headers': {
'User-Agent': user_agent,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://www.bilibili.com/',
'Origin': 'https://www.bilibili.com'
}
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")
title = info.get("title")
duration = info.get("duration", 0)
cover_url = info.get("thumbnail")
audio_path = os.path.join(output_dir, f"{video_id}.mp3")
info = None
try:
print("尝试使用 yt-dlp 下载...")
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
print(f"正在使用User-Agent: {user_agent}")
print(f"尝试下载视频: {video_url}")
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")
title = info.get("title")
duration = info.get("duration", 0)
cover_url = info.get("thumbnail")
audio_path = os.path.join(output_dir, f"{video_id}.mp3")
print(f"下载成功: {title}")
except Exception as e:
print(f"yt-dlp 下载失败,错误信息: {str(e)}")
print("尝试使用备选下载器 youtube-dl...")
# 失败后尝试使用 youtube_dl
try:
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
print(f"正在使用 youtube-dl 下载: {video_url}")
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")
title = info.get("title")
duration = info.get("duration", 0)
cover_url = info.get("thumbnail")
audio_path = os.path.join(output_dir, f"{video_id}.mp3")
print(f"youtube-dl 下载成功: {title}")
except Exception as e2:
print(f"youtube-dl 也下载失败,错误信息: {str(e2)}")
raise Exception(f"所有下载方法都失败: yt-dlp错误: {str(e)}, youtube-dl错误: {str(e2)}")
if not info:
raise Exception("无法获取视频信息")
# 检查下载是否成功
video_id = info.get("id")
audio_path = os.path.join(output_dir, f"{video_id}.mp3")
# 等待5秒确保文件写入完成
for _ in range(5):
if os.path.exists(audio_path):
break
print(f"等待文件写入: {audio_path}")
time.sleep(1)
if not os.path.exists(audio_path):
print(f"警告:下载可能成功但找不到文件: {audio_path}")
return AudioDownloadResult(
file_path=audio_path,
title=title,
duration=duration,
cover_url=cover_url,
title=info.get("title"),
duration=info.get("duration", 0),
cover_url=info.get("thumbnail"),
platform="bilibili",
video_id=video_id,
raw_info=info,
@@ -69,10 +141,27 @@ class BilibiliDownloader(Downloader, ABC):
"""
下载视频,返回视频文件路径
"""
if output_dir is None:
output_dir = get_data_dir()
os.makedirs(output_dir, exist_ok=True)
print("video_url",video_url)
video_id=extract_video_id(video_url, "bilibili")
video_path = os.path.join(output_dir, f"{video_id}.mp4")
if os.path.exists(video_path):
return video_path
# 常见浏览器 User-Agent 列表
user_agents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.42',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/113.0'
]
# 随机选择一个User-Agent
user_agent = random.choice(user_agents)
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
ydl_opts = {
@@ -81,12 +170,46 @@ class BilibiliDownloader(Downloader, ABC):
'noplaylist': True,
'quiet': False,
'merge_output_format': 'mp4', # 确保合并成 mp4
# 添加重试和连接设置
'retries': 10, # 重试10次
'fragment_retries': 10, # 片段下载重试10次
'socket_timeout': 30, # 套接字超时时间30秒
'extractor_retries': 5, # 提取器重试5次
'nocheckcertificate': True, # 不检查SSL证书
'http_headers': {
'User-Agent': user_agent,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://www.bilibili.com/',
'Origin': 'https://www.bilibili.com'
}
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")
video_path = os.path.join(output_dir, f"{video_id}.mp4")
info = None
try:
print("尝试使用 yt-dlp 下载视频...")
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
print(f"正在使用User-Agent: {user_agent}")
print(f"尝试下载视频: {video_url}")
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")
video_path = os.path.join(output_dir, f"{video_id}.mp4")
print(f"下载成功: {video_path}")
except Exception as e:
print(f"yt-dlp 下载视频失败,错误信息: {str(e)}")
print("尝试使用备选下载器 youtube-dl...")
# 失败后尝试使用 youtube_dl
try:
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
print(f"正在使用 youtube-dl 下载视频: {video_url}")
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")
video_path = os.path.join(output_dir, f"{video_id}.mp4")
print(f"youtube-dl 下载视频成功: {video_path}")
except Exception as e2:
print(f"youtube-dl 也下载视频失败,错误信息: {str(e2)}")
raise Exception(f"所有下载视频方法都失败: yt-dlp错误: {str(e)}, youtube-dl错误: {str(e2)}")
if not os.path.exists(video_path):
raise FileNotFoundError(f"视频文件未找到: {video_path}")

View File

@@ -1,90 +1,304 @@
import datetime
import json
import os
from abc import ABC
import re
from typing import Union, Optional
from urllib.parse import quote, urlencode
import yt_dlp
import httpx
import requests
from pydantic import BaseModel
from app.downloaders.base import Downloader, DownloadQuality
from app.models.notes_model import AudioDownloadResult
from app.downloaders.base import Downloader
from app.downloaders.douyin_helper.abogus import ABogus
from app.enmus.note_enums import DownloadQuality
from app.models.audio_model import AudioDownloadResult
from app.services.cookie_manager import CookieConfigManager
from app.utils.path_helper import get_data_dir
from dotenv import load_dotenv
load_dotenv()
DOUYIN_DOMAIN = "https://www.douyin.com"
cfm=CookieConfigManager()
def get_timestamp(unit: str = "milli"):
"""
根据给定的单位获取当前时间 (Get the current time based on the given unit)
Args:
unit (str): 时间单位,可以是 "milli""sec""min"
(The time unit, which can be "milli", "sec", "min", etc.)
Returns:
int: 根据给定单位的当前时间 (The current time based on the given unit)
"""
now = datetime.datetime.utcnow() - datetime.datetime(1970, 1, 1)
if unit == "milli":
return int(now.total_seconds() * 1000)
elif unit == "sec":
return int(now.total_seconds())
elif unit == "min":
return int(now.total_seconds() / 60)
else:
raise ValueError("Unsupported time unit")
class DouyinDownloader(Downloader, ABC):
def download(
self,
video_url: str,
output_dir: Union[str, None] = None,
quality: DownloadQuality = "fast",
need_video:Optional[bool]=False
) -> AudioDownloadResult:
if output_dir is None:
output_dir = get_data_dir()
class DouyinConfig:
HEADERS = {
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36",
"Referer": "https://www.douyin.com/",
"Cookie": None
}
os.makedirs(output_dir, exist_ok=True)
PROXIES = {
"http": None,
"https": None,
}
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
MS_TOKEN = {
"url": "https://mssdk.bytedance.com/web/report",
"magic": 538969122,
"version": 1,
"dataType": 8,
"strData": "fWOdJTQR3/jwmZqBBsPO6tdNEc1jX7YTwPg0Z8CT+j3HScLFbj2Zm1XQ7/lqgSutntVKLJWaY3Hc/+vc0h+So9N1t6EqiImu5jKyUa+S4NPy6cNP0x9CUQQgb4+RRihCgsn4QyV8jivEFOsj3N5zFQbzXRyOV+9aG5B5EAnwpn8C70llsWq0zJz1VjN6y2KZiBZRyonAHE8feSGpwMDeUTllvq6BG3AQZz7RrORLWNCLEoGzM6bMovYVPRAJipuUML4Hq/568bNb5vqAo0eOFpvTZjQFgbB7f/CtAYYmnOYlvfrHKBKvb0TX6AjYrw2qmNNEer2ADJosmT5kZeBsogDui8rNiI/OOdX9PVotmcSmHOLRfw1cYXTgwHXr6cJeJveuipgwtUj2FNT4YCdZfUGGyRDz5bR5bdBuYiSRteSX12EktobsKPksdhUPGGv99SI1QRVmR0ETdWqnKWOj/7ujFZsNnfCLxNfqxQYEZEp9/U01CHhWLVrdzlrJ1v+KJH9EA4P1Wo5/2fuBFVdIz2upFqEQ11DJu8LSyD43qpTok+hFG3Moqrr81uPYiyPHnUvTFgwA/TIE11mTc/pNvYIb8IdbE4UAlsR90eYvPkI+rK9KpYN/l0s9ti9sqTth12VAw8tzCQvhKtxevJRQntU3STeZ3coz9Dg8qkvaSNFWuBDuyefZBGVSgILFdMy33//l/eTXhQpFrVc9OyxDNsG6cvdFwu7trkAENHU5eQEWkFSXBx9Ml54+fa3LvJBoacfPViyvzkJworlHcYYTG392L4q6wuMSSpYUconb+0c5mwqnnLP6MvRdm/bBTaY2Q6RfJcCxyLW0xsJMO6fgLUEjAg/dcqGxl6gDjUVRWbCcG1NAwPCfmYARTuXQYbFc8LO+r6WQTWikO9Q7Cgda78pwH07F8bgJ8zFBbWmyrghilNXENNQkyIzBqOQ1V3w0WXF9+Z3vG3aBKCjIENqAQM9qnC14WMrQkfCHosGbQyEH0n/5R2AaVTE/ye2oPQBWG1m0Gfcgs/96f6yYrsxbDcSnMvsA+okyd6GfWsdZYTIK1E97PYHlncFeOjxySjPpfy6wJc4UlArJEBZYmgveo1SZAhmXl3pJY3yJa9CmYImWkhbpwsVkSmG3g11JitJXTGLIfqKXSAhh+7jg4HTKe+5KNir8xmbBI/DF8O/+diFAlD+BQd3cV0G4mEtCiPEhOvVLKV1pE+fv7nKJh0t38wNVdbs3qHtiQNN7JhY4uWZAosMuBXSjpEtoNUndI+o0cjR8XJ8tSFnrAY8XihiRzLMfeisiZxWCvVwIP3kum9MSHXma75cdCQGFBfFRj0jPn1JildrTh2vRgwG+KeDZ33BJ2VGw9PgRkztZ2l/W5d32jc7H91FftFFhwXil6sA23mr6nNp6CcrO7rOblcm5SzXJ5MA601+WVicC/g3p6A0lAnhjsm37qP+xGT+cbCFOfjexDYEhnqz0QZm94CCSnilQ9B/HBLhWOddp9GK0SABIk5i3xAH701Xb4HCcgAulvfO5EK0RL2eN4fb+CccgZQeO1Zzo4qsMHc13UG0saMgBEH8SqYlHz2S0CVHuDY5j1MSV0nsShjM01vIynw6K0T8kmEyNjt1eRGlleJ5lvE8vonJv7rAeaVRZ06rlYaxrMT6cK3RSHd2liE50Z3ik3xezwWoaY6zBXvCzljyEmqjNFgAPU3gI+N1vi0MsFmwAwFzYqqWdk3jwRoWLp//FnawQX0g5T64CnfAe/o2e/8o5/bvz83OsAAwZoR48GZzPu7KCIN9q4GBjyrePNx5Csq2srblifmzSKwF5MP/RLYsk6mEE15jpCMKOVlHcu0zhJybNP3AKMVllF6pvn+HWvUnLXNkt0A6zsfvjAva/tbLQiiiYi6vtheasIyDz3HpODlI+BCkV6V8lkTt7m8QJ1IcgTfqjQBummyjYTSwsQji3DdNCnlKYd13ZQa545utqu837FFAzOZQhbnC3bKqeJqO2sE3m7WBUMbRWLflPRqp/PsklN+9jBPADKxKPl8g6/NZVq8fB1w68D5EJlGExdDhglo4B0aihHhb1u3+zJ2DqkxkPCGBAZ2AcuFIDzD53yS4NssoWb4HJ7YyzPaJro+tgG9TshWRBtUw8Or3m0OtQtX+rboYn3+GxvD1O8vWInrg5qxnepelRcQzmnor4rHF6ZNhAJZAf18Rjncra00HPJBugY5rD+EwnN9+mGQo43b01qBBRYEnxy9JJYuvXxNXxe47/MEPOw6qsxN+dmyIWZSuzkw8K+iBM/anE11yfU4qTFt0veCaVprK6tXaFK0ZhGXDOYJd70sjIP4UrPhatp8hqIXSJ2cwi70B+TvlDk/o19CA3bH6YxrAAVeag1P9hmNlfJ7NxK3Jp7+Ny1Vd7JHWVF+R6rSJiXXPfsXi3ZEy0klJAjI51NrDAnzNtgIQf0V8OWeEVv7F8Rsm3/GKnjdNOcDKymi9agZUgtctENWbCXGFnI40NHuVHtBRZeYAYtwfV7v6U0bP9s7uZGpkp+OETHMv3AyV0MVbZwQvarnjmct4Z3Vma+DvT+Z4VlMVnkC2x2FLt26K3SIMz+KV2XLv5ocEdPFSn1vMR7zruCWC8XqAG288biHo/soldmb/nlw8o8qlfZj4h296K3hfdFubGIUtqgsrZCrLCkkRC08Cv1ozEX/y6t2YrQepwiNmwDVk5IufStVvJMj+y2r9TcYLv7UKWXx3P6aySvM2ZHPaZhv+6Z/A/jIMBSvOizn4qG11iK7Oo6JYhxCSMJZsetjsnL4ecSIAufEmoFlAScWBh6nFArRpVLvkAZ3tej7H2lWFRXIU7x7mdBfGqU82PpM6znKMMZCpEsvHqpkSPSL+Kwz2z1f5wW7BKcKK4kNZ8iveg9VzY1NNjs91qU8DJpUnGyM04C7KNMpeilEmoOxvyelMQdi85ndOVmigVKmy5JYlODNX744sHpeqmMEK/ux3xY5O406lm7dZlyGPSMrFWbm4rzqvSEIskP43+9xVP8L84GeHE4RpOHg3qh/shx+/WnT1UhKuKpByHCpLoEo144udpzZswCYSMp58uPrlwdVF31//AacTRk8dUP3tBlnSQPa1eTpXWFCn7vIiqOTXaRL//YQK+e7ssrgSUnwhuGKJ8aqNDgdsL+haVZnV9g5Qrju643adyNixvYFEp0uxzOzVkekOMh2FYnFVIL2mJYGpZEXlAIC0zQbb54rSP89j0G7soJ2HcOkD0NmMEWj/7hUdTuMin1lRNde/qmHjwhbhqL8Z9MEO/YG3iLMgFTgSNQQhyE8AZAAKnehmzjORJfbK+qxyiJ07J843EDduzOoYt9p/YLqyTFmAgpdfK0uYrtAJ47cbl5WWhVXp5/XUxwWdL7TvQB0Xh6ir1/XBRcsVSDrR7cPE221ThmW1EPzD+SPf2L2gS0WromZqj1PhLgk92YnnR9s7/nLBXZHPKy+fDbJT16QqabFKqAl9G0blyf+R5UGX2kN+iQp4VGXEoH5lXxNNTlgRskzrW7KliQXcac20oimAHUE8Phf+rXXglpmSv4XN3eiwfXwvOaAMVjMRmRxsKitl5iZnwpcdbsC4jt16g2r/ihlKzLIYju+XZej4dNMlkftEidyNg24IVimJthXY1H15RZ8Hm7mAM/JZrsxiAVI0A49pWEiUk3cyZcBzq/vVEjHUy4r6IZnKkRvLjqsvqWE95nAGMor+F0GLHWfBCVkuI51EIOknwSB1eTvLgwgRepV4pdy9cdp6iR8TZndPVCikflXYVMlMEJ2bJ2c0Swiq57ORJW6vQwnkxtPudpFRc7tNNDzz4LKEznJxAwGi6pBR7/co2IUgRw1ijLFTHWHQJOjgc7KaduHI0C6a+BJb4Y8IWuIk2u2qCMF1HNKFAUn/J1gTcqtIJcvK5uykpfJFCYc899TmUc8LMKI9nu57m0S44Y2hPPYeW4XSakScsg8bJHMkcXk3Tbs9b4eqiD+kHUhTS2BGfsHadR3d5j8lNhBPzA5e+mE==",
"User-Agent": "5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36 Edg/117.0.2045.47"
}
ydl_opts = {
'format': 'bestaudio[ext=m4a]/bestaudio/best',
'outtmpl': output_path,
'postprocessors': [
TTWID = {
"url": "https://ttwid.bytedance.com/ttwid/union/register/",
"data": '{"region":"cn","aid":1768,"needFid":false,"service":"www.ixigua.com","migrate_info":{"ticket":"","source":"node"},"cbUrlProtocol":"https","union":true}'
}
class BaseRequestModel(BaseModel):
device_platform: str = "webapp"
aid: str = "6383"
channel: str = "channel_pc_web"
pc_client_type: int = 1
version_code: str = "290100"
version_name: str = "29.1.0"
cookie_enabled: str = "true"
screen_width: int = 1920
screen_height: int = 1080
browser_language: str = "zh-CN"
browser_platform: str = "Win32"
browser_name: str = "Chrome"
browser_version: str = "130.0.0.0"
browser_online: str = "true"
engine_name: str = "Blink"
engine_version: str = "130.0.0.0"
os_name: str = "Windows"
os_version: str = "10"
cpu_core_num: int = 12
device_memory: int = 8
platform: str = "PC"
downlink: str = "10"
effective_type: str = "4g"
from_user_page: str = "1"
locate_query: str = "false"
need_time_list: str = "1"
pc_libra_divert: str = "Windows"
publish_video_strategy_type: str = "2"
round_trip_time: str = "0"
show_live_replay_strategy: str = "1"
time_list_query: str = "0"
whale_cut_token: str = ""
update_version_code: str = "170400"
msToken: str = None
class DouyinDownloader(Downloader):
def __init__(self, cookie=None):
super().__init__()
self.headers_config = DouyinConfig.HEADERS.copy()
self.headers_config["Cookie"] = cfm.get('douyin')
print(self.headers_config)
self.proxies_config = DouyinConfig.PROXIES.copy()
self.ttwid_config = DouyinConfig.TTWID.copy()
self.ms_token_config = DouyinConfig.MS_TOKEN.copy()
@staticmethod
def find_url(string: str) -> list:
url = re.findall('http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', string)
return url
def extract_video_id(self, url: str) -> str:
video_url = self.find_url(url)
if len(video_url):
video_url = video_url[0]
try:
response = requests.head(video_url, allow_redirects=True)
url = response.url
except Exception as e:
return ""
patterns = [
r'video/(\d+)',
r'aweme_id=(\d+)',
]
for pattern in patterns:
match = re.search(pattern, url)
if match:
return match.group(1)
return ""
def gen_real_msToken(self) -> str:
try:
payload = json.dumps(
{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '64',
"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(),
}
],
'noplaylist': True,
'quiet': False,
}
)
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()
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")
title = info.get("title")
duration = info.get("duration", 0)
cover_url = info.get("thumbnail")
audio_path = os.path.join(output_dir, f"{video_id}.mp3")
msToken = str(httpx.Cookies(response.cookies).get("msToken"))
if len(msToken) not in [120, 128]:
raise ValueError("响应内容:{0} Douyin msToken API 的响应内容不符合要求。".format(msToken))
return AudioDownloadResult(
file_path=audio_path,
title=title,
duration=duration,
cover_url=cover_url,
platform="douyin",
video_id=video_id,
raw_info={'tags':info.get('tags')}, #全部返回会报错
video_path=None # ❗音频下载不包含视频路径
)
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 download_video(
self,
video_url: str,
output_dir: Union[str, None] = None,
) -> str:
"""
下载视频,返回视频文件路径
"""
if output_dir is None:
output_dir = get_data_dir()
def fetch_video_info(self, video_url: str) -> json:
try:
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
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()
ydl_opts = {
'format': 'worst[ext=mp4]/worst',
'outtmpl': output_path,
'noplaylist': True,
'quiet': False,
'merge_output_format': 'mp4', # 确保合并成 mp4
}
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}"
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")
print("Request URL:", full_url)
response = requests.get(full_url, headers=kwargs)
print("Response JSON:", response.content)
return response.json()
except Exception as e:
print("请求失败:", e)
raise ValueError("请求失败:", e)
# print(kwargs)
def download(
self,
video_url: str,
output_dir: Union[str, None] = None,
quality: DownloadQuality = "fast",
need_video: Optional[bool] = False
) -> AudioDownloadResult:
try:
print(
f"正在下载视频: {video_url},保存路径: {output_dir},质量: {quality}"
)
if output_dir is None:
output_dir = get_data_dir()
if not output_dir:
output_dir = self.cache_data
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
video_data = self.fetch_video_info(video_url)
output_path = output_path % {
"id": video_data['aweme_detail']['aweme_id'],
"ext": "mp3",
}
url = video_data['aweme_detail']['music']['play_url']['uri']
# 下载音频
audio_data = requests.get(url)
with open(output_path, 'wb') as f:
f.write(audio_data.content)
print(url)
tags = []
for tag in video_data['aweme_detail']['video_tag']:
if tag['tag_name']:
tags.append(tag['tag_name'])
return AudioDownloadResult(
file_path=output_path,
title=video_data['aweme_detail']['item_title'],
duration=video_data['aweme_detail']['video']['duration'],
cover_url=video_data['aweme_detail']['video']['cover_original_scale']['url_list'][0] if
video_data['aweme_detail']['video']['cover'] else video_data['video']['big_thumbs']['img_url'],
platform="douyin",
video_id=video_data['aweme_detail']['aweme_id'],
raw_info={
'tags': video_data['aweme_detail']['caption'] + ''.join(tags),
},
video_path=None # ❗音频下载不包含视频路径
)
except Exception as e:
raise e
def download_video(self, video_url: str, output_dir: Union[str, None] = None) -> str:
try:
if output_dir is None:
output_dir = get_data_dir()
if not output_dir:
output_dir = self.cache_data
os.makedirs(output_dir, exist_ok=True)
video_id = self.extract_video_id(video_url)
video_path = os.path.join(output_dir, f"{video_id}.mp4")
if os.path.exists(video_path):
return video_path
if not os.path.exists(video_path):
raise FileNotFoundError(f"视频文件未找到: {video_path}")
return video_path
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": "mp4",
}
url=video_data['aweme_detail']['video']['download_addr']['url_list'][0]
_data = requests.get(url,allow_redirects=True,headers=self.headers_config)
with open(output_path, 'wb') as f:
f.write(_data.content)
return output_path
except Exception as e:
print("请求失败:", e)
raise ValueError("请求失败:", e)
if __name__ == '__main__':
dy = DouyinDownloader(
cookie='')
dy.download(
'7.43 11/16 gba:/ j@P.xS 以“马成钢”的视角打开《抓娃娃》笼中鸟,何时飞 # 独白 # 人物故事 https://v.douyin.com/0pcFVdG_lx4/ 复制此链接打开Dou音搜索直接观看视频'
)

View File

@@ -0,0 +1,635 @@
"""
Original Author:
This file is from https://github.com/JoeanAmier/TikTokDownloader
And is licensed under the GNU General Public License v3.0
If you use this code, please keep this license and the original author information.
Modified by:
And this file is now a part of the https://github.com/Evil0ctal/Douyin_TikTok_Download_API open-source project.
This project is licensed under the Apache License 2.0, and the original author information is kept.
Purpose:
This file is used to generate the `a_bogus` parameter for the Douyin Web API.
Changes Made:
1. Changed the ua_code to compatible with the current config file User-Agent string in https://github.com/Evil0ctal/Douyin_TikTok_Download_API/blob/main/crawlers/douyin/web/config.yaml
"""
from random import choice
from random import randint
from random import random
from re import compile
from time import time
from urllib.parse import urlencode
from urllib.parse import quote
from gmssl import sm3, func
__all__ = ["ABogus", ]
class ABogus:
__filter = compile(r'%([0-9A-F]{2})')
__arguments = [0, 1, 14]
__ua_key = "\u0000\u0001\u000e"
__end_string = "cus"
__version = [1, 0, 1, 5]
__browser = "1536|742|1536|864|0|0|0|0|1536|864|1536|864|1536|742|24|24|MacIntel"
__reg = [
1937774191,
1226093241,
388252375,
3666478592,
2842636476,
372324522,
3817729613,
2969243214,
]
__str = {
"s0": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
"s1": "Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=",
"s2": "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=",
"s3": "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe",
"s4": "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe",
}
def __init__(self,
# user_agent: str = USERAGENT,
platform: str = None, ):
self.chunk = []
self.size = 0
self.reg = self.__reg[:]
# self.ua_code = self.generate_ua_code(user_agent)
# Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36
self.ua_code = [
76,
98,
15,
131,
97,
245,
224,
133,
122,
199,
241,
166,
79,
34,
90,
191,
128,
126,
122,
98,
66,
11,
14,
40,
49,
110,
110,
173,
67,
96,
138,
252]
self.browser = self.generate_browser_info(
platform) if platform else self.__browser
self.browser_len = len(self.browser)
self.browser_code = self.char_code_at(self.browser)
@classmethod
def list_1(cls, random_num=None, a=170, b=85, c=45, ) -> list:
return cls.random_list(
random_num,
a,
b,
1,
2,
5,
c & a,
)
@classmethod
def list_2(cls, random_num=None, a=170, b=85, ) -> list:
return cls.random_list(
random_num,
a,
b,
1,
0,
0,
0,
)
@classmethod
def list_3(cls, random_num=None, a=170, b=85, ) -> list:
return cls.random_list(
random_num,
a,
b,
1,
0,
5,
0,
)
@staticmethod
def random_list(
a: float = None,
b=170,
c=85,
d=0,
e=0,
f=0,
g=0,
) -> list:
r = a or (random() * 10000)
v = [
r,
int(r) & 255,
int(r) >> 8,
]
s = v[1] & b | d
v.append(s)
s = v[1] & c | e
v.append(s)
s = v[2] & b | f
v.append(s)
s = v[2] & c | g
v.append(s)
return v[-4:]
@staticmethod
def from_char_code(*args):
return "".join(chr(code) for code in args)
@classmethod
def generate_string_1(
cls,
random_num_1=None,
random_num_2=None,
random_num_3=None,
):
return cls.from_char_code(*cls.list_1(random_num_1)) + cls.from_char_code(
*cls.list_2(random_num_2)) + cls.from_char_code(*cls.list_3(random_num_3))
def generate_string_2(
self,
url_params: str,
method="GET",
start_time=0,
end_time=0,
) -> str:
a = self.generate_string_2_list(
url_params,
method,
start_time,
end_time,
)
e = self.end_check_num(a)
a.extend(self.browser_code)
a.append(e)
return self.rc4_encrypt(self.from_char_code(*a), "y")
def generate_string_2_list(
self,
url_params: str,
method="GET",
start_time=0,
end_time=0,
) -> list:
start_time = start_time or int(time() * 1000)
end_time = end_time or (start_time + randint(4, 8))
params_array = self.generate_params_code(url_params)
method_array = self.generate_method_code(method)
return self.list_4(
(end_time >> 24) & 255,
params_array[21],
self.ua_code[23],
(end_time >> 16) & 255,
params_array[22],
self.ua_code[24],
(end_time >> 8) & 255,
(end_time >> 0) & 255,
(start_time >> 24) & 255,
(start_time >> 16) & 255,
(start_time >> 8) & 255,
(start_time >> 0) & 255,
method_array[21],
method_array[22],
int(end_time / 256 / 256 / 256 / 256) >> 0,
int(start_time / 256 / 256 / 256 / 256) >> 0,
self.browser_len,
)
@staticmethod
def reg_to_array(a):
o = [0] * 32
for i in range(8):
c = a[i]
o[4 * i + 3] = (255 & c)
c >>= 8
o[4 * i + 2] = (255 & c)
c >>= 8
o[4 * i + 1] = (255 & c)
c >>= 8
o[4 * i] = (255 & c)
return o
def compress(self, a):
f = self.generate_f(a)
i = self.reg[:]
for o in range(64):
c = self.de(i[0], 12) + i[4] + self.de(self.pe(o), o)
c = (c & 0xFFFFFFFF)
c = self.de(c, 7)
s = (c ^ self.de(i[0], 12)) & 0xFFFFFFFF
u = self.he(o, i[0], i[1], i[2])
u = (u + i[3] + s + f[o + 68]) & 0xFFFFFFFF
b = self.ve(o, i[4], i[5], i[6])
b = (b + i[7] + c + f[o]) & 0xFFFFFFFF
i[3] = i[2]
i[2] = self.de(i[1], 9)
i[1] = i[0]
i[0] = u
i[7] = i[6]
i[6] = self.de(i[5], 19)
i[5] = i[4]
i[4] = (b ^ self.de(b, 9) ^ self.de(b, 17)) & 0xFFFFFFFF
for l in range(8):
self.reg[l] = (self.reg[l] ^ i[l]) & 0xFFFFFFFF
@classmethod
def generate_f(cls, e):
r = [0] * 132
for t in range(16):
r[t] = (e[4 * t] << 24) | (e[4 * t + 1] <<
16) | (e[4 * t + 2] << 8) | e[4 * t + 3]
r[t] &= 0xFFFFFFFF
for n in range(16, 68):
a = r[n - 16] ^ r[n - 9] ^ cls.de(r[n - 3], 15)
a = a ^ cls.de(a, 15) ^ cls.de(a, 23)
r[n] = (a ^ cls.de(r[n - 13], 7) ^ r[n - 6]) & 0xFFFFFFFF
for n in range(68, 132):
r[n] = (r[n - 68] ^ r[n - 64]) & 0xFFFFFFFF
return r
@staticmethod
def pad_array(arr, length=60):
while len(arr) < length:
arr.append(0)
return arr
def fill(self, length=60):
size = 8 * self.size
self.chunk.append(128)
self.chunk = self.pad_array(self.chunk, length)
for i in range(4):
self.chunk.append((size >> 8 * (3 - i)) & 255)
@staticmethod
def list_4(
a: int,
b: int,
c: int,
d: int,
e: int,
f: int,
g: int,
h: int,
i: int,
j: int,
k: int,
m: int,
n: int,
o: int,
p: int,
q: int,
r: int,
) -> list:
return [
44,
a,
0,
0,
0,
0,
24,
b,
n,
0,
c,
d,
0,
0,
0,
1,
0,
239,
e,
o,
f,
g,
0,
0,
0,
0,
h,
0,
0,
14,
i,
j,
0,
k,
m,
3,
p,
1,
q,
1,
r,
0,
0,
0]
@staticmethod
def end_check_num(a: list):
r = 0
for i in a:
r ^= i
return r
@classmethod
def decode_string(cls, url_string, ):
decoded = cls.__filter.sub(cls.replace_func, url_string)
return decoded
@staticmethod
def replace_func(match):
return chr(int(match.group(1), 16))
@staticmethod
def de(e, r):
r %= 32
return ((e << r) & 0xFFFFFFFF) | (e >> (32 - r))
@staticmethod
def pe(e):
return 2043430169 if 0 <= e < 16 else 2055708042
@staticmethod
def he(e, r, t, n):
if 0 <= e < 16:
return (r ^ t ^ n) & 0xFFFFFFFF
elif 16 <= e < 64:
return (r & t | r & n | t & n) & 0xFFFFFFFF
raise ValueError
@staticmethod
def ve(e, r, t, n):
if 0 <= e < 16:
return (r ^ t ^ n) & 0xFFFFFFFF
elif 16 <= e < 64:
return (r & t | ~r & n) & 0xFFFFFFFF
raise ValueError
@staticmethod
def convert_to_char_code(a):
d = []
for i in a:
d.append(ord(i))
return d
@staticmethod
def split_array(arr, chunk_size=64):
result = []
for i in range(0, len(arr), chunk_size):
result.append(arr[i:i + chunk_size])
return result
@staticmethod
def char_code_at(s):
return [ord(char) for char in s]
def write(self, e, ):
self.size = len(e)
if isinstance(e, str):
e = self.decode_string(e)
e = self.char_code_at(e)
if len(e) <= 64:
self.chunk = e
else:
chunks = self.split_array(e, 64)
for i in chunks[:-1]:
self.compress(i)
self.chunk = chunks[-1]
def reset(self, ):
self.chunk = []
self.size = 0
self.reg = self.__reg[:]
def sum(self, e, length=60):
self.reset()
self.write(e)
self.fill(length)
self.compress(self.chunk)
return self.reg_to_array(self.reg)
@classmethod
def generate_result_unit(cls, n, s):
r = ""
for i, j in zip(range(18, -1, -6), (16515072, 258048, 4032, 63)):
r += cls.__str[s][(n & j) >> i]
return r
@classmethod
def generate_result_end(cls, s, e="s4"):
r = ""
b = ord(s[120]) << 16
r += cls.__str[e][(b & 16515072) >> 18]
r += cls.__str[e][(b & 258048) >> 12]
r += "=="
return r
@classmethod
def generate_result(cls, s, e="s4"):
# r = ""
# for i in range(len(s)//4):
# b = ((ord(s[i * 3]) << 16) | (ord(s[i * 3 + 1]))
# << 8) | ord(s[i * 3 + 2])
# r += cls.generate_result_unit(b, e)
# return r
r = []
for i in range(0, len(s), 3):
if i + 2 < len(s):
n = (
(ord(s[i]) << 16)
| (ord(s[i + 1]) << 8)
| ord(s[i + 2])
)
elif i + 1 < len(s):
n = (ord(s[i]) << 16) | (
ord(s[i + 1]) << 8
)
else:
n = ord(s[i]) << 16
for j, k in zip(range(18, -1, -6),
(0xFC0000, 0x03F000, 0x0FC0, 0x3F)):
if j == 6 and i + 1 >= len(s):
break
if j == 0 and i + 2 >= len(s):
break
r.append(cls.__str[e][(n & k) >> j])
r.append("=" * ((4 - len(r) % 4) % 4))
return "".join(r)
@classmethod
def generate_args_code(cls):
a = []
for j in range(24, -1, -8):
a.append(cls.__arguments[0] >> j)
a.append(cls.__arguments[1] / 256)
a.append(cls.__arguments[1] % 256)
a.append(cls.__arguments[1] >> 24)
a.append(cls.__arguments[1] >> 16)
for j in range(24, -1, -8):
a.append(cls.__arguments[2] >> j)
return [int(i) & 255 for i in a]
def generate_method_code(self, method: str = "GET") -> list[int]:
return self.sm3_to_array(self.sm3_to_array(method + self.__end_string))
# return self.sum(self.sum(method + self.__end_string))
def generate_params_code(self, params: str) -> list[int]:
return self.sm3_to_array(self.sm3_to_array(params + self.__end_string))
# return self.sum(self.sum(params + self.__end_string))
@classmethod
def sm3_to_array(cls, data: str | list) -> list[int]:
"""
代码参考: https://github.com/Johnserf-Seed/f2/blob/main/f2/utils/abogus.py
计算请求体的 SM3 哈希值,并将结果转换为整数数组
Calculate the SM3 hash value of the request body and convert the result to an array of integers
Args:
data (Union[str, List[int]]): 输入数据 (Input data).
Returns:
List[int]: 哈希值的整数数组 (Array of integers representing the hash value).
"""
if isinstance(data, str):
b = data.encode("utf-8")
else:
b = bytes(data) # 将 List[int] 转换为字节数组
# 将字节数组转换为适合 sm3.sm3_hash 函数处理的列表格式
h = sm3.sm3_hash(func.bytes_to_list(b))
# 将十六进制字符串结果转换为十进制整数列表
return [int(h[i: i + 2], 16) for i in range(0, len(h), 2)]
@classmethod
def generate_browser_info(cls, platform: str = "Win32") -> str:
inner_width = randint(1280, 1920)
inner_height = randint(720, 1080)
outer_width = randint(inner_width, 1920)
outer_height = randint(inner_height, 1080)
screen_x = 0
screen_y = choice((0, 30))
value_list = [
inner_width,
inner_height,
outer_width,
outer_height,
screen_x,
screen_y,
0,
0,
outer_width,
outer_height,
outer_width,
outer_height,
inner_width,
inner_height,
24,
24,
platform,
]
return "|".join(str(i) for i in value_list)
@staticmethod
def rc4_encrypt(plaintext, key):
s = list(range(256))
j = 0
for i in range(256):
j = (j + s[i] + ord(key[i % len(key)])) % 256
s[i], s[j] = s[j], s[i]
i = 0
j = 0
cipher = []
for k in range(len(plaintext)):
i = (i + 1) % 256
j = (j + s[i]) % 256
s[i], s[j] = s[j], s[i]
t = (s[i] + s[j]) % 256
cipher.append(chr(s[t] ^ ord(plaintext[k])))
return ''.join(cipher)
def get_value(self,
url_params: dict | str,
method="GET",
start_time=0,
end_time=0,
random_num_1=None,
random_num_2=None,
random_num_3=None,
) -> str:
string_1 = self.generate_string_1(
random_num_1,
random_num_2,
random_num_3,
)
string_2 = self.generate_string_2(urlencode(url_params) if isinstance(
url_params, dict) else url_params, method, start_time, end_time, )
string = string_1 + string_2
# return self.generate_result(
# string, "s4") + self.generate_result_end(string, "s4")
return self.generate_result(string, "s4")
if __name__ == "__main__":
bogus = ABogus()
USERAGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"
url_str = "https://www.douyin.com/aweme/v1/web/aweme/detail/?device_platform=webapp&aid=6383&channel=channel_pc_web&pc_client_type=1&version_code=190500&version_name=19.5.0&cookie_enabled=true&browser_language=zh-CN&browser_platform=Win32&browser_name=Firefox&browser_online=true&engine_name=Gecko&os_name=Windows&os_version=10&platform=PC&screen_width=1920&screen_height=1080&browser_version=124.0&engine_version=122.0.0.0&cpu_core_num=12&device_memory=8&aweme_id=7345492945006595379"
# 将url参数转换为字典
url_params = dict([param.split("=")
for param in url_str.split("?")[1].split("&")])
print(f"URL参数: {url_params}")
a_bogus = bogus.get_value(url_params, )
# 使用url编码a_bogus
a_bogus = quote(a_bogus, safe='')
print(a_bogus)
print(USERAGENT)

View File

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

View File

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

View File

@@ -0,0 +1,137 @@
import os
import subprocess
from abc import ABC
from typing import Optional
from app.downloaders.base import Downloader
from app.enmus.note_enums import DownloadQuality
from app.models.audio_model import AudioDownloadResult
import os
import subprocess
from app.utils.video_helper import save_cover_to_static
class LocalDownloader(Downloader, ABC):
def __init__(self):
super().__init__()
def extract_cover(self, input_path: str, output_dir: Optional[str] = None) -> str:
"""
从本地视频文件中提取一张封面图(默认取第一帧)
:param input_path: 输入视频路径
:param output_dir: 输出目录,默认和视频同目录
:return: 提取出的封面图片路径
"""
if not os.path.exists(input_path):
raise FileNotFoundError(f"输入文件不存在: {input_path}")
if output_dir is None:
output_dir = os.path.dirname(input_path)
base_name = os.path.splitext(os.path.basename(input_path))[0]
output_path = os.path.join(output_dir, f"{base_name}_cover.jpg")
try:
command = [
'ffmpeg',
'-i', input_path,
'-ss', '00:00:01', # 跳到视频第1秒防止黑屏
'-vframes', '1', # 只截取一帧
'-q:v', '2', # 输出质量高一点qscale2是很高
'-y', # 覆盖
output_path
]
subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
if not os.path.exists(output_path):
raise RuntimeError(f"封面图片生成失败: {output_path}")
return output_path
except subprocess.CalledProcessError as e:
raise RuntimeError(f"提取封面失败: {output_path}") from e
def convert_to_mp3(self,input_path: str, output_path: str = None) -> str:
"""
将本地视频文件转为 MP3 音频文件
:param input_path: 输入文件路径(如 .mp4
:param output_path: 输出文件路径(可选,默认同目录同名 .mp3
:return: 生成的 mp3 文件路径
"""
if not os.path.exists(input_path):
raise FileNotFoundError(f"输入文件不存在: {input_path}")
if output_path is None:
base, _ = os.path.splitext(input_path)
output_path = base + ".mp3"
try:
# 调用 ffmpeg 转换
command = [
'ffmpeg',
'-i', input_path,
'-vn', # 不要视频流
'-acodec', 'libmp3lame', # 使用mp3编码
'-y', # 覆盖输出文件
output_path
]
subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
if not os.path.exists(output_path):
raise RuntimeError(f"mp3 文件生成失败: {output_path}")
return output_path
except subprocess.CalledProcessError as e:
raise RuntimeError(f"mp3 文件生成失败: {output_path}") from e
def download_video(self, video_url: str, output_dir: str = None) -> str:
"""
处理本地文件路径,返回视频文件路径
"""
if video_url.startswith('/uploads'):
project_root = os.getcwd()
video_url = os.path.join(project_root, video_url.lstrip('/'))
video_url = os.path.normpath(video_url)
if not os.path.exists(video_url):
raise FileNotFoundError()
return video_url
def download(
self,
video_url: str,
output_dir: str = None,
quality: DownloadQuality = "fast",
need_video: Optional[bool] = False
) -> AudioDownloadResult:
"""
处理本地文件路径,返回音频元信息
"""
if video_url.startswith('/uploads'):
project_root = os.getcwd()
video_url = os.path.join(project_root, video_url.lstrip('/'))
video_url = os.path.normpath(video_url)
if not os.path.exists(video_url):
raise FileNotFoundError(f"本地文件不存在: {video_url}")
file_name = os.path.basename(video_url)
title, _ = os.path.splitext(file_name)
print(title, file_name,video_url)
file_path=self.convert_to_mp3(video_url)
cover_path = self.extract_cover(video_url)
cover_url = save_cover_to_static(cover_path)
print('file——path',file_path)
return AudioDownloadResult(
file_path=file_path,
title=title,
duration=0, # 可选:后续加上读取时长
cover_url=cover_url, # 暂无封面
platform="local",
video_id=title,
raw_info={
'path': file_path
},
video_path=None
)

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

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

View File

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

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