54 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
bd8b4dc44e Fix Bilibili 412 error: inject dm_img risk-control params into yt-dlp wbi/playurl requests 2026-06-11 03:25:30 +00:00
copilot-swe-agent[bot]
e5a7cf7151 Initial plan 2026-06-11 03:18:45 +00:00
huangjianwu
f5bfb43619 docs(readme): 群二维码改为关注公众号回复「交流群」获取
将 README 社区区块中 5 个会过期的微信群二维码,替换为公众号二维码,
关注后回复「交流群」获取最新群码,避免群码失效。与关于页保持一致。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:09:03 +08:00
huangjianwu
ddaa0eef92 Merge branch 'release/2.4.0' 2026-06-07 02:05:37 +08:00
huangjianwu
e41a3e27eb chore(release): 2.4.0
- 版本号 2.3.4 → 2.4.0(tauri.conf.json / README)
- CHANGELOG 补充 2.4.0、回填 2.3.4

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:05:30 +08:00
huangjianwu
fd18aa2955 Merge branch 'pr-383' into develop 2026-06-07 02:03:40 +08:00
huangjianwu
0a93911f3e Merge branch 'docs/gpu-deploy-notes' into develop 2026-06-07 02:03:40 +08:00
huangjianwu
d0760bcbbc Merge branch 'feat/configurable-whisper-models' into develop 2026-06-07 02:03:40 +08:00
huangjianwu
2fc558e00a Merge branch 'master' into develop 2026-06-07 02:01:09 +08:00
huangjianwu
a83642e602 feat(about): 群二维码改为扫描公众号回复「交流群」获取
关于页社区区块由直接展示会过期的微信群二维码,改为展示公众号二维码,
引导用户关注公众号后回复「交流群」获取最新群二维码,避免群码过期失效。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:00:56 +08:00
huangjianwu
9bc3b2960b Merge branch 'master' into develop 2026-05-27 02:52:11 +08:00
huangjianwu
095d772c7d Merge branch 'release/2.3.4' 2026-05-27 02:51:43 +08:00
huangjianwu
2e3fda7df4 feat: 新增 BiliNote AI笔记系统一对一搭建服务二维码(README + 关于页),bump 版本至 2.3.4
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:51:15 +08:00
Jianwu Huang
2ea95b2fad Merge pull request #382 from techotaku39/fix/frontend-about-version
fix(frontend): 同步关于页版本号
2026-05-27 02:37:03 +08:00
techotaku39
e78b687096 fix(extension): improve title display and mindmap export 2026-05-26 21:15:39 +08:00
techotaku39
9d1a7cd699 fix(frontend): 同步关于页版本号 2026-05-26 15:54:05 +08:00
Jianwu Huang
f2d8ece0c1 Merge pull request #374 from techotaku39/fix/backend-api-key-mask-and-ffmpeg-path
fix(backend): 防御 API Key 掩码污染并修复 EXE 版 .env 加载路径
2026-05-25 13:21:38 +08:00
Jianwu Huang
7bc4b0114e Merge pull request #375 from techotaku39/feature/extension-show-title-instead-of-url
feat(extension): 侧边栏与 popup 用视频标题替代链接显示
2026-05-25 13:21:11 +08:00
Jianwu Huang
f7ea6f72d9 Merge pull request #377 from techotaku39/fix/markdown-anchor-navigation
fix(frontend): 修复 Markdown 目录锚点跳转与 Tauri 路由
2026-05-25 13:20:19 +08:00
techotaku39
905dbcce47 fix(frontend): 增强锚点链接模糊匹配,兼容 LLM 生成的不一致目录格式 2026-05-24 03:21:20 +08:00
techotaku39
ebdb254fc6 fix(frontend): 修复 Markdown 目录锚点跳转与 Tauri 路由
- 安装 rehype-slug 插件,自动为 heading 生成 id,解决目录链接无锚点目标的问题
- 自定义 <a> 组件处理内部锚点链接,阻止默认刷新行为并使用 scrollIntoView 平滑滚动
- Tauri 桌面端改用 HashRouter,避免刷新时 404 及错误打开外部浏览器

fixes #xxx

Co-Authored-By: HAPI <noreply@hapi.run>
2026-05-24 03:21:20 +08:00
techotaku39
1eb213e215 feat(extension): 侧边栏与 popup 用视频标题替代链接显示
在任务未完成的早期阶段(PENDING/DOWNLOADING 等),侧边栏和 popup
只能回退到 videoUrl,用户看到的是一长串链接,难以辨认。

改动:
- TaskRecord 新增 title 字段,用于存储浏览器标签页标题
- popup 创建任务时保存 tab.title
- background startTask 接收可选 title,右键菜单和悬浮按钮均传入
- 显示优先级:result.audio_meta.title > title > videoUrl
- 所有平台(Bilibili / YouTube / Douyin / Kuaishou)均受益

测试:
- pnpm typecheck 通过
- pnpm build 通过
- 在 B 站、YouTube 视频页提交任务,侧边栏和 popup 均显示标题而非链接
2026-05-24 00:06:21 +08:00
techotaku39
4425239717 fix(backend): 防御 API Key 掩码污染并修复 EXE 版 .env 加载路径
- provider.py: 更新供应商时,若 api_key 包含 '*'(掩码字符),
  跳过该字段,防止前端展示用的 mask_key() 值被误写入数据库。

- ffmpeg_helper.py: load_dotenv() 默认只从 CWD 查找 .env,
  PyInstaller 打包后 CWD 为 EXE 目录,导致 _internal/.env 被忽略。
  改为遍历多个候选路径(CWD、脚本目录、项目根目录、_internal/),
  确保源码和打包两种场景都能正确加载环境变量。
2026-05-23 22:49:56 +08:00
huangjianwu
64a0400792 docs(readme): 补全 GPU/CUDA 部署说明
原 CUDA 段落只有一行链接。补上实操步骤与常见坑:
- 宿主机前提:NVIDIA 驱动 + NVIDIA Container Toolkit + --gpus all 验证命令
- 切换:先 docker-compose down(两套 compose 容器名相同)再 -f docker-compose.gpu.yml up --build -d
- 数据不丢(两套 compose 都绑挂 ./backend);首次构建大而慢
- 只有本地 Faster Whisper 吃 GPU(在线引擎无关);device 自动检测无需配置
- 确认走 GPU 的方法 + 没走 GPU 的排查清单 + 国内镜像 build-arg 提示
另:把 compose 块里 GPU 那行 up -d 改成 up --build -d(首次需构建)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:51:47 +08:00
huangjianwu
58d992f28f feat(transcriber): 可配置 whisper 模型 + 名称映射(自定义 HF repo / 本地路径)
此前 fast-whisper 把「size → Systran/faster-whisper-{size}」的约定隐式散落在
加载/下载/检测三处,用户想用命名不符该约定的模型(社区微调版、或自己下到本地
的模型)接不上。本功能把映射显式化 + 可配置(对齐已有的 MLX_MODEL_MAP 模式)。

后端:
- 新增 app/transcriber/whisper_models.py 注册表:内置映射 + 用户自定义
  (config/whisper_models.json 持久化,Docker 下随 config 卷保留);resolve
  优先级 自定义 > 内置 > 直通(含 / 的 repo_id / 已存在本地目录)。
- whisper.py / config.py 的加载、下载、完整性检测统一走 resolve;HF cache 目录从
  任意 repo_id 推导(models--{org}--{name})不再写死 Systran;本地路径跳过下载,
  _purge_cache 绝不删用户本地模型。
- 新增 /whisper_models 增删查 API;/transcriber_config 返回内置+自定义列表;
  下载校验放开到「已登记/可解析」的模型。

前端:transcriber.tsx 新增「自定义模型」卡片(增删 + 下载状态),模型下拉自动含自定义。

Docker:自定义 HF 模型下到 /app/backend/models(v2.3.3 models 卷已持久化);本地模型
走挂载目录 + 配置路径,UI 已提示挂载。

测试:tests/test_whisper_models.py 13 个单测全过;并在 v2.3.3 镜像真实后端环境做了
import 链 + resolve + 真实模型检测的集成冒烟,均通过。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:09:06 +08:00
huangjianwu
db556b8991 Merge branch 'release/2.3.3'
v2.3.3:预构建镜像持久化数据库/笔记,修升级丢配置与历史。
2026-05-22 14:07:00 +08:00
huangjianwu
717df2af7b Merge branch 'release/2.3.3' into develop
回合并 v2.3.3 版本号。
2026-05-22 14:07:00 +08:00
huangjianwu
b431db545a docs: v2.3.3 CHANGELOG + README + tauri 版本
Docker 镜像数据持久化修复版本。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:07:00 +08:00
huangjianwu
25face4b67 Merge branch 'fix/docker-data-persistence' into develop
v2.3.3:预构建镜像持久化数据库/笔记,修升级丢配置与历史。
2026-05-22 14:06:24 +08:00
huangjianwu
edfd6e4765 fix(docker): 预构建镜像持久化数据库/笔记,修升级丢配置与历史
README 的 docker run 只挂了 -v …:/app/backend/data(仅媒体缓存),但
SQLite 库在 /app/backend/bili_note.db、笔记在 /app/backend/note_results
都不在该卷下 → 用户删容器重建(如 docker pull 升级镜像)会丢失 LLM
供应商配置和全部笔记历史。

- Dockerfile.complete:DATABASE_URL 重定向到 /app/backend/data/bili_note.db、
  NOTE_OUTPUT_DIR 改到 data/note_results,并预建 /app/backend/data 目录;
  两条 supervisord environment 行同步(兜底默认值 + %(ENV_*)s 透传)。
- README:docker run 改挂 data(库+笔记)/ config(供应商·Cookie·转写配置)/
  static(截图)/ models(模型缓存,可选)四个数据卷,并警告不要挂整个
  /app/backend(命名卷会固化镜像内代码,导致 docker pull 升级不生效)。
- docker-build.yml:同步发布说明里 echo 的 run 命令。

截图仍留在 /app/backend/static(static_dir 硬编码服务,不能重定向),单独挂卷。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:30:42 +08:00
huangjianwu
b53cafda5a Merge branch 'release/2.3.2' into develop
回合并 v2.3.2 版本号。
2026-05-22 11:41:17 +08:00
huangjianwu
adda5fd240 Merge branch 'release/2.3.2'
v2.3.2:修后端启动崩溃(ctranslate2 4.6.0) + whisper 模型路径 + 桌面端版本号。
2026-05-22 11:41:04 +08:00
huangjianwu
3e28f1fe38 docs: v2.3.2 CHANGELOG + README + tauri 版本
docker 启动崩溃 / whisper 模型路径 / 桌面端版本号 修复版本。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:40:55 +08:00
huangjianwu
bffa285cd0 Merge branch 'fix/docker-startup-and-qr' into develop
v2.3.2 修复集:
- ctranslate2 4.5.0→4.6.0 修后端启动崩溃(可执行栈 / glibc 2.41)
- whisper 模型下载/加载统一 HF cache 布局
- 桌面端构建版本号从 tag 注入(修产物恒为 2.0.0)
2026-05-22 11:40:21 +08:00
huangjianwu
b740e70068 fix(desktop): 构建时从 tag 注入版本号,修产物版本恒为 2.0.0
桌面端构建产物(.dmg/.msi 文件名 + app 内部版本)一直是 2.0.0:
Tauri 取 tauri.conf.json 的静态 version 字段作为产物版本,而 Release
工作流只把 tag 名用作 Release 标题,没同步到 conf → 产物版本与 Release
版本错位。

在 pnpm tauri build 前新增 Sync version 步骤,从 github.ref_name(形如
v2.3.2,去掉前缀 v)注入版本到 tauri.conf.json。以后每次 tag 发版自动
对齐;workflow_dispatch 手动构建无 tag 时跳过,保留静态值不破坏。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:40:04 +08:00
huangjianwu
261c95cf12 fix(transcriber): whisper 模型下载/加载统一走 HF cache 布局
此前用 modelscope 下到自定义目录 whisper-{size}/ 再把该路径传给
WhisperModel。但 faster-whisper 1.1.1 只要 path 含 '/' 就当成 HF
repo_id 处理,没有「本地目录直接返回」分支 → 在线请求失败后 fallback
local_files_only,又因 modelscope 布局命不中 HF cache → LocalEntryNotFound,
误导用户以为是「离线模式」。

改为下载与加载路径对齐:
- 下载:huggingface_hub.snapshot_download(cache_dir=model_dir),落到 HF
  cache 布局 models--Systran--faster-whisper-{size}/snapshots/<hash>/
- 加载:WhisperModel(model_size_or_path=size, download_root=model_dir),
  让 faster-whisper 自己映射到 Systran/faster-whisper-* 并命中同一 cache
- 完整性检测 / 损坏自愈(_purge_cache) 同步按 HF cache 布局,并兼容老
  modelscope 目录(向后兼容已下载的老用户)

HF_ENDPOINT 已在 Dockerfile 指向 hf-mirror.com,国内可用。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:27:03 +08:00
huangjianwu
1cc7f38e14 fix(backend): 升级 ctranslate2 4.5.0→4.6.0 修复后端启动崩溃
Docker 容器反复重启,启动即报:
  ImportError: libctranslate2-*.so.4.5.0: cannot enable executable
  stack as shared object requires: Invalid argument

根因:ctranslate2 4.5.0 预编译 wheel 把共享库标记为「需要可执行栈」,
新内核 / glibc 2.41+ 加载时拒绝并返回 EINVAL。faster-whisper 在
whisper.py 顶层 import,import 失败直接拖垮整个后端启动 → 重启死循环。

ctranslate2 4.6.0 加入 noexecstack 链接标志(OpenNMT/CTranslate2 #1852、
#1861)从 wheel 层根治。faster-whisper 1.1.1 依赖 ctranslate2<5,>=4.0,
4.6.0 兼容;同时覆盖 web / GPU / 桌面 三条构建链。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:26:49 +08:00
huangjianwu
7fffd6873b Merge branch 'release/2.3.1' into develop
回合并 v2.3.1 版本号(CHANGELOG + README)。
2026-05-22 10:51:49 +08:00
huangjianwu
7b927db363 Merge branch 'release/2.3.1'
v2.3.1:更新微信交流群二维码(群 1-5)。
2026-05-22 10:51:39 +08:00
huangjianwu
c42ceaaa32 docs: v2.3.1 CHANGELOG + README 版本
二维码热修补丁版本。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:51:30 +08:00
huangjianwu
177ee4ba3a Merge branch 'fix/docker-startup-and-qr' into develop
更新微信交流群二维码(群 1-5,旧码即将失效)。
同步 master 上的二维码热修(README 二维码区块 + 5 张入群图)到 develop。
2026-05-22 10:51:05 +08:00
huangjianwu
aae17abf9a docs: 更新微信交流群二维码(群 1-5,有效期至 5/29)
旧二维码即将失效,替换 README 中 5 个交流群的入群二维码。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:42:12 +08:00
huangjianwu
33d44e32d2 docs: 突出 BiliNote Pro 在线版 + 更新微信交流群二维码
- README 顶部新增 BiliNote Pro 在线版推广区块(www.bilinote.app),
  项目简介后加引导提示,「在线使用」章节改为「在线使用(推荐)」
- 微信交流群二维码更新为 5 个群,doc/wechat-group-1~5.png

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:45:34 +08:00
Jianwu Huang
ce58cb9352 Merge pull request #369 from JefferyHcool/release/2.3.0
chore(release): merge release/2.3.0 back into develop
2026-05-14 21:09:55 +08:00
Jianwu Huang
2043d89288 Merge pull request #368 from JefferyHcool/release/2.3.0
chore(release): v2.3.0
2026-05-14 21:09:30 +08:00
huangjianwu
56e075253a docs: v2.3.0 CHANGELOG + README 版本
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:05:46 +08:00
huangjianwu
f1b091b846 chore(deploy): docker 镜像源/restart 策略 + .env 修正 + 文档
- 所有 Dockerfile 加 BASE_REGISTRY build-arg,国内拉不到 docker.io
  时可换 daocloud 等镜像源;compose 透传该 arg
- docker-compose: restart 从 on-failure:3 改 unless-stopped(避免短暂
  崩溃后永久打死);gpu compose 补齐 healthcheck/restart/mem_limit
- Dockerfile.complete: supervisord 用 %(ENV_*)s 透传环境变量给 backend
  子进程(之前只白名单 2 个,docker run -e 配的变量后端看不到)
- .env.example: 修正 VITE_API_BASE_URL 端口(8000→8483)、
  WHISPER_MODEL_SIZE medium→tiny(首次启动不被大模型下载卡住)、
  补 Docker 部署说明注释
- README: 新增 Docker 部署常见问题 FAQ(镜像源/restart/数据持久化等)
- CLAUDE.md: 勘误(移除不存在的 messaging/i18n/worker_registry 描述,
  修正 events 路径),补 pytest/typecheck 命令

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:01:55 +08:00
huangjianwu
37f7ee6e15 feat(desktop): 后端健康监控韧性 + onboarding 修复 + 全局代理 UI
- useCheckBackend 重写:60s 总超时取代 while(true) 死轮询,订阅 Tauri
  backend-ready/terminated/startup-timeout 事件,裸 fetch 探测避免
  启动期 toast 叠堆
- Tauri lib.rs:spawn 后 HTTP 探针轮询 /api/sys_check 拿 200 才算就绪
  (之前 TCP connect 会被孤儿进程误判);RunEvent::Exit 钩子退出前
  kill sidecar,修孤儿进程占端口;restart 前发 backend-restarting
  让前端忽略主动 kill 引发的 terminated
- BackendInitDialog:失败态展示原因 + 最近 stderr + 重启/复制日志按钮
- StartupBanner:收到 restarted/ready 自动清「已退出」横幅
- BackendHealthIndicator:修 /api/api/sys_health 双前缀 404
- Onboarding:step1 后端连通改自动重试 + 事件触发 + 手动按钮;step2
  撞预置供应商名时改为更新已存在供应商;errText 统一错误文案
- 全局代理 UI:下载配置页新增代理卡片(services/proxy.ts + ProxyConfig)
- request.ts 加 suppressToast 配置位,预期失败不弹全局红 toast
- NoteForm/taskStore:捕获就绪门禁错误,引导去音频转写配置页下载
- providerCard:整行可点切换(之前只有 icon 区域响应)
- Monitor 页 Whisper 卡显示模型本地下载状态
- tauri/api 升级对齐 2.11,修 vite build 版本不匹配

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:01:37 +08:00
huangjianwu
41f17592c2 fix(backend): 部署韧性——模型自愈/就绪门禁/全局代理/启动诊断
- whisper: model.bin 截断/损坏时删目录重下重试一次,修「Unable to
  open file model.bin」死循环;mlx 同样按 config.json 判完整性
- /generate_note 加就绪门禁:本地转写引擎模型没下好直接拦截,返回
  reason=transcriber_model_not_ready,不让任务静默卡在首次下载
- 全局代理:新增 ProxyConfigManager(JSON 配置 + HTTP_PROXY env 兜底)
  + build_openai_client,统一注入代理到 LLM/Groq 客户端;yt-dlp 与
  youtube-transcript-api 也走代理
- build_openai_client 校验 api_key 非空,空 key 给「xxx 的 API Key
  未配置」而不是天书般的 Illegal header value b'Bearer '
- universal_gpt: 模型拒绝自定义 temperature(o1/o3/gpt-5 系列)时
  就地去掉参数重试,不消耗重试预算
- connect_test 改用真实 chat completion 而非 /v1/models 探测
- main.py: lifespan 拆 [startup 1/5..5/5] 分段日志 + 异常清晰定位
- /sys_health 重构为结构化返回 {backend,ffmpeg,db,whisper_model}

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:01:14 +08:00
Jianwu Huang
88d25f8cc1 Update README.md 2026-05-14 09:24:59 +08:00
huangjianwu
de630dadb3 chore(release): merge hotfix/tauri-api-dep into develop 2026-05-09 14:53:18 +08:00
huangjianwu
e9d4740cc7 chore(release): merge hotfix/desktop-pnpm-version into develop 2026-05-09 14:49:53 +08:00
huangjianwu
ec33ae35ed chore(release): merge hotfix/docker-pnpm-version into develop 2026-05-09 14:47:25 +08:00
huangjianwu
0742387235 chore(release): merge release/2.2.0 back into develop 2026-05-09 14:43:48 +08:00
78 changed files with 3559 additions and 1030 deletions

View File

@@ -1,12 +1,28 @@
# =============================================================================
# BiliNote 环境变量示例
# Docker 部署cp .env.example .env按需修改然后 docker-compose up --build -d
#
# 注意区分两类变量:
# 1) VITE_* 是【构建时】变量,会被烘进前端 JS bundle。改完必须
# docker-compose build frontend && docker-compose up -d 才会生效,
# 只 docker-compose restart 不行。
# 2) 其他后端变量是【运行时】变量,改完 docker-compose up -d 即可。
#
# 提醒LLM API key 不要写在这里!请部署完成后,从前端「模型供应商」页面录入,
# 这些 key 会保存到 SQLite 数据库(./backend/bili_note.db并随容器持久化。
# =============================================================================
# 通用端口配置
BACKEND_PORT=8483 # 后端端口
FRONTEND_PORT=3015
BACKEND_HOST=0.0.0.0 # 默认为 0.0.0.0,表示监听所有 IP 地址 不建议动
APP_PORT= 3015 # docker 部署时
# 前端访问后端用 (开发环境使用)
VITE_API_BASE_URL=http://127.0.0.1:8000
APP_PORT=3015 # docker 部署时对外暴露端口(浏览器访问的端口)
# 前端访问后端用开发环境直连Docker 部署下走 nginx 代理,此值仅作回退)
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
@@ -14,11 +30,16 @@ OUT_DIR=./static/screenshots
NOTE_OUTPUT_DIR=note_results
IMAGE_BASE_URL=/static/screenshots
DATA_DIR=data
# FFMPEG 配置
# FFMPEG 配置Docker 镜像已内置 ffmpeg留空即可自建/桌面端可填绝对路径)
FFMPEG_BIN_PATH=
# transcriber 相关配置
TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台)/groq
WHISPER_MODEL_SIZE=medium
# 转写器配置
# TRANSCRIBER_TYPE 可选:fast-whisper / bcut / kuaishou / mlx-whisper(仅 Apple Silicon) / groq
TRANSCRIBER_TYPE=fast-whisper
# WHISPER_MODEL_SIZE 默认 tiny (~75MB),首次启动快;想要更高识别质量可在前端
# 「音频转写配置」页切到 base/small/medium/large。直接在这里改大尺寸会触发
# 首次启动下载 ~1.5GB 文件,慢网络或 4GB 内存的容器容易 OOM。
WHISPER_MODEL_SIZE=tiny
GROQ_TRANSCRIBER_MODEL=whisper-large-v3-turbo # groq提供的faster-whisper 默认为 whisper-large-v3-turbo

View File

@@ -42,6 +42,16 @@ jobs:
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Resolve app version
id: app-version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
else
echo "version=" >> "$GITHUB_OUTPUT"
fi
- name: Build and Push Docker Image
uses: docker/build-push-action@v6
with:
@@ -50,6 +60,8 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VITE_APP_VERSION=${{ steps.app-version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
@@ -66,6 +78,9 @@ jobs:
echo "Run the container:"
echo " docker run -d -p 80:80 \\"
echo " -v bilinote-data:/app/backend/data \\"
echo " -v bilinote-config:/app/backend/config \\"
echo " -v bilinote-static:/app/backend/static \\"
echo " -v bilinote-models:/app/backend/models \\"
echo " --name bilinote \\"
echo " ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
echo ""

View File

@@ -79,6 +79,19 @@ jobs:
key: ${{ runner.os }}-cargo-${{ hashFiles('BillNote_frontend/src-tauri/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
# 从 tag 注入版本号到 tauri.conf.jsonTauri 取该文件的静态 version 作为
# 产物版本,不同步的话构建产物会恒为 conf 里写死的值(此前的 2.0.0)。
# github.ref_name 形如 v2.3.2,去掉前缀 v。workflow_dispatch无 tag时跳过保留静态值。
- name: Sync version from tag
if: startsWith(github.ref, 'refs/tags/v')
working-directory: BillNote_frontend
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "Injecting version $VERSION into tauri.conf.json"
node -e "const f='src-tauri/tauri.conf.json'; const fs=require('fs'); const j=JSON.parse(fs.readFileSync(f,'utf8')); j.version=process.argv[1]; fs.writeFileSync(f, JSON.stringify(j,null,2)+'\n');" "$VERSION"
node -e "console.log('tauri.conf.json version =', require('./src-tauri/tauri.conf.json').version)"
# 打包 Tauri 应用
- name: Build Tauri App
working-directory: BillNote_frontend

View File

@@ -3,6 +3,7 @@ import type { Settings, TaskRecord } from '~/logic/types'
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from '~/logic/constants'
import { detectPlatform } from '~/logic/platform'
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
import { normalizeVideoTitle } from '~/logic/task-display'
// only on dev mode
if (import.meta.hot) {
@@ -56,8 +57,9 @@ async function upsertTask(record: TaskRecord) {
// ---------- 启动任务 ----------
async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, error?: string }> {
async function startTask(url: string, title?: string): Promise<{ ok: boolean, taskId?: string, error?: string }> {
const platform = detectPlatform(url)
const displayTitle = normalizeVideoTitle(title)
if (!platform)
return { ok: false, error: '当前链接不是支持的视频平台' }
@@ -107,6 +109,7 @@ async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, e
message: '已提交',
createdAt: Date.now(),
updatedAt: Date.now(),
title: displayTitle,
})
return { ok: true, taskId: body.data.task_id }
}
@@ -129,8 +132,8 @@ async function openSidePanelInTab(tabId?: number) {
// ---------- 消息桥 ----------
onMessage<{ url: string }, 'bilinote-start'>('bilinote-start', async ({ data, sender }) => {
const result = await startTask(data.url)
onMessage<{ url: string; title?: string }, 'bilinote-start'>('bilinote-start', async ({ data, sender }) => {
const result = await startTask(data.url, data.title)
// 成功就把侧边栏拉起来给用户看进度
if (result.ok)
await openSidePanelInTab(sender?.tabId)
@@ -168,7 +171,7 @@ browser.contextMenus?.onClicked.addListener(async (info, tab) => {
const url = info.linkUrl || tab?.url
if (!url)
return
const result = await startTask(url)
const result = await startTask(url, tab?.title)
if (result.ok)
await openSidePanelInTab(tab?.id)
else

View File

@@ -1,32 +1,181 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Transformer } from 'markmap-lib'
import { Markmap } from 'markmap-view'
import { absolutizeMarkdownImages, stripSourceLink } from '~/logic/api'
const props = defineProps<{ markdown: string }>()
const wrapRef = ref<HTMLDivElement | null>(null)
const svgRef = ref<SVGSVGElement | null>(null)
let mm: Markmap | null = null
let resizeObserver: ResizeObserver | null = null
const transformer = new Transformer()
const MIN_EXPORT_FONT_PX = 256
const MIN_EXPORT_WIDTH = 12800
const MAX_EXPORT_SCALE = 24
const MAX_CANVAS_SIDE = 32767
function render() {
if (!svgRef.value)
return
const md = absolutizeMarkdownImages(stripSourceLink(props.markdown || ''))
const { root } = transformer.transform(md)
if (!mm)
mm = Markmap.create(svgRef.value, undefined, root)
else
mm.setData(root).then(() => mm?.fit())
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob)
resolve(blob)
else
reject(new Error('导出思维导图图片失败'))
}, 'image/png')
})
}
onMounted(render)
function createSvgElement<K extends keyof SVGElementTagNameMap>(tag: K): SVGElementTagNameMap[K] {
return document.createElementNS('http://www.w3.org/2000/svg', tag)
}
function sanitizeSvgForCanvas(svg: SVGSVGElement): SVGSVGElement {
const cloned = svg.cloneNode(true) as SVGSVGElement
cloned.querySelectorAll('image').forEach(el => el.remove())
cloned.querySelectorAll('foreignObject').forEach((foreignObject) => {
const textContent = foreignObject.textContent?.replace(/\s+/g, ' ').trim()
if (!textContent) {
foreignObject.remove()
return
}
const x = Number(foreignObject.getAttribute('x') || 0)
const y = Number(foreignObject.getAttribute('y') || 0)
const height = Number(foreignObject.getAttribute('height') || 20)
const text = createSvgElement('text')
text.setAttribute('x', String(x))
text.setAttribute('y', String(y + height / 2))
text.setAttribute('dominant-baseline', 'middle')
text.setAttribute('font-size', '14')
text.setAttribute('font-family', 'Arial, "Microsoft YaHei", sans-serif')
text.setAttribute('fill', '#333')
text.textContent = textContent
foreignObject.replaceWith(text)
})
return cloned
}
function getExportFontSize(svg: SVGSVGElement): number {
const text = svg.querySelector('text, foreignObject')
if (!text)
return 14
const fontSize = Number.parseFloat(getComputedStyle(text).fontSize || '')
if (Number.isFinite(fontSize) && fontSize > 0)
return fontSize
const attrSize = Number.parseFloat(text.getAttribute('font-size') || '')
return Number.isFinite(attrSize) && attrSize > 0 ? attrSize : 14
}
function stripMindmapNoise(md: string): string {
return absolutizeMarkdownImages(stripSourceLink(md || ''))
// 笔记里的截图/封面图片在思维导图中会被当作超大 SVG foreignObject
// 容易把导图挤成截图里那种“只剩半框/一条竖线”的效果。导图只保留文字层级。
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/<img\b[^>]*>/gi, '')
}
async function fit() {
await nextTick()
requestAnimationFrame(() => mm?.fit())
}
async function render() {
if (!svgRef.value)
return
const { root } = transformer.transform(stripMindmapNoise(props.markdown))
if (!mm)
mm = Markmap.create(svgRef.value, { autoFit: true }, root)
else
await mm.setData(root)
await fit()
}
async function toPngBlob(): Promise<Blob> {
await fit()
await nextTick()
if (!svgRef.value)
throw new Error('思维导图尚未渲染完成')
const svg = svgRef.value
const bbox = svg.getBBox()
const padding = 48
const x = Math.floor(bbox.x - padding)
const y = Math.floor(bbox.y - padding)
const width = Math.max(Math.ceil(bbox.width + padding * 2), 1)
const height = Math.max(Math.ceil(bbox.height + padding * 2), 1)
const cloned = sanitizeSvgForCanvas(svg)
cloned.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
cloned.setAttribute('width', String(width))
cloned.setAttribute('height', String(height))
cloned.setAttribute('viewBox', `${x} ${y} ${width} ${height}`)
cloned.insertAdjacentHTML('afterbegin', `<rect width="100%" height="100%" fill="#fff"/>`)
const svgText = new XMLSerializer().serializeToString(cloned)
const url = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }))
try {
const img = new Image()
img.decoding = 'async'
img.src = url
await img.decode()
// 不写死某个导出宽度:按导图内容和文字字号动态反推 PNG 倍率。
// 目标是让导出的正文至少有 MIN_EXPORT_FONT_PX 像素高,小图自动放大,
// 大图则按内容尺寸导出;同时限制最大边长,避免复杂导图撑爆内存。
const fontScale = MIN_EXPORT_FONT_PX / getExportFontSize(svg)
const widthScale = MIN_EXPORT_WIDTH / width
const rawScale = Math.max(window.devicePixelRatio || 1, fontScale, widthScale)
const sideLimitScale = Math.min(MAX_CANVAS_SIDE / width, MAX_CANVAS_SIDE / height)
const scale = Math.max(1, Math.min(rawScale, MAX_EXPORT_SCALE, sideLimitScale))
const canvas = document.createElement('canvas')
canvas.width = Math.ceil(width * scale)
canvas.height = Math.ceil(height * scale)
const ctx = canvas.getContext('2d')
if (!ctx)
throw new Error('当前浏览器不支持 Canvas 导出')
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.scale(scale, scale)
ctx.drawImage(img, 0, 0, width, height)
return await canvasToBlob(canvas)
}
finally {
URL.revokeObjectURL(url)
}
}
defineExpose({
toPngBlob,
})
onMounted(() => {
render()
if (wrapRef.value) {
resizeObserver = new ResizeObserver(() => fit())
resizeObserver.observe(wrapRef.value)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
resizeObserver = null
mm?.destroy()
mm = null
})
watch(() => props.markdown, render)
</script>
<template>
<div class="w-full h-full bg-white rounded border overflow-hidden">
<svg ref="svgRef" class="w-full h-full" />
<div ref="wrapRef" class="w-full h-full min-h-[360px] bg-white rounded border overflow-hidden">
<svg ref="svgRef" class="w-full h-full min-h-[360px]" />
</div>
</template>

View File

@@ -19,6 +19,7 @@ async function trigger() {
const res = await sendMessage('bilinote-start', {
url: window.location.href,
platform,
title: document.title,
}, 'background')
const ok = res && (res as any).ok
toast.value = ok

View File

@@ -0,0 +1,21 @@
import type { TaskRecord } from './types'
const SITE_SUFFIX_RE = /\s*[-_—|]\s*(哔哩哔哩|bilibili|youtube|抖音|douyin|快手|kuaishou)\s*$/i
export function normalizeVideoTitle(title: string | undefined | null): string | undefined {
const value = title?.trim()
if (!value)
return undefined
return value
.replace(SITE_SUFFIX_RE, '')
.trim() || value
}
export function getTaskDisplayTitle(task: TaskRecord | undefined | null, fallbackTitle?: string): string {
if (!task)
return normalizeVideoTitle(fallbackTitle) || ''
return normalizeVideoTitle((task.result?.audio_meta as { title?: string } | undefined)?.title)
|| normalizeVideoTitle(task.title)
|| normalizeVideoTitle(fallbackTitle)
|| task.videoUrl
}

View File

@@ -79,6 +79,8 @@ export interface TaskRecord {
createdAt: number
updatedAt: number
result?: NoteResult
// 从浏览器 tab.title 抓取,任务完成前用来替代 videoUrl 显示
title?: string
}
// 与 backend/app/gpt/prompt_builder.py note_styles 一一对齐

View File

@@ -5,6 +5,7 @@ import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/
import { generateNote, getTaskStatus, resolveImageUrl } from '~/logic/api'
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
import { NOTE_FORMATS, NOTE_STYLES, type NoteFormat, type TaskRecord } from '~/logic/types'
import { getTaskDisplayTitle, normalizeVideoTitle } from '~/logic/task-display'
const tabUrl = ref<string>('')
const tabTitle = ref<string>('')
@@ -43,6 +44,7 @@ async function poll(taskId: string) {
createdAt: activeTask.value?.createdAt ?? Date.now(),
updatedAt: Date.now(),
result: res.result ?? activeTask.value?.result,
title: activeTask.value?.title || normalizeVideoTitle(tabTitle.value),
})
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
pollTimer = setTimeout(() => poll(taskId), 3000)
@@ -94,6 +96,7 @@ async function start() {
message: '已提交',
createdAt: Date.now(),
updatedAt: Date.now(),
title: normalizeVideoTitle(tabTitle.value),
})
poll(task_id)
// 提交后顺手把侧边栏拉起来,免得用户来回切窗口
@@ -142,7 +145,7 @@ function selectTask(id: string) {
}
const activeCover = computed(() => activeTask.value?.result?.audio_meta?.cover_url as string | undefined)
const activeTitle = computed(() => (activeTask.value?.result?.audio_meta?.title as string | undefined) || tabTitle.value)
const activeTitle = computed(() => getTaskDisplayTitle(activeTask.value, tabTitle.value))
function fmtTime(ts?: number) {
if (!ts)
@@ -177,8 +180,8 @@ onUnmounted(() => {
<button class="text-xs text-gray-500 hover:text-gray-800" @click="openOptions">设置</button>
</header>
<div class="text-xs text-gray-500 truncate" :title="tabUrl">
{{ tabUrl || '当前没有打开的标签页' }}
<div class="text-xs text-gray-500 truncate" :title="normalizeVideoTitle(tabTitle) || tabUrl">
{{ normalizeVideoTitle(tabTitle) || tabUrl || '当前没有打开的标签页' }}
</div>
<div v-if="!supported" class="text-xs text-amber-700 bg-amber-50 p-2 rounded">
@@ -331,8 +334,8 @@ onUnmounted(() => {
:class="{ 'bg-blue-50': t.taskId === activeTaskId }"
@click="selectTask(t.taskId)"
>
<span class="truncate flex-1" :title="t.videoUrl">
{{ (t.result?.audio_meta as { title?: string } | undefined)?.title || t.videoUrl }}
<span class="truncate flex-1" :title="getTaskDisplayTitle(t)">
{{ getTaskDisplayTitle(t) }}
</span>
<span class="text-gray-500 shrink-0">{{ t.status }}</span>
</li>

View File

@@ -3,14 +3,17 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { getTaskStatus, resolveImageUrl } from '~/logic/api'
import { tasks, tasksReady, settingsReady, upsertTask } from '~/logic/storage'
import type { TaskRecord } from '~/logic/types'
import { getTaskDisplayTitle } from '~/logic/task-display'
type ViewMode = 'markdown' | 'mindmap' | 'chat'
const activeTaskId = ref<string>('')
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
const errorMsg = ref('')
const successMsg = ref('')
const viewMode = ref<ViewMode>('markdown')
const showHistory = ref(false)
const mindMapRef = ref<{ toPngBlob: () => Promise<Blob> } | null>(null)
const isDone = computed(() => activeTask.value?.status === 'SUCCESS')
const isFailed = computed(() => activeTask.value?.status === 'FAILED')
@@ -41,6 +44,7 @@ async function poll(taskId: string) {
message: res.message,
result: res.result ?? cur.result,
updatedAt: Date.now(),
title: cur.title || getTaskDisplayTitle(cur),
})
}
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
@@ -74,11 +78,19 @@ async function copyMarkdown() {
await navigator.clipboard.writeText(md)
}
function safeFilename(name: string): string {
return (name || 'bilinote')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120) || 'bilinote'
}
function downloadMarkdown() {
const md = activeTask.value?.result?.markdown
if (!md)
return
const title = (activeTask.value?.result?.audio_meta as { title?: string } | undefined)?.title || 'bilinote'
const title = safeFilename(getTaskDisplayTitle(activeTask.value))
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
@@ -88,8 +100,44 @@ function downloadMarkdown() {
URL.revokeObjectURL(url)
}
const activeTitle = computed(() =>
(activeTask.value?.result?.audio_meta as { title?: string } | undefined)?.title || activeTask.value?.videoUrl || '')
async function copyMindMapImage() {
try {
errorMsg.value = ''
successMsg.value = ''
const blob = await mindMapRef.value?.toPngBlob()
if (!blob)
return
await navigator.clipboard.write([
new ClipboardItem({ [blob.type]: blob }),
])
successMsg.value = '思维导图图片已复制'
setTimeout(() => { successMsg.value = '' }, 2000)
}
catch (e) {
errorMsg.value = (e as Error).message || '复制思维导图图片失败'
}
}
async function downloadMindMapImage() {
try {
errorMsg.value = ''
successMsg.value = ''
const blob = await mindMapRef.value?.toPngBlob()
if (!blob)
return
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${safeFilename(getTaskDisplayTitle(activeTask.value))}.png`
a.click()
URL.revokeObjectURL(url)
}
catch (e) {
errorMsg.value = (e as Error).message || '下载思维导图图片失败'
}
}
const activeTitle = computed(() => getTaskDisplayTitle(activeTask.value))
const activeCover = computed(() =>
(activeTask.value?.result?.audio_meta as { cover_url?: string } | undefined)?.cover_url)
@@ -140,8 +188,8 @@ onUnmounted(() => {
:class="{ 'bg-white border': t.taskId === activeTaskId }"
@click="selectTask(t.taskId)"
>
<span class="truncate flex-1" :title="t.videoUrl">
{{ (t.result?.audio_meta as { title?: string } | undefined)?.title || t.videoUrl }}
<span class="truncate flex-1" :title="getTaskDisplayTitle(t)">
{{ getTaskDisplayTitle(t) }}
</span>
<span class="text-gray-400 shrink-0">{{ STAGE_LABELS[t.status] || t.status }}</span>
</li>
@@ -151,6 +199,9 @@ onUnmounted(() => {
<div v-if="errorMsg" class="text-xs text-red-600 px-3 py-1 break-words bg-red-50 shrink-0">
{{ errorMsg }}
</div>
<div v-if="successMsg" class="text-xs text-green-700 px-3 py-1 break-words bg-green-50 shrink-0">
{{ successMsg }}
</div>
<section v-if="!activeTask" class="flex-1 flex items-center justify-center text-gray-400 text-xs px-4 text-center">
还没有任务在视频页点悬浮按钮 popup 提交或右键菜单选 BiliNote 总结
@@ -170,7 +221,7 @@ onUnmounted(() => {
class="text-sm font-medium leading-tight line-clamp-1 break-all flex-1 min-w-0 hover:text-blue-600"
:href="activeTask.videoUrl"
target="_blank"
:title="activeTask.videoUrl"
:title="activeTitle || activeTask.videoUrl"
>{{ activeTitle }}</a>
<span
v-if="isDone"
@@ -224,6 +275,18 @@ onUnmounted(() => {
title="下载 .md"
@click="downloadMarkdown"
>下载</button>
<button
v-if="viewMode === 'mindmap'"
class="text-gray-500 hover:text-gray-800 px-1.5 py-1 rounded hover:bg-gray-100"
title="复制思维导图图片"
@click="copyMindMapImage"
>复制</button>
<button
v-if="viewMode === 'mindmap'"
class="text-gray-500 hover:text-gray-800 px-1.5 py-1 rounded hover:bg-gray-100"
title="下载思维导图 PNG"
@click="downloadMindMapImage"
>下载</button>
</div>
<!-- 内容区占满剩余空间 -->
@@ -236,6 +299,7 @@ onUnmounted(() => {
/>
<MindMap
v-else-if="isDone && activeTask.result?.markdown && viewMode === 'mindmap'"
ref="mindMapRef"
:markdown="activeTask.result.markdown"
class="h-full"
/>

View File

@@ -1,6 +1,13 @@
# === 前端构建阶段 ===
# Tailwind v4 / Vite 6 需要 Node 20+alpine + pnpm 会按 lockfile 拉 musl native binary。
FROM node:20-alpine AS builder
# BASE_REGISTRY 默认 docker.io国内拉不到可换 daocloud / 阿里云镜像:
# docker-compose build --build-arg BASE_REGISTRY=docker.m.daocloud.io
ARG BASE_REGISTRY=docker.io
FROM ${BASE_REGISTRY}/library/node:20-alpine AS builder
# 可由发布 workflow 从 git tag 注入,用于前端 About 页展示版本;未传时由 Vite 回退读取 tauri.conf.json。
ARG VITE_APP_VERSION=
ENV VITE_APP_VERSION=${VITE_APP_VERSION}
# pnpm pin 到 9.xlockfile 是 v9 生成pnpm 11 要求 Node 22+ 与 node:20 不兼容
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
@@ -16,7 +23,9 @@ COPY ./BillNote_frontend/ ./
RUN pnpm run build
# --- 阶段2使用 nginx 作为静态服务器 ---
FROM nginx:1.25-alpine
# 重新声明 ARG —— buildkit 跨阶段不自动继承
ARG BASE_REGISTRY=docker.io
FROM ${BASE_REGISTRY}/library/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

View File

@@ -25,8 +25,8 @@
"@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.1.3",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-shell": "~2.2.2",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-shell": "~2.3.5",
"@uiw/react-markdown-preview": "^5.1.3",
"antd": "^5.24.8",
"axios": "^1.8.4",
@@ -57,6 +57,7 @@
"react-router-dom": "^7.5.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^6.0.2",
"rehype-slug": "5.1.0",
"remark-gfm": "3.0.1",
"remark-math": "^5.1.1",
"sonner": "^2.0.3",

View File

@@ -13,7 +13,7 @@ importers:
version: 2.4.0(antd@5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@hookform/resolvers':
specifier: ^5.0.1
version: 5.2.2(react-hook-form@7.72.0(react@19.2.4))
version: 5.4.0(react-hook-form@7.72.0(react@19.2.4))
'@lobehub/icons':
specifier: ^1.97.1
version: 1.98.0(@babel/core@7.29.0)(@types/mdast@4.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(antd@5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -52,16 +52,16 @@ importers:
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tailwindcss/vite':
specifier: ^4.1.3
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
version: 4.2.2(vite@6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
'@tauri-apps/api':
specifier: ^2.10.1
version: 2.10.1
specifier: ^2.11.0
version: 2.11.0
'@tauri-apps/plugin-shell':
specifier: ~2.2.2
version: 2.2.2
specifier: ~2.3.5
version: 2.3.5
'@uiw/react-markdown-preview':
specifier: ^5.1.3
version: 5.1.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 5.2.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
antd:
specifier: ^5.24.8
version: 5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -76,13 +76,13 @@ importers:
version: 2.1.1
fuse.js:
specifier: ^7.1.0
version: 7.1.0
version: 7.3.0
github-markdown-css:
specifier: ^5.8.1
version: 5.9.0
idb-keyval:
specifier: ^6.2.2
version: 6.2.2
version: 6.2.4
jszip:
specifier: ^3.10.1
version: 3.10.1
@@ -149,6 +149,9 @@ importers:
rehype-katex:
specifier: ^6.0.2
version: 6.0.3
rehype-slug:
specifier: 5.1.0
version: 5.1.0
remark-gfm:
specifier: 3.0.1
version: 3.0.1
@@ -169,7 +172,7 @@ importers:
version: 1.4.0
uuid:
specifier: ^11.1.0
version: 11.1.0
version: 11.1.1
zod:
specifier: ^3.24.2
version: 3.25.76
@@ -197,7 +200,7 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.7.0(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
version: 4.7.0(vite@6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
autoprefixer:
specifier: ^10.4.21
version: 10.4.27(postcss@8.5.8)
@@ -227,7 +230,7 @@ importers:
version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.7.3)
vite:
specifier: ^6.2.0
version: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
version: 6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
packages:
@@ -732,8 +735,8 @@ packages:
react: ^16 || ^17 || ^18 || ^19
react-dom: ^16 || ^17 || ^18 || ^19
'@hookform/resolvers@5.2.2':
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
'@hookform/resolvers@5.4.0':
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
peerDependencies:
react-hook-form: ^7.55.0
@@ -1590,8 +1593,8 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
'@tauri-apps/api@2.10.1':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
'@tauri-apps/api@2.11.0':
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
'@tauri-apps/cli-darwin-arm64@2.10.1':
resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==}
@@ -1669,8 +1672,8 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-shell@2.2.2':
resolution: {integrity: sha512-fg9XKWfzRQsN8p+Zrk82WeHvXFvGVnG0/mTlujQdLWNnO5cM6WD9qCrHbFytScVS+WhmRAkuypQPcxeKKl3VBg==}
'@tauri-apps/plugin-shell@2.3.5':
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -1786,6 +1789,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
@@ -1910,8 +1916,8 @@ packages:
'@uiw/copy-to-clipboard@1.0.20':
resolution: {integrity: sha512-IFQhS62CLNon1YgYJTEzXR2N3WVXg7V1FaBRDLMlzU6JY5X6Hr3OPAcw4WNoKcz2XcFD6XCgwEjlsmj+JA0mWA==}
'@uiw/react-markdown-preview@5.1.5':
resolution: {integrity: sha512-DNOqx1a6gJR7Btt57zpGEKTfHRlb7rWbtctMRO2f82wWcuoJsxPBrM+JWebDdOD0LfD8oe2CQvW2ICQJKHQhZg==}
'@uiw/react-markdown-preview@5.2.1':
resolution: {integrity: sha512-JjvcHveT6glhlJYJx1XGBZij6wkw+VwREV6Z6m/GpsjPPdLjF1x8nlPBSB/ATyUF4lD7C8ttMkCqVH9N9XMgEA==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
@@ -1919,6 +1925,9 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
'@ungap/structured-clone@1.3.1':
resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
'@upsetjs/venn.js@2.0.0':
resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
@@ -2689,8 +2698,8 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
fuse.js@7.1.0:
resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==}
fuse.js@7.3.0:
resolution: {integrity: sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==}
engines: {node: '>=10'}
gensync@1.0.0-beta.2:
@@ -2794,9 +2803,15 @@ packages:
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-has-property@2.0.1:
resolution: {integrity: sha512-X2+RwZIMTMKpXUzlotatPzWj8bspCymtXH3cfG3iQKV+wPF53Vgaqxi/eLqGck0wKq1kS9nvoB1wchbCPEL8sg==}
hast-util-has-property@3.0.0:
resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==}
hast-util-heading-rank@2.1.1:
resolution: {integrity: sha512-iAuRp+ESgJoRFJbSyaqsfvJDY6zzmFoEnL1gtz1+U8gKtGGj1p0CVlysuUAUjq95qlZESHINLThwJzNGmgGZxA==}
hast-util-heading-rank@3.0.0:
resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==}
@@ -2833,6 +2848,9 @@ packages:
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
hast-util-to-string@2.0.0:
resolution: {integrity: sha512-02AQ3vLhuH3FisaMM+i/9sm4OXGSq1UhOOCpTLLQtHdL3tZt7qil69r8M8iDkZYyC0HCFylcYoP+8IO7ddta1A==}
hast-util-to-string@3.0.1:
resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==}
@@ -2883,8 +2901,8 @@ packages:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
idb-keyval@6.2.2:
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
idb-keyval@6.2.4:
resolution: {integrity: sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
@@ -4228,12 +4246,6 @@ packages:
'@types/react': '>=16'
react: '>=16'
react-markdown@9.0.3:
resolution: {integrity: sha512-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw==}
peerDependencies:
'@types/react': '>=18'
react: '>=18'
react-medium-image-zoom@5.4.1:
resolution: {integrity: sha512-DD2iZYaCfAwiQGR8AN62r/cDJYoXhezlYJc5HY4TzBUGuGge43CptG0f7m0PEIM72aN6GfpjohvY1yYdtCJB7g==}
peerDependencies:
@@ -4372,8 +4384,8 @@ packages:
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
rehype-attr@3.0.3:
resolution: {integrity: sha512-Up50Xfra8tyxnkJdCzLBIBtxOcB2M1xdeKe1324U06RAvSjYm7ULSeoM+b/nYPQPVd7jsXJ9+39IG1WAJPXONw==}
rehype-attr@4.0.0:
resolution: {integrity: sha512-tANn9EmhG4mEZlNdDDRKuS0OXPDvc6P6OjJ1yApzOjIdCvKNLiuU2HdMSLTpiVi3D/FyLK6B+ZZ8PYtRxiGg7Q==}
engines: {node: '>=16'}
rehype-autolink-headings@7.1.0:
@@ -4408,6 +4420,9 @@ packages:
resolution: {integrity: sha512-L/FO96EOzSA6bzOam4DVu61/PB3AGKcSPXpa53yMIozoxH4qg1+bVZDF8zh1EsuxtSauAhzt5cCnvoplAaSLrw==}
engines: {node: '>=16.0.0'}
rehype-slug@5.1.0:
resolution: {integrity: sha512-Gf91dJoXneiorNEnn+Phx97CO7oRMrpi+6r155tTxzGuLtm+QrI4cTwCa9e1rtePdL4i9tSO58PeSS6HWfgsiw==}
rehype-slug@6.0.0:
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
@@ -4822,12 +4837,12 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
uuid@11.1.1:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
uuid@13.0.0:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
uuid@13.0.2:
resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==}
hasBin: true
uvu@0.5.6:
@@ -4856,8 +4871,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@6.4.1:
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
vite@6.4.2:
resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
@@ -5534,7 +5549,7 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@hookform/resolvers@5.2.2(react-hook-form@7.72.0(react@19.2.4))':
'@hookform/resolvers@5.4.0(react-hook-form@7.72.0(react@19.2.4))':
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.72.0(react@19.2.4)
@@ -5725,7 +5740,7 @@ snapshots:
unified: 11.0.5
url-join: 5.0.0
use-merge-value: 1.2.0(react@19.2.4)
uuid: 13.0.0
uuid: 13.0.2
transitivePeerDependencies:
- '@babel/core'
- '@types/mdast'
@@ -6456,14 +6471,14 @@ snapshots:
postcss: 8.5.8
tailwindcss: 4.2.2
'@tailwindcss/vite@4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))':
'@tailwindcss/vite@4.2.2(vite@6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))':
dependencies:
'@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 4.2.2
tailwindcss: 4.2.2
vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
vite: 6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
'@tauri-apps/api@2.10.1': {}
'@tauri-apps/api@2.11.0': {}
'@tauri-apps/cli-darwin-arm64@2.10.1':
optional: true
@@ -6512,9 +6527,9 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
'@tauri-apps/plugin-shell@2.2.2':
'@tauri-apps/plugin-shell@2.3.5':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/api': 2.11.0
'@types/babel__core@7.20.5':
dependencies:
@@ -6664,6 +6679,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/estree@1.0.9': {}
'@types/geojson@7946.0.16': {}
'@types/hast@2.3.10':
@@ -6811,14 +6828,14 @@ snapshots:
'@uiw/copy-to-clipboard@1.0.20': {}
'@uiw/react-markdown-preview@5.1.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
'@uiw/react-markdown-preview@5.2.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.29.2
'@uiw/copy-to-clipboard': 1.0.20
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-markdown: 9.0.3(@types/react@19.2.14)(react@19.2.4)
rehype-attr: 3.0.3
react-markdown: 10.1.0(@types/react@19.2.14)(react@19.2.4)
rehype-attr: 4.0.0
rehype-autolink-headings: 7.1.0
rehype-ignore: 2.0.3
rehype-prism-plus: 2.0.0
@@ -6834,6 +6851,8 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@ungap/structured-clone@1.3.1': {}
'@upsetjs/venn.js@2.0.0':
optionalDependencies:
d3-selection: 3.0.0
@@ -6846,7 +6865,7 @@ snapshots:
'@use-gesture/core': 10.3.1
react: 19.2.4
'@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))':
'@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -6854,7 +6873,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
vite: 6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
@@ -7630,7 +7649,7 @@ snapshots:
estree-util-attach-comments@3.0.0:
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
estree-util-build-jsx@3.0.1:
dependencies:
@@ -7742,7 +7761,7 @@ snapshots:
function-bind@1.1.2: {}
fuse.js@7.1.0: {}
fuse.js@7.3.0: {}
gensync@1.0.0-beta.2: {}
@@ -7871,10 +7890,16 @@ snapshots:
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-has-property@2.0.1: {}
hast-util-has-property@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-heading-rank@2.1.1:
dependencies:
'@types/hast': 2.3.10
hast-util-heading-rank@3.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -7934,7 +7959,7 @@ snapshots:
hast-util-to-estree@3.1.3:
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
@@ -7969,7 +7994,7 @@ snapshots:
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
'@types/hast': 3.0.4
'@types/unist': 3.0.3
comma-separated-tokens: 2.0.3
@@ -7997,6 +8022,10 @@ snapshots:
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-to-string@2.0.0:
dependencies:
'@types/hast': 2.3.10
hast-util-to-string@3.0.1:
dependencies:
'@types/hast': 3.0.4
@@ -8070,7 +8099,7 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
idb-keyval@6.2.2: {}
idb-keyval@6.2.4: {}
ignore@5.3.2: {}
@@ -8690,7 +8719,7 @@ snapshots:
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@ungap/structured-clone': 1.3.0
'@ungap/structured-clone': 1.3.1
devlop: 1.1.0
micromark-util-sanitize-uri: 2.0.1
trim-lines: 3.0.1
@@ -8760,7 +8789,7 @@ snapshots:
roughjs: 4.6.6
stylis: 4.3.6
ts-dedent: 2.2.0
uuid: 11.1.0
uuid: 11.1.1
micromark-core-commonmark@1.1.0:
dependencies:
@@ -8957,7 +8986,7 @@ snapshots:
micromark-extension-mdx-expression@3.0.1:
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
devlop: 1.1.0
micromark-factory-mdx-expression: 2.0.3
micromark-factory-space: 2.0.1
@@ -8968,7 +8997,7 @@ snapshots:
micromark-extension-mdx-jsx@3.0.2:
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
devlop: 1.1.0
estree-util-is-identifier-name: 3.0.0
micromark-factory-mdx-expression: 2.0.3
@@ -8985,7 +9014,7 @@ snapshots:
micromark-extension-mdxjs-esm@3.0.0:
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
micromark-util-character: 2.1.1
@@ -9034,7 +9063,7 @@ snapshots:
micromark-factory-mdx-expression@2.0.3:
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
@@ -9150,7 +9179,7 @@ snapshots:
micromark-util-events-to-acorn@2.0.3:
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
'@types/unist': 3.0.3
devlop: 1.1.0
estree-util-visit: 2.0.0
@@ -9936,23 +9965,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
react-markdown@9.0.3(@types/react@19.2.14)(react@19.2.4):
dependencies:
'@types/hast': 3.0.4
'@types/react': 19.2.14
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 19.2.4
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.1.0
vfile: 6.0.3
transitivePeerDependencies:
- supports-color
react-medium-image-zoom@5.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
@@ -10078,7 +10090,7 @@ snapshots:
recma-parse@1.0.0:
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
esast-util-from-js: 2.0.1
unified: 11.0.5
vfile: 6.0.3
@@ -10120,7 +10132,7 @@ snapshots:
dependencies:
regex-utilities: 2.3.0
rehype-attr@3.0.3:
rehype-attr@4.0.0:
dependencies:
unified: 11.0.5
unist-util-visit: 5.0.0
@@ -10201,6 +10213,16 @@ snapshots:
unified: 11.0.5
unist-util-visit: 5.1.0
rehype-slug@5.1.0:
dependencies:
'@types/hast': 2.3.10
github-slugger: 2.0.0
hast-util-has-property: 2.0.1
hast-util-heading-rank: 2.1.1
hast-util-to-string: 2.0.0
unified: 10.1.2
unist-util-visit: 4.1.2
rehype-slug@6.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -10696,9 +10718,9 @@ snapshots:
util-deprecate@1.0.2: {}
uuid@11.1.0: {}
uuid@11.1.1: {}
uuid@13.0.0: {}
uuid@13.0.2: {}
uvu@0.5.6:
dependencies:
@@ -10741,7 +10763,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3):
vite@6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)

File diff suppressed because it is too large Load Diff

View File

@@ -15,14 +15,16 @@ name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.2.0", features = [] }
# tauri-build / tauri crate 与 @tauri-apps/api 大版本必须对齐CLI 在 build 前会校验)。
# @tauri-apps/api 已升 2.10commit bb9a70e这里同步到 2.x 最新让 cargo 解析到匹配版本。
tauri-build = { version = "2", 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 = { version = "2", features = ["devtools"] }
tauri-plugin-log = "2"
tauri-plugin-shell = "2"
[package.metadata.tauri.bundle.macOS]

View File

@@ -3,10 +3,19 @@ use tauri_plugin_shell::ShellExt;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use std::env;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::path::Path;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::Serialize;
// Sidecar 启动期内前端不该看到「加载中」无限转。
// 总等待上限 = 启动期 PyInstaller 解压 + uvicorn bind 时间的最坏估计,
// 实测 macOS / Windows 慢盘大概 5-20s设 45s 留余量但不至于让用户绝望。
const BACKEND_STARTUP_TIMEOUT_SECS: u64 = 45;
const BACKEND_DEFAULT_PORT: u16 = 8483;
// Sidecar 子进程句柄,用 Mutex 包裹方便 restart 时杀旧进程
struct SidecarHandle(Mutex<Option<CommandChild>>);
@@ -50,6 +59,10 @@ pub fn run() {
})?;
app.manage(SidecarHandle(Mutex::new(Some(child))));
// 启动 ready probe异步轮询本地 BACKEND_PORT 是否在监听,
// 解决前端 useCheckBackend 在 PyInstaller 解压期瞎猜后端起没起的问题。
spawn_backend_ready_probe(app.handle().clone());
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -60,8 +73,33 @@ pub fn run() {
get_install_path_diagnostics,
restart_backend_sidecar
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while building tauri application")
// 用 build()+run() 拿到 RunEvent 流关键诉求app 退出前必须 kill 掉 PyInstaller
// sidecar否则它会变成持有 8483 端口的孤儿进程,下次启动 BiliNote 直接 bind 失败。
// 之前漏掉这一步导致用户 PID 96739 那种「上次没关干净 → 这次起不来」的死循环。
.run(|app_handle, event| {
match event {
// ExitRequested 在用户 Cmd-Q / 点关闭 / Dock 退出时触发,先于实际进程结束。
// Exit 是兜底——任何走到 Tauri 主循环结束的路径都会经过它。
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => {
kill_backend_sidecar(app_handle);
}
_ => {}
}
});
}
// 关闭期统一杀 sidecartake() 把 child 从 state 拿走避免重复 kill。
fn kill_backend_sidecar(app_handle: &tauri::AppHandle) {
if let Some(state) = app_handle.try_state::<SidecarHandle>() {
if let Ok(mut guard) = state.0.lock() {
if let Some(child) = guard.take() {
eprintln!("[shutdown] killing backend sidecar before app exit");
let _ = child.kill();
}
}
}
}
// 获取额外的二进制路径
@@ -306,6 +344,12 @@ fn restart_backend_sidecar(
state: State<'_, SidecarHandle>,
app: tauri::AppHandle,
) -> Result<(), String> {
// 0. 先告诉前端「我们要重启了」。前端可以借此忽略接下来 N 秒内的 backend-terminated
// 事件——那是我们主动 kill 老 sidecar 的副作用,不是真异常。否则会出现:
// terminated 事件延迟到达 → 覆盖掉 'running' 状态 → 面板永远显示「已退出」。
if let Some(window) = app.get_webview_window("main") {
let _ = window.emit("backend-restarting", ());
}
// 1. 拿出旧 child 并 killkill 失败也继续,可能进程已经退了)
{
let mut guard = state.0.lock().map_err(|e| format!("锁 sidecar state 失败: {}", e))?;
@@ -323,9 +367,88 @@ fn restart_backend_sidecar(
if let Some(window) = app.get_webview_window("main") {
let _ = window.emit("backend-restarted", ());
}
// 4. 重启后同样起一次 ready probe让前端能及时退出失败态
spawn_backend_ready_probe(app);
Ok(())
}
// 后端就绪探测:异步轮询 GET /api/sys_check要求 HTTP 200 才算就绪。
//
// 旧实现只做 TcpStream::connect_timeout——但端口被另一个孤儿 sidecar 占着时也会
// 连得通,导致 emit('backend-ready') 误判:前端进入主界面,但真正的新 sidecar
// 没 bind 上立刻就死banner 永远停在「后端进程已退出」。
//
// 真发一个 HTTP 请求拿 200 才算「这是我们的后端在响应」。
fn spawn_backend_ready_probe(app: tauri::AppHandle) {
let port: u16 = env::var("BACKEND_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(BACKEND_DEFAULT_PORT);
let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().expect("invalid backend addr");
let timeout = Duration::from_secs(BACKEND_STARTUP_TIMEOUT_SECS);
std::thread::spawn(move || {
let start = Instant::now();
let probe_interval = Duration::from_millis(500);
loop {
if probe_sys_check(&addr) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.emit("backend-ready", port);
println!("Backend ready on port {} after {:?}", port, start.elapsed());
}
return;
}
if start.elapsed() >= timeout {
if let Some(window) = app.get_webview_window("main") {
let payload = format!(
"后端在 {}s 内 /api/sys_check 未返回 200疑似启动失败或端口 {} 被其他进程占用",
timeout.as_secs(),
port
);
let _ = window.emit("backend-startup-timeout", payload);
eprintln!(
"Backend startup timeout: /api/sys_check did not return 200 on 127.0.0.1:{} after {:?}",
port,
start.elapsed()
);
}
return;
}
std::thread::sleep(probe_interval);
}
});
}
// 极简 HTTP/1.0 GET /api/sys_check —— 用 std::net 手写避免引 reqwest/ureq 的重依赖。
// 任何错都视为「还没就绪」,下次 tick 再试。
fn probe_sys_check(addr: &SocketAddr) -> bool {
let connect_timeout = Duration::from_millis(800);
let rw_timeout = Duration::from_millis(1500);
let mut stream = match TcpStream::connect_timeout(addr, connect_timeout) {
Ok(s) => s,
Err(_) => return false,
};
let _ = stream.set_read_timeout(Some(rw_timeout));
let _ = stream.set_write_timeout(Some(rw_timeout));
// HTTP/1.0 + Connection: close 让服务端发完响应就关,免去 chunked / keep-alive 解析
let req = format!(
"GET /api/sys_check HTTP/1.0\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n",
addr.port()
);
if stream.write_all(req.as_bytes()).is_err() {
return false;
}
// 只要 status line64 字节够了
let mut buf = [0u8; 64];
let n = match stream.read(&mut buf) {
Ok(n) => n,
Err(_) => return false,
};
let head = std::str::from_utf8(&buf[..n]).unwrap_or("");
// 兼容 HTTP/1.0 / 1.1 起始行
head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
}
// 安装路径诊断PyInstaller 在含非 ASCII / 空格的路径下加载 _internal/* 经常炸;
// 父目录不可写时模型 / 配置 / 日志也无法落盘
#[derive(Serialize, Clone)]

View File

@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "BiliNote",
"version": "2.0.0",
"version": "2.4.0",
"identifier": "com.jefferyhuang.bilinote",
"build": {
"frontendDist": "../dist",

View File

@@ -1,6 +1,6 @@
import './App.css'
import { lazy, Suspense, useEffect } from 'react'
import { BrowserRouter, Navigate, Routes, Route } from 'react-router-dom'
import { BrowserRouter, HashRouter, Navigate, Routes, Route } from 'react-router-dom'
import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
import { useCheckBackend } from '@/hooks/useCheckBackend.ts'
import { systemCheck } from '@/services/system.ts'
@@ -33,7 +33,7 @@ const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
function App() {
useTaskPolling(3000) // 每 3 秒轮询一次
const { loading, initialized } = useCheckBackend()
const { loading, initialized, failed, lastError, retry } = useCheckBackend()
// 在后端初始化完成后执行系统检查
useEffect(() => {
@@ -42,22 +42,31 @@ function App() {
}
}, [initialized])
// 如果后端还未初始化,显示初始化对话框
// 如果后端还未初始化,显示初始化对话框loading 或 failed 都展示,由 dialog 内部决定渲染哪一态)
if (!initialized) {
return (
<>
<StartupBanner />
<BackendInitDialog open={loading} />
<BackendInitDialog
open={loading}
failed={failed}
lastError={lastError}
onRetry={retry}
/>
</>
)
}
// 桌面端使用 HashRouter 避免刷新 404Web 端继续使用 BrowserRouter
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
const Router = isTauri ? HashRouter : BrowserRouter
// 后端已初始化,渲染主应用
return (
<>
<StartupBanner />
<BackendHealthIndicator />
<BrowserRouter>
<Router>
<Suspense fallback={<div className="flex h-screen items-center justify-center"></div>}>
<Routes>
<Route path="/onboarding" element={<Onboarding />} />
@@ -81,7 +90,7 @@ function App() {
</Route>
</Routes>
</Suspense>
</BrowserRouter>
</Router>
</>
)
}

View File

@@ -10,12 +10,14 @@ import BackendLogPanel from './BackendLogPanel'
type Health = 'green' | 'yellow' | 'red' | 'unknown'
const HEALTH_POLL_MS = 5000
const SYS_HEALTH_PATH = '/api/sys_health'
// 路径不带 /api/,因为 backendBase() 已经把它包进 baseURL 了(同 axios 实例的语义)。
// 之前写 '/api/sys_health' + base='http://host/api' = 双 /api → 一直 404。
const SYS_HEALTH_PATH = '/sys_health'
function backendBase(): string {
// 与 services/request.ts 用的一致
// 与 utils/request.ts 的 baseURL 计算保持一致env 没设走 '/api' 兜底。
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
return (fromEnv ?? '').replace(/\/$/, '')
return ((fromEnv && fromEnv.length > 0) ? fromEnv : '/api').replace(/\/$/, '')
}
const BackendHealthIndicator = () => {

View File

@@ -35,6 +35,9 @@ export function useBackendEvents(): BackendEvents {
const [logs, setLogs] = useState<LogEntry[]>([])
// 用 ref 持有最新 logs 数组append 时不被闭包陷阱卡到旧值
const logsRef = useRef<LogEntry[]>([])
// 主动重启期Rust 在 kill 老 sidecar 前会 emit 'backend-restarting'。
// 期间到达的 'backend-terminated' 是我们自己造成的,不要污染状态。
const ignoreNextTerminatedRef = useRef(false)
function append(entry: LogEntry) {
const next = logsRef.current.concat(entry)
@@ -58,7 +61,23 @@ export function useBackendEvents(): BackendEvents {
const offErr = await listen<string>('backend-error', event => {
append({ level: 'error', text: stripQuotes(event.payload), ts: Date.now() })
})
const offRestarting = await listen('backend-restarting', () => {
// 紧接着到达的 terminated 是我们主动 kill 老 sidecar 引发的,跳过 3s
ignoreNextTerminatedRef.current = true
setTimeout(() => { ignoreNextTerminatedRef.current = false }, 3000)
append({ level: 'info', text: '[Backend restarting]', ts: Date.now() })
})
const offTerm = await listen<number | null>('backend-terminated', event => {
// 主动重启窗口内的 terminated 是预期副作用,仅记日志、不改状态
if (ignoreNextTerminatedRef.current) {
ignoreNextTerminatedRef.current = false
append({
level: 'info',
text: `[Backend terminated, restart in progress] code=${event.payload ?? 'unknown'}`,
ts: Date.now(),
})
return
}
setStatus('terminated')
setExitCode(event.payload ?? null)
append({
@@ -73,7 +92,7 @@ export function useBackendEvents(): BackendEvents {
append({ level: 'info', text: '[Backend restarted]', ts: Date.now() })
})
unlisteners = [offMsg, offErr, offTerm, offRestart]
unlisteners = [offMsg, offErr, offRestarting, offTerm, offRestart]
})()
return () => {

View File

@@ -1,13 +1,141 @@
import { useMemo, useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Loader2, AlertTriangle, RotateCcw, Clipboard } from 'lucide-react'
import { useBackendEvents } from '@/components/BackendHealth/useBackendEvents'
// 失败态预览里最多展示几行 stderr。比这还多就请用户去 copyLogs() 拷出来。
const STDERR_PREVIEW_LINES = 6
interface Props {
/** 加载中:显示转圈对话框 */
open: boolean
/** 启动失败:显示错误 + 重启/复制日志按钮 */
failed?: boolean
/** 失败原因(来自 useCheckBackend.lastError 或 Tauri 事件 payload */
lastError?: string | null
/** 重新走一遍 useCheckBackend 的轮询(不重启 sidecar */
onRetry?: () => void
}
function BackendInitDialog({ open }: Props) {
// 加载中 + 启动失败两个状态合并在一个 dialog 里。
// 失败态比加载态更紧急:用户能看到具体原因 + 一键重启 + 一键复制日志去 issue
// 而不是面对一个永远转圈的对话框。
function BackendInitDialog({ open, failed = false, lastError = null, onRetry }: Props) {
const { isTauri, restart, copyLogs, logs } = useBackendEvents()
const [restarting, setRestarting] = useState(false)
const [copyResult, setCopyResult] = useState<'idle' | 'ok' | 'fail'>('idle')
// 从 ring buffer 里挑最后几行 stderr —— 它们比 lastErrorhook 自己总结的那句)信息密度更高,
// 通常 Python traceback 的最后一行就是真正的错误类型 + 消息
const stderrPreview = useMemo(() => {
if (!failed || !logs?.length) return []
return logs
.filter((l) => l.level === 'error')
.slice(-STDERR_PREVIEW_LINES)
.map((l) => l.text)
}, [failed, logs])
// 任一态需要展示就保持 dialog 开着,关掉只在两个 flag 都熄灭时发生
const isOpen = open || failed
const handleRestart = async () => {
setRestarting(true)
try {
if (isTauri) await restart()
onRetry?.()
} catch {
// restart 内部已经 append 到 log这里不再 toast
} finally {
setRestarting(false)
}
}
const handleCopy = async () => {
const ok = await copyLogs()
setCopyResult(ok ? 'ok' : 'fail')
setTimeout(() => setCopyResult('idle'), 2000)
}
if (failed) {
return (
<Dialog open={isOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="w-5 h-5" />
</DialogTitle>
</DialogHeader>
<div className="space-y-3 mt-2 text-sm">
<p className="text-muted-foreground">
{lastError || '后端在预计时间内未就绪。'}
</p>
{stderrPreview.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
{stderrPreview.length}
<span className="opacity-60"></span>:
</p>
<pre className="max-h-32 overflow-auto rounded bg-zinc-900 px-2 py-1.5 font-mono text-[11px] leading-snug text-red-200">
{stderrPreview.join('\n')}
</pre>
</div>
)}
<div className="text-xs text-muted-foreground space-y-1">
<p></p>
<ul className="list-disc list-inside space-y-0.5 pl-1">
<li> / PyInstaller </li>
<li> ffmpeg / 8483 </li>
<li> whisper </li>
</ul>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<Button
size="sm"
onClick={handleRestart}
disabled={restarting}
className="gap-1.5"
>
{restarting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RotateCcw className="w-4 h-4" />
)}
{isTauri ? (restarting ? '重启中…' : '重启后端') : '重试'}
</Button>
{isTauri && (
<Button size="sm" variant="outline" onClick={handleCopy} className="gap-1.5">
<Clipboard className="w-4 h-4" />
{copyResult === 'ok'
? '已复制 ✓'
: copyResult === 'fail'
? '复制失败'
: '复制启动日志'}
</Button>
)}
</div>
<p className="text-xs text-muted-foreground pt-2">
&nbsp;
<a
href="https://github.com/JefferyHcool/BiliNote/issues"
target="_blank"
rel="noreferrer"
className="text-blue-600 underline"
>
GitHub Issues
</a>
&nbsp;
</p>
</div>
</DialogContent>
</Dialog>
)
}
// 默认加载态
return (
<Dialog open={open}>
<Dialog open={isOpen}>
<DialogContent className="text-center">
<DialogHeader>
<DialogTitle className="flex items-center justify-center gap-2">
@@ -15,9 +143,12 @@ interface Props {
</DialogTitle>
</DialogHeader>
<p className="text-muted-foreground mt-2">,</p>
<p className="text-muted-foreground mt-2">
10-30
</p>
</DialogContent>
</Dialog>
)
}
export default BackendInitDialog
export default BackendInitDialog

View File

@@ -0,0 +1,89 @@
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { getProxyConfig, updateProxyConfig } from '@/services/proxy'
// 全局代理配置:作用于 LLM API + 转写 APIGroq 等)+ yt-dlp 视频下载。
// 国内访问 OpenAI / Groq / YouTube 基本都要靠它。
const ProxyConfig = () => {
const [enabled, setEnabled] = useState(false)
const [url, setUrl] = useState('')
const [effective, setEffective] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
;(async () => {
try {
const cfg = await getProxyConfig()
setEnabled(cfg.enabled)
setUrl(cfg.url)
setEffective(cfg.effective)
} catch {
/* 拦截器已 toast */
} finally {
setLoading(false)
}
})()
}, [])
const handleSave = async () => {
if (enabled && !url.trim()) {
toast.error('请填写代理地址,或关闭代理开关')
return
}
setSaving(true)
try {
const cfg = await updateProxyConfig({ enabled, url: url.trim() })
setEnabled(cfg.enabled)
setUrl(cfg.url)
setEffective(cfg.effective)
toast.success('代理配置已保存')
} catch {
/* 拦截器已 toast */
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="text-xs text-gray-400"></div>
}
// env 兜底:配置没开但 effective 有值,说明来自 HTTP_PROXY 环境变量
const fromEnv = !enabled && !!effective
return (
<div className="flex flex-col gap-2 rounded border border-neutral-200 p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<p className="text-xs text-gray-400">
AI Groq YouTube
</p>
<Input
placeholder="http://127.0.0.1:7890"
value={url}
disabled={!enabled}
onChange={e => setUrl(e.target.value)}
className="text-sm"
/>
{fromEnv && (
<p className="text-xs text-amber-600">
{effective}
</p>
)}
{enabled && effective && (
<p className="text-xs text-green-600">{effective}</p>
)}
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? '保存中…' : '保存代理配置'}
</Button>
</div>
)
}
export default ProxyConfig

View File

@@ -41,21 +41,22 @@ const ProviderCard: FC<IProviderCardProps> = ({
<div
className={
styles.card +
' flex h-14 items-center justify-between rounded border border-[#f3f3f3] p-2' +
' flex h-14 cursor-pointer items-center justify-between rounded border border-[#f3f3f3] p-2' +
(isActive ? ' bg-[#F0F0F0] font-semibold text-blue-600' : '')
}
// 整行可点跳转到对应供应商编辑页(之前 onClick 只挂在 icon+名字那一小块 div 上,
// 名字和开关之间的空白区域点不动)
onClick={() => navigate(`/settings/model/${id}`)}
>
<div
className="flex items-center text-lg"
onClick={() => navigate(`/settings/model/${id}`)}
>
<div className="flex items-center text-lg">
<div className="flex h-9 w-9 items-center">
<AILogo name={Icon} />
</div>
<div className="font-semibold">{providerName}</div>
</div>
<div>
{/* Switch 自己的点击不应该冒泡触发整行跳转 */}
<div onClick={e => e.stopPropagation()}>
<Switch
checked={isChecked}
onCheckedChange={handleToggle}

View File

@@ -74,8 +74,17 @@ const StartupBanner = () => {
})
})
// 后端被「重启后端」按钮拉起来后 / Rust ready-probe 检测到新 sidecar 真的就绪后,
// 自动清掉 terminated 横幅。之前 dismissible:false + 没自动清逻辑 = banner 永远卡。
const offRestarted = await listen('backend-restarted', () => {
setBanner(b => (b?.severity === 'error' ? null : b))
})
const offReady = await listen('backend-ready', () => {
setBanner(b => (b?.severity === 'error' ? null : b))
})
// backend-error 是 sidecar stderr量大噪音多这里不直接展示留给 P2 的日志面板。
unlisteners = [offWarning, offTerminated]
unlisteners = [offWarning, offTerminated, offRestarted, offReady]
})()
return () => {

View File

@@ -1,52 +1,156 @@
import { useEffect, useState } from 'react'
import request from '@/utils/request'
import { useCallback, useEffect, useRef, useState } from 'react'
const MAX_RETRIES = 3
const RETRY_INTERVAL = 10000 // 10秒
// 后端就绪检测的几个时间常量
// - 总等待上限 60s超过这个时间没就绪就切「启动失败」UI
// 不再像旧实现 while(true) 无限转
// - 轮询间隔 2s比旧的 10s 更敏感,桌面端 sidecar 5-15s 解压期内能尽快感知就绪
// - 单次请求超时 5s避免连接 hang 拖到下一轮
const TOTAL_TIMEOUT_MS = 60_000
const POLL_INTERVAL_MS = 2_000
const PROBE_TIMEOUT_MS = 5_000
export const useCheckBackend = () => {
const [loading, setLoading] = useState(false)
const [initialized, setInitialized] = useState(false)
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
useEffect(() => {
let retries = 0
// 直接用 fetch 而非 utils/request 的共享 axios那个 axios 装了全局 toast 拦截器,
// 启动期每次 /sys_check 失败都会弹一个红色 toast2s 一次轮询会叠出十几个。
function getBackendBase(): string {
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
return ((fromEnv && fromEnv.length > 0) ? fromEnv : '/api').replace(/\/$/, '')
}
const check = async () => {
try {
await request.get('/sys_check')
setInitialized(true)
setLoading(false)
} catch {
if (retries === 0) {
// 第一次失败时开始显示加载状态
setLoading(true)
}
async function probeSysCheck(): Promise<boolean> {
const url = `${getBackendBase()}/sys_check`
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS)
try {
const res = await fetch(url, { signal: ctrl.signal })
if (!res.ok) return false
const json = await res.json().catch(() => null)
return json?.code === 0
}
catch {
return false
}
finally {
clearTimeout(t)
}
}
if (retries < MAX_RETRIES) {
retries++
setTimeout(check, RETRY_INTERVAL)
} else {
// 达到重试上限,继续轮询直到后端就绪
waitUntilBackendReady()
}
}
}
interface Status {
loading: boolean
initialized: boolean
failed: boolean
lastError: string | null
}
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))
}
}
}
interface BackendCheck extends Status {
retry: () => void
}
check()
const initialStatus: Status = {
loading: true,
initialized: false,
failed: false,
lastError: null,
}
/**
* 后端就绪检测。
*
* 三路信号汇聚:
* 1. HTTP 轮询 /sys_check —— 所有平台通用
* 2. Tauri 'backend-ready' 事件 —— 桌面端 sidecar 探测器先于 HTTP 一步触达
* 3. Tauri 'backend-terminated' / 'backend-startup-timeout' 事件 —— sidecar 死了或超时
* 立即进失败态,不再继续轮询(旧实现的 while(true) 就是死在这里)
*
* 任何一路报「ready」即成功任何一路报「失败」立即停掉所有轮询。
*/
export const useCheckBackend = (): BackendCheck => {
const [status, setStatus] = useState<Status>(initialStatus)
// tick 用来强制重启 useEffectretry 时 +1不引入 ref 互斥逻辑的复杂性
const [tick, setTick] = useState(0)
// 标记当前 effect 是否已 settle避免后到的事件覆盖已确定的成功/失败态)
const settledRef = useRef(false)
const retry = useCallback(() => {
settledRef.current = false
setStatus(initialStatus)
setTick((t) => t + 1)
}, [])
return { loading, initialized }
}
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout> | null = null
let pollTimerId: ReturnType<typeof setTimeout> | null = null
let cancelled = false
const tauriUnsubs: Array<() => void> = []
const markReady = () => {
if (cancelled || settledRef.current) return
settledRef.current = true
setStatus({ loading: false, initialized: true, failed: false, lastError: null })
}
const markFailed = (msg: string) => {
if (cancelled || settledRef.current) return
settledRef.current = true
setStatus({ loading: false, initialized: false, failed: true, lastError: msg })
}
const poll = async () => {
if (cancelled || settledRef.current) return
const ok = await probeSysCheck()
if (cancelled || settledRef.current) return
if (ok) {
markReady()
return
}
// 单次失败不报 toast、不抛错继续轮询
setStatus((s) => ({ ...s, lastError: '后端尚未响应' }))
pollTimerId = setTimeout(poll, POLL_INTERVAL_MS)
}
// 总超时兜底
timeoutId = setTimeout(() => {
markFailed(`后端 ${TOTAL_TIMEOUT_MS / 1000}s 内未就绪,请检查后端日志或重启`)
}, TOTAL_TIMEOUT_MS)
// 桌面端订阅 Tauri 事件(动态 import 避免 web 端打包报错)
if (isTauri) {
import('@tauri-apps/api/event')
.then(async ({ listen }) => {
if (cancelled) return
const offReady = await listen<number>('backend-ready', () => markReady())
const offTimeout = await listen<string>('backend-startup-timeout', (e) => {
markFailed(typeof e.payload === 'string' ? e.payload : '后端启动超时')
})
const offTerm = await listen<number | null>('backend-terminated', (e) => {
const code = e.payload
markFailed(`后端进程已退出 (code=${code ?? 'unknown'})`)
})
tauriUnsubs.push(offReady, offTimeout, offTerm)
})
.catch((err) => {
// 拿不到 @tauri-apps/api/event 不致命,继续走 HTTP 轮询
console.warn('[useCheckBackend] 无法订阅 Tauri 事件:', err)
})
}
// 立刻开始第一轮轮询
poll()
return () => {
cancelled = true
if (timeoutId) clearTimeout(timeoutId)
if (pollTimerId) clearTimeout(pollTimerId)
tauriUnsubs.forEach((off) => {
try {
off()
} catch {
/* noop */
}
})
}
}, [tick])
return { ...status, retry }
}

View File

@@ -14,6 +14,7 @@ import 'react-medium-image-zoom/dist/styles.css'
import gfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import rehypeSlug from 'rehype-slug'
import 'katex/dist/katex.min.css'
import 'github-markdown-css/github-markdown-light.css'
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
@@ -47,7 +48,7 @@ const steps = [
]
const remarkPlugins = [gfm, remarkMath]
const rehypePlugins = [rehypeKatex]
const rehypePlugins = [rehypeKatex, rehypeSlug]
/**
* 构建 ReactMarkdown components 对象baseURL 用于修正图片路径。
@@ -117,6 +118,51 @@ function createMarkdownComponents(baseURL: string) {
)
}
// 处理笔记内部锚点链接(如目录跳转)
if (href?.startsWith('#')) {
const handleAnchorClick = (e: React.MouseEvent) => {
e.preventDefault()
const id = decodeURIComponent(href.slice(1))
// 1. 优先精确匹配 id
let target = document.getElementById(id)
// 2. 精确失败时按 heading 文本模糊匹配
// LLM 生成的目录锚点可能和 heading 实际文本不完全一致
//(例如 heading 带 *Content-[00:00]* 后缀,目录链接里没有)
if (!target) {
const normalize = (s: string) =>
s.replace(/[-:\s*\[\]]/g, '').toLowerCase()
const search = normalize(id)
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6')
for (const h of headings) {
const text = h.textContent || ''
if (normalize(text).includes(search) || search.includes(normalize(text))) {
target = h
break
}
}
}
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
} else {
toast.error('未找到对应章节')
}
}
return (
<a
href={href}
onClick={handleAnchorClick}
className="text-primary hover:text-primary/80 inline-flex items-center gap-0.5 font-medium underline underline-offset-4"
{...props}
>
{children}
</a>
)
}
return (
<a
href={href}

View File

@@ -5,6 +5,171 @@ import { Toolbar } from 'markmap-toolbar'
import 'markmap-toolbar/dist/style.css'
import JSZip from 'jszip'
const MIN_EXPORT_FONT_PX = 256
const MIN_EXPORT_WIDTH = 12800
const WEB_EXPORT_SCALE_FACTOR = 0.34
const MAX_EXPORT_SCALE = 24
const MAX_CANVAS_SIDE = 32767
const MAX_CANVAS_PIXELS = 268000000
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('无法创建PNG图片'))
}
}, 'image/png')
})
}
function createSvgElement<K extends keyof SVGElementTagNameMap>(tag: K): SVGElementTagNameMap[K] {
return document.createElementNS('http://www.w3.org/2000/svg', tag)
}
function sanitizeSvgForCanvas(svg: SVGSVGElement): SVGSVGElement {
const cloned = svg.cloneNode(true) as SVGSVGElement
// markmap 会在 SVG 的顶层 <g> 上写入当前预览视口的 pan/zoom transform。
// 导出时我们按内容 bbox 裁剪,如果保留这个视口 transform会产生双重偏移
// 导致图片内容跑到角落并留下大片空白。这里只移除顶层视口 transform
// 保留内部节点自身的布局 transform。
cloned.querySelector(':scope > g')?.removeAttribute('transform')
cloned.querySelectorAll('image').forEach(el => el.remove())
cloned.querySelectorAll('foreignObject').forEach((foreignObject) => {
const textContent = foreignObject.textContent?.replace(/\s+/g, ' ').trim()
if (!textContent) {
foreignObject.remove()
return
}
const x = Number(foreignObject.getAttribute('x') || 0)
const y = Number(foreignObject.getAttribute('y') || 0)
const height = Number(foreignObject.getAttribute('height') || 20)
const text = createSvgElement('text')
text.setAttribute('x', String(x))
text.setAttribute('y', String(y + height / 2))
text.setAttribute('dominant-baseline', 'middle')
text.setAttribute('font-size', '14')
text.setAttribute('font-family', 'Arial, "Microsoft YaHei", sans-serif')
text.setAttribute('fill', '#333')
text.textContent = textContent
foreignObject.replaceWith(text)
})
return cloned
}
function getExportFontSize(svg: SVGSVGElement): number {
const text = svg.querySelector('text, foreignObject')
if (!text) return 14
const fontSize = Number.parseFloat(getComputedStyle(text).fontSize || '')
if (Number.isFinite(fontSize) && fontSize > 0) return fontSize
const attrSize = Number.parseFloat(text.getAttribute('font-size') || '')
return Number.isFinite(attrSize) && attrSize > 0 ? attrSize : 14
}
function getMindmapBounds(svg: SVGSVGElement) {
const target = svg.querySelector('g') || svg
const bbox = target.getBBox()
const padding = 50
return {
x: Math.floor(bbox.x - padding),
y: Math.floor(bbox.y - padding),
width: Math.max(Math.ceil(bbox.width + padding * 2), 1),
height: Math.max(Math.ceil(bbox.height + padding * 2), 1),
}
}
function stripMindmapImages(markdown: string) {
return (markdown || '')
// 思维导图只保留文字结构,图片节点会让预览排版和 PNG 导出效果都很差。
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/<img\b[^>]*>/gi, '')
}
function transformMindmap(markdown: string) {
return transformer.transform(stripMindmapImages(markdown))
}
function createExportSvg(svgEl: SVGSVGElement) {
const bounds = getMindmapBounds(svgEl)
const clonedSvg = sanitizeSvgForCanvas(svgEl)
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
clonedSvg.setAttribute('width', String(bounds.width))
clonedSvg.setAttribute('height', String(bounds.height))
clonedSvg.setAttribute('viewBox', `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`)
clonedSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
bgRect.setAttribute('x', String(bounds.x))
bgRect.setAttribute('y', String(bounds.y))
bgRect.setAttribute('width', String(bounds.width))
bgRect.setAttribute('height', String(bounds.height))
bgRect.setAttribute('fill', 'white')
const firstG = clonedSvg.querySelector('g')
clonedSvg.insertBefore(bgRect, firstG || clonedSvg.firstChild)
return { clonedSvg, ...bounds }
}
async function exportSvgToPngBlob(svgEl: SVGSVGElement): Promise<Blob> {
const { clonedSvg, width, height } = createExportSvg(svgEl)
const svgData = new XMLSerializer().serializeToString(clonedSvg)
const svgUrl = URL.createObjectURL(new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }))
try {
const img = new Image()
img.decoding = 'async'
img.src = svgUrl
await img.decode()
// 按导图内容尺寸和字号动态反推 PNG 倍率,而不是按预览容器或固定倍率导出。
const fontScale = MIN_EXPORT_FONT_PX / getExportFontSize(svgEl)
const widthScale = MIN_EXPORT_WIDTH / width
const rawScale = Math.max(window.devicePixelRatio || 1, fontScale, widthScale)
const sideLimitScale = Math.min(MAX_CANVAS_SIDE / width, MAX_CANVAS_SIDE / height)
const pixelLimitScale = Math.sqrt(MAX_CANVAS_PIXELS / (width * height))
const baseScale = Math.min(rawScale, MAX_EXPORT_SCALE, sideLimitScale, pixelLimitScale)
const scale = Math.max(1, baseScale * WEB_EXPORT_SCALE_FACTOR)
let currentScale = scale
let lastError: unknown
while (currentScale >= 1) {
try {
const canvas = document.createElement('canvas')
canvas.width = Math.ceil(width * currentScale)
canvas.height = Math.ceil(height * currentScale)
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('无法获取Canvas上下文')
}
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.setTransform(currentScale, 0, 0, currentScale, 0, 0)
ctx.drawImage(img, 0, 0, width, height)
ctx.setTransform(1, 0, 0, 1, 0, 0)
return await canvasToBlob(canvas)
} catch (error) {
lastError = error
currentScale = Math.floor(currentScale / 2)
}
}
throw lastError || new Error('导出PNG失败')
} finally {
URL.revokeObjectURL(svgUrl)
}
}
export interface MarkmapEditorProps {
/** 要渲染的 Markdown 文本 */
value: string
@@ -34,6 +199,13 @@ export default function MarkmapEditor({
// 用于跟踪是否处于全屏状态
const [isFullscreen, setIsFullscreen] = useState(false)
const [pngAction, setPngAction] = useState<'idle' | 'exporting' | 'copying'>('idle')
const [pngMessage, setPngMessage] = useState('')
const showPngMessage = (message: string) => {
setPngMessage(message)
window.setTimeout(() => setPngMessage(''), 2500)
}
// 监听全屏状态变化
useEffect(() => {
@@ -64,7 +236,7 @@ export default function MarkmapEditor({
// 导出HTML思维导图
const exportHtml = () => {
try {
const { root } = transformer.transform(value)
const { root } = transformMindmap(value)
const data = JSON.stringify(root)
// 创建HTML内容
@@ -202,7 +374,7 @@ export default function MarkmapEditor({
// 导出XMind格式思维导图
const exportXMind = async () => {
try {
const { root } = transformer.transform(value);
const { root } = transformMindmap(value);
// 生成唯一ID
const generateId = () => Math.random().toString(36).substring(2, 15);
@@ -311,100 +483,44 @@ export default function MarkmapEditor({
try {
if (!svgRef.current || !mmRef.current) return;
const svgEl = svgRef.current;
const mm = mmRef.current;
// 先调用fit()确保显示完整的思维导图内容
await mm.fit();
// 等待渲染完成
await new Promise(resolve => setTimeout(resolve, 100));
// 获取SVG实际尺寸
const svgWidth = svgEl.width.baseVal.value || svgEl.clientWidth || 800;
const svgHeight = svgEl.height.baseVal.value || svgEl.clientHeight || 600;
// 设置足够大的缩放比例以确保高清输出
const scale = 3;
// 克隆SVG以避免修改原始SVG
const clonedSvg = svgEl.cloneNode(true) as SVGSVGElement;
// 设置SVG的背景为白色
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
style.textContent = 'svg { background-color: white; }';
clonedSvg.insertBefore(style, clonedSvg.firstChild);
// 确保SVG有正确的命名空间
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clonedSvg.setAttribute('width', svgWidth.toString());
clonedSvg.setAttribute('height', svgHeight.toString());
// 将SVG转换为Data URI (避免使用Blob URL来解决跨域问题)
const svgData = new XMLSerializer().serializeToString(clonedSvg);
const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
const dataUri = `data:image/svg+xml;base64,${svgBase64}`;
// 创建Canvas
const canvas = document.createElement('canvas');
canvas.width = svgWidth * scale;
canvas.height = svgHeight * scale;
// 获取上下文并设置白色背景
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('无法获取Canvas上下文');
}
// 设置白色背景
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 创建Image对象
const img = new Image();
// 当图片加载完成后在Canvas上绘制并导出
img.onload = () => {
try {
// 应用缩放
ctx.setTransform(scale, 0, 0, scale, 0, 0);
// 绘制SVG
ctx.drawImage(img, 0, 0);
// 重置变换
ctx.setTransform(1, 0, 0, 1, 0, 0);
// 将Canvas转换为PNG Blob
canvas.toBlob((blob) => {
if (blob) {
// 创建下载链接
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'mindmap'}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
console.error('无法创建Blob对象');
}
}, 'image/png');
} catch (err) {
console.error('Canvas处理失败:', err);
}
};
// 设置图片加载错误处理
img.onerror = (error) => {
console.error('导出PNG失败图片加载错误:', error);
};
// 开始加载SVG图像 (使用Data URI而不是Blob URL)
img.src = dataUri;
setPngAction('exporting');
setPngMessage('正在生成高清 PNG…');
const blob = await exportSvgToPngBlob(svgRef.current);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'mindmap'}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showPngMessage('PNG 已开始下载');
} catch (error) {
console.error('导出PNG失败:', error);
showPngMessage('导出 PNG 失败,请查看控制台');
} finally {
setPngAction('idle');
}
};
// 复制PNG思维导图
const copyPng = async () => {
try {
if (!svgRef.current || !mmRef.current) return;
setPngAction('copying');
setPngMessage('正在复制高清 PNG…');
await navigator.clipboard.write([
new ClipboardItem({
'image/png': exportSvgToPngBlob(svgRef.current),
}),
]);
showPngMessage('PNG 已复制');
} catch (error) {
console.error('复制PNG失败:', error);
showPngMessage('复制 PNG 失败,请查看控制台');
} finally {
setPngAction('idle');
}
};
@@ -428,7 +544,7 @@ export default function MarkmapEditor({
useEffect(() => {
const mm = mmRef.current
if (!mm) return
const { root } = transformer.transform(value)
const { root } = transformMindmap(value)
mm.setData(root).then(() => mm.fit())
}, [value])
@@ -459,8 +575,17 @@ export default function MarkmapEditor({
onClick={exportPng}
className="rounded p-1 hover:bg-gray-200"
title="导出PNG图片"
disabled={pngAction !== 'idle'}
>
🖼
{pngAction === 'exporting' ? '⏳' : '🖼️'}
</button>
<button
onClick={copyPng}
className="rounded p-1 hover:bg-gray-200"
title="复制PNG图片"
disabled={pngAction !== 'idle'}
>
{pngAction === 'copying' ? '⏳' : '📋'}
</button>
<button
onClick={exportHtml}
@@ -483,6 +608,11 @@ export default function MarkmapEditor({
</button>
)}
</div>
{pngMessage && (
<div className="absolute top-11 right-2 z-20 rounded bg-white/95 px-2 py-1 text-xs text-gray-600 shadow">
{pngMessage}
</div>
)}
{/* 如果需要编辑区,就自己加一个 <textarea> 并把 handleChange 绑上 */}
{/* <textarea value={value} onChange={handleChange} className="mb-2 p-2 border rounded" /> */}

View File

@@ -39,6 +39,7 @@ import { Textarea } from '@/components/ui/textarea.tsx'
import { noteStyles, noteFormats, videoPlatforms } from '@/constant/note.ts'
import { fetchModels } from '@/services/model.ts'
import { useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
/* -------------------- 校验 Schema -------------------- */
const formSchema = z
@@ -229,8 +230,25 @@ const NoteForm = () => {
}
// message.success('已提交任务')
const data = await generateNote(payload)
addPendingTask(data.task_id, values.platform, payload)
try {
const data = await generateNote(payload)
addPendingTask(data.task_id, values.platform, payload)
} catch (e: any) {
// 就绪门禁:本地转写模型还没下载好。后端返回 reason='transcriber_model_not_ready'
// 引导用户去「设置 → 音频转写配置」下载,而不是留一个静默失败的任务。
if (e?.data?.reason === 'transcriber_model_not_ready') {
const downloading = e?.data?.downloading
toast.error(
downloading
? '转写模型正在下载中,请稍候再提交'
: '转写模型尚未下载,请先去「音频转写配置」页下载',
)
if (!downloading) navigate('/settings/transcriber')
return
}
// 其余错误axios 拦截器已经弹过 toast这里只兜底不让 promise 变成未处理 rejection
console.error('提交任务失败:', e)
}
}
const onInvalid = (errors: FieldErrors<NoteFormValues>) => {
console.warn('表单校验失败:', errors)

View File

@@ -1,9 +1,36 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { addProvider, addModel, getProviderList, testConnection } from '@/services/model'
import { addProvider, addModel, testConnection, getProviderList, updateProviderById } from '@/services/model'
import { getTranscriberConfig, updateTranscriberConfig } from '@/services/transcriber'
import logo from '@/assets/icon.svg'
// 后端 R.error / ProviderError 的形状是 { code, msg, data },没有 .message。
// 直接 ${e} 会渲染成 [object Object],这里统一抽取可读文案。
function errText(e: any): string {
if (!e) return '未知错误'
if (typeof e === 'string') return e
return e.msg || e.message || JSON.stringify(e)
}
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
// 后端连通性自检不走共享 axios会弹 toast用裸 fetch 避免启动期 toast 叠堆
function getBackendBase(): string {
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
return ((fromEnv && fromEnv.length > 0) ? fromEnv : '/api').replace(/\/$/, '')
}
async function pingBackend(): Promise<boolean> {
try {
const res = await fetch(`${getBackendBase()}/sys_check`)
if (!res.ok) return false
const json = await res.json().catch(() => null)
return json?.code === 0
}
catch {
return false
}
}
// 桌面端首启 4 步引导。完成后写 localStorage('bilinote-onboarded') = '1',路由守卫不再拦。
//
// 1. 后端连通性自检
@@ -52,24 +79,52 @@ const Onboarding = () => {
}
// step 1: ping 后端
// 关键点:旧实现 useEffect 只在 step===1 时 ping 一次。失败后 backendOk=false 永远卡死,
// 即便后端随后就绪了也不会刷新。现在改成:
// - 手动重试按钮调用 doPing
// - Tauri backend-ready / backend-restarted 事件触发 doPing
// - 初次失败后 2s 自动再 ping 一次(覆盖 sidecar 慢热场景)
const doPing = useCallback(async () => {
setPinging(true)
const ok = await pingBackend()
setBackendOk(ok)
setPinging(false)
return ok
}, [])
useEffect(() => {
if (step !== 1) return
let cancelled = false
let timerId: ReturnType<typeof setTimeout> | null = null
let offReady: (() => void) | null = null
let offRestarted: (() => void) | null = null
;(async () => {
setPinging(true)
try {
await getProviderList()
if (!cancelled) setBackendOk(true)
const ok = await doPing()
if (cancelled) return
if (!ok) {
// 后端可能正在解压/启动2s 后再试一次
timerId = setTimeout(() => { if (!cancelled) doPing() }, 2000)
}
catch {
if (!cancelled) setBackendOk(false)
}
finally {
if (!cancelled) setPinging(false)
// 桌面端订阅 Tauri 事件:后端真正就绪 / 重启完成时立刻再检查一次
if (isTauri) {
try {
const { listen } = await import('@tauri-apps/api/event')
offReady = await listen('backend-ready', () => { if (!cancelled) doPing() })
offRestarted = await listen('backend-restarted', () => { if (!cancelled) doPing() })
}
catch { /* 拿不到事件 API 不致命 */ }
}
})()
return () => { cancelled = true }
}, [step])
return () => {
cancelled = true
if (timerId) clearTimeout(timerId)
offReady?.()
offRestarted?.()
}
}, [step, doPing])
async function saveProvider() {
setError('')
@@ -79,31 +134,61 @@ const Onboarding = () => {
if (!modelName.trim()) { setError('请填模型名'); return }
setSavingProvider(true)
try {
// 复用桌面 web 端的 add_providertype 必须是 'custom'backend 强制)
const res: any = await addProvider({
name: providerName.trim(),
api_key: apiKey.trim(),
base_url: baseUrl.trim(),
type: 'custom',
logo: 'custom',
})
const newId = (res?.data ?? res) as string | undefined
if (!newId) throw new Error('后端未返回 provider id')
setProviderId(newId)
const name = providerName.trim()
let pid: string | undefined
// 加一个默认 model
await addModel({ provider_id: newId, model_name: modelName.trim() })
// 后端 seed_default_providers() 会预置 OpenAI / DeepSeek / Qwen 等同名供应商,
// 直接 add_provider 撞名会报「供应商名称已存在」。所以:撞名时改为
// 「找到那个已存在的同名供应商 → 更新它的 key / base_url」而不是新建。
// 这些调用都带 silent:true —— 撞名是预期内的,不弹全局红 toast。
try {
const res: any = await addProvider({
name,
api_key: apiKey.trim(),
base_url: baseUrl.trim(),
type: 'custom',
logo: 'custom',
}, { silent: true })
pid = (res?.data ?? res) as string | undefined
if (!pid) throw new Error('后端未返回 provider id')
}
catch (addErr: any) {
const msg = errText(addErr)
if (!msg.includes('已存在')) throw addErr
// 撞名:复用已存在的同名供应商
const list: any[] = (await getProviderList({ silent: true })) || []
const existing = list.find(p => p?.name === name)
if (!existing?.id) throw new Error(`供应商「${name}」已存在但无法定位,请换个名字`)
pid = existing.id
await updateProviderById({
id: pid,
api_key: apiKey.trim(),
base_url: baseUrl.trim(),
enabled: 1,
}, { silent: true })
}
// 测试连通
try { await testConnection({ id: newId }) }
setProviderId(pid!)
// 加一个默认 model同名 model 已存在时后端会报错,这里也容错)
try {
await addModel({ provider_id: pid!, model_name: modelName.trim() }, { silent: true })
}
catch (modelErr: any) {
const msg = errText(modelErr)
if (!msg.includes('已存在')) throw modelErr
// 模型已存在,直接继续
}
// 测试连通(失败不阻断流程,让用户自己决定继续)
try { await testConnection({ id: pid!, model: modelName.trim() }, { silent: true }) }
catch (e: any) {
// 测试失败不阻断流程,让用户自己决定继续
console.warn('测试连接失败:', e?.message ?? e)
console.warn('测试连接失败:', errText(e))
}
next()
}
catch (e: any) {
setError(`保存失败:${e?.message ?? e}`)
setError(`保存失败:${errText(e)}`)
}
finally {
setSavingProvider(false)
@@ -123,7 +208,7 @@ const Onboarding = () => {
next()
}
catch (e: any) {
setError(`保存失败:${e?.message ?? e}`)
setError(`保存失败:${errText(e)}`)
}
finally {
setSavingTranscriber(false)
@@ -171,6 +256,15 @@ const Onboarding = () => {
</div>
)}
<div className="flex gap-2 justify-end">
{backendOk !== true && (
<button
className="px-3 py-1.5 text-sm rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
disabled={pinging}
onClick={doPing}
>
{pinging ? '检测中…' : '重新检测'}
</button>
)}
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50" disabled={!backendOk} onClick={next}>
</button>

View File

@@ -1,10 +1,11 @@
import Provider from '@/components/Form/modelForm/Provider.tsx'
import { Outlet } from 'react-router-dom'
import Options from '@/components/Form/DownloaderForm/Options.tsx'
import ProxyConfig from '@/components/Form/DownloaderForm/ProxyConfig.tsx'
const Downloader = () => {
return (
<div className={'flex h-full bg-white'}>
<div className={'flex-1/5 border-r border-neutral-200 p-2'}>
<div className={'flex flex-1/5 flex-col gap-3 overflow-y-auto border-r border-neutral-200 p-2'}>
<ProxyConfig />
<Options></Options>
</div>
<div className={'flex-4/5'}>

View File

@@ -174,7 +174,11 @@ export default function Monitor() {
<AudioLines className="mr-2 inline h-5 w-5 text-purple-500" />
Whisper
</CardTitle>
{status && <StatusBadge ok={true} label="已配置" />}
{status && (() => {
const isLocal = status.whisper.transcriber_type === 'fast-whisper' || status.whisper.transcriber_type === 'mlx-whisper'
if (!isLocal) return <StatusBadge ok={true} label="在线引擎" />
return <StatusBadge ok={status.whisper.downloaded} label={status.whisper.downloaded ? '已下载' : '未下载'} />
})()}
</CardHeader>
<CardContent>
{loading && !status ? (
@@ -192,6 +196,14 @@ export default function Monitor() {
<span className="text-muted-foreground">:</span>
<span className="font-mono">{status.whisper.transcriber_type}</span>
</div>
{(status.whisper.transcriber_type === 'fast-whisper' || status.whisper.transcriber_type === 'mlx-whisper') && (
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className={status.whisper.downloaded ? 'font-medium text-green-600' : 'font-medium text-amber-600'}>
{status.whisper.downloaded ? '已就绪' : '未下载(首次转写会触发下载)'}
</span>
</div>
)}
</div>
) : null}
</CardContent>

View File

@@ -5,9 +5,14 @@ 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'
import wechatQr from '@/assets/wechat.png'
// 二维码统一走 GitHub 在线地址:后续在仓库替换图片后,已打包的 App 会自动更新,无需重新发版
const GITHUB_DOC_RAW = 'https://raw.githubusercontent.com/JefferyHcool/BiliNote/master/doc'
const wechatQr = `${GITHUB_DOC_RAW}/wechat-gzh.png`
const remoteInstallQr = `${GITHUB_DOC_RAW}/remote-install-wechat.png`
export default function AboutPage() {
const appVersion = __APP_VERSION__
const images = [
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504102850.png',
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504103028.png',
@@ -27,7 +32,7 @@ export default function AboutPage() {
height={50}
className="rounded-lg"
/>
<h1 className="text-4xl font-bold">BiliNote v2.0.0</h1>
<h1 className="text-4xl font-bold">BiliNote v{appVersion}</h1>
</div>
<p className="text-muted-foreground mb-6 text-xl italic">
AI AI
@@ -197,12 +202,28 @@ export default function AboutPage() {
<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="flex flex-col items-center justify-center gap-10 md:flex-row md:items-start">
<div className="text-center">
<h3 className="mb-3 text-xl font-semibold">BiliNote </h3>
<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={wechatQr} alt="BiliNote 交流微信群" className="h-full w-full object-contain" />
<img src={wechatQr} alt="BiliNote 公众号" className="h-full w-full object-contain" />
</div>
<p className="text-muted-foreground mt-3 text-sm"></p>
</div>
<div className="text-center">
<h3 className="mb-3 text-xl font-semibold">BiliNote AI笔记系统一对一搭建服务</h3>
<div className="bg-muted mx-auto flex h-52 w-52 items-center justify-center rounded-md">
<img
src={remoteInstallQr}
alt="BiliNote AI笔记系统一对一搭建服务"
className="h-full w-full object-contain"
/>
</div>
<p className="text-muted-foreground mt-3 text-sm">
<br />
</p>
</div>
</div>
</div>

View File

@@ -10,13 +10,16 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AudioLines, AlertTriangle, CheckCircle2, Download, Loader2, Save, XCircle } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { AudioLines, AlertTriangle, CheckCircle2, Download, Loader2, Save, XCircle, Plus, Trash2, Boxes } from 'lucide-react'
import { toast } from 'react-hot-toast'
import {
getTranscriberConfig,
updateTranscriberConfig,
getModelsStatus,
downloadModel,
addWhisperModel,
deleteWhisperModel,
TranscriberConfig,
ModelStatus,
} from '@/services/transcriber'
@@ -33,6 +36,19 @@ export default function Transcriber() {
const [modelStatuses, setModelStatuses] = useState<ModelStatus[]>([])
const [mlxModelStatuses, setMlxModelStatuses] = useState<ModelStatus[]>([])
const [mlxAvailable, setMlxAvailable] = useState(false)
// 自定义模型表单
const [newModelName, setNewModelName] = useState('')
const [newModelTarget, setNewModelTarget] = useState('')
const [addingModel, setAddingModel] = useState(false)
// 重新拉取配置(不重置用户当前的选择),用于增删自定义模型后刷新下拉与列表
const reloadConfig = useCallback(async () => {
try {
setConfig(await getTranscriberConfig())
} catch {
// 静默
}
}, [])
const fetchModelsStatus = useCallback(async () => {
try {
@@ -123,6 +139,41 @@ export default function Transcriber() {
}
}
const handleAddCustomModel = async () => {
const name = newModelName.trim()
const target = newModelTarget.trim()
if (!name || !target) {
toast.error('请填写模型名称和 HF repo_id / 本地路径')
return
}
setAddingModel(true)
try {
await addWhisperModel({ name, target })
toast.success(`已添加自定义模型 ${name}`)
setNewModelName('')
setNewModelTarget('')
await reloadConfig()
await fetchModelsStatus()
} catch {
// 后端的具体错误(如重名)已由请求拦截器 toast这里不重复提示
} finally {
setAddingModel(false)
}
}
const handleDeleteCustomModel = async (name: string) => {
try {
await deleteWhisperModel(name)
toast.success(`已删除自定义模型 ${name}`)
// 删的正好是当前选中的,回退到 tiny避免选中一个不存在的名称
if (selectedModelSize === name) setSelectedModelSize('tiny')
await reloadConfig()
await fetchModelsStatus()
} catch {
// 拦截器已提示
}
}
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
@@ -272,6 +323,97 @@ export default function Transcriber() {
</CardContent>
</Card>
)}
{/* 自定义 Whisper 模型(仅 fast-whisper名称不符合内置 Systran 约定的模型在此登记映射) */}
{selectedType === 'fast-whisper' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Boxes className="h-5 w-5" />
<span className="text-sm font-normal text-neutral-400">
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Alert className="text-sm">
<AlertDescription>
<strong>HF repo_id</strong>{' '}
<code className="rounded bg-neutral-100 px-1">Systran/faster-whisper-large-v3</code>
<strong></strong>{' '}
<code className="rounded bg-neutral-100 px-1">/app/backend/models/my-whisper</code>
<code className="rounded bg-neutral-100 px-1">model.bin</code>
Docker README {' '}
<code className="rounded bg-neutral-100 px-1">models</code>
</AlertDescription>
</Alert>
{config.whisper_custom_models &&
Object.keys(config.whisper_custom_models).length > 0 ? (
<div className="space-y-2">
{Object.entries(config.whisper_custom_models).map(([name, target]) => {
const status = modelStatuses.find(m => m.model_size === name)
return (
<div
key={name}
className="flex items-center justify-between gap-3 rounded-md border px-4 py-2.5"
>
<div className="min-w-0">
<div className="flex items-center gap-2 font-medium">
{name}
{status?.downloaded && (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
)}
{status?.downloading && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-neutral-400" />
)}
</div>
<div className="truncate text-xs text-neutral-400" title={target}>
{target}
</div>
</div>
<Button
size="sm"
variant="ghost"
className="text-red-500 hover:text-red-600"
onClick={() => handleDeleteCustomModel(name)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
})}
</div>
) : (
<p className="text-sm text-neutral-400"></p>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
placeholder="模型名称(自定义,如 my-large-v3"
value={newModelName}
onChange={e => setNewModelName(e.target.value)}
className="sm:max-w-[220px]"
/>
<Input
placeholder="HF repo_id 或本地路径"
value={newModelTarget}
onChange={e => setNewModelTarget(e.target.value)}
className="flex-1"
/>
<Button onClick={handleAddCustomModel} disabled={addingModel}>
{addingModel ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-1 h-4 w-4" />
)}
</Button>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -1,21 +1,26 @@
import request from '@/utils/request.ts'
export const getProviderList = async () => {
return await request.get('/get_all_providers')
// opts.silent: 让本次请求失败时不弹全局红 toast调用方自行 catch 处理,
// 比如 onboarding 撞名重试这种预期内失败)
interface CallOpts { silent?: boolean }
const cfg = (o?: CallOpts) => (o?.silent ? { suppressToast: true } : undefined)
export const getProviderList = async (opts?: CallOpts) => {
return await request.get('/get_all_providers', cfg(opts))
}
export const getProviderById = async (id: string) => {
return await request.get(`/get_provider_by_id/${id}`)
}
export const updateProviderById = async (data: any) => {
return await request.post('/update_provider', data)
export const updateProviderById = async (data: any, opts?: CallOpts) => {
return await request.post('/update_provider', data, cfg(opts))
}
export const addProvider = async (data: any) => {
return await request.post('/add_provider', data)
export const addProvider = async (data: any, opts?: CallOpts) => {
return await request.post('/add_provider', data, cfg(opts))
}
export const testConnection = async (data: any) => {
return await request.post('/connect_test', data)
export const testConnection = async (data: any, opts?: CallOpts) => {
return await request.post('/connect_test', data, cfg(opts))
}
export const fetchModels = async (providerId: string) => {
@@ -26,8 +31,11 @@ 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)
export async function addModel(
data: { provider_id: string; model_name: string },
opts?: CallOpts,
) {
return request.post('/models', data, cfg(opts))
}
export const fetchEnableModels = async () => {

View File

@@ -0,0 +1,19 @@
import request from '@/utils/request'
export interface ProxyConfig {
enabled: boolean
url: string
/** 后端实际生效的代理(可能来自配置,也可能来自 HTTP_PROXY 环境变量兜底) */
effective: string
}
export const getProxyConfig = async (): Promise<ProxyConfig> => {
return await request.get('/proxy_config')
}
export const updateProxyConfig = async (data: {
enabled: boolean
url?: string
}): Promise<ProxyConfig> => {
return await request.post('/proxy_config', data)
}

View File

@@ -1,9 +1,29 @@
import request from '@/utils/request'
export const systemCheck = async () => {
export interface SysHealth {
backend: 'ok' | 'error'
ffmpeg: 'ok' | 'missing'
db: 'ok' | 'error'
whisper_model: {
/** 当前选中的模型 size例如 'tiny' / 'base' / 'large-v3' */
size: string | null
/** 转写器类型 */
type: string | null
/** 是否已完整下载到本地(仅本地引擎有意义) */
downloaded: boolean
/** 是否实际检查过 —— 在线引擎跳过检查时为 false */
checked: boolean
}
}
/** 详细健康状态:用于设置页 / 启动诊断。后端始终返回 200按字段判断各项。 */
export const getSysHealth = async (): Promise<SysHealth> => {
return await request.get('/sys_health')
}
/** 保留旧 systemCheck 函数名App.tsx 启动时仍调用),返回值同 getSysHealth。 */
export const systemCheck = getSysHealth
export interface DeployStatus {
backend: {
status: string
@@ -11,12 +31,16 @@ export interface DeployStatus {
}
cuda: {
available: boolean
/** 新增torch 是否安装。轻量部署没装 torch 时为 false避免误判为 CUDA 故障 */
torch_installed?: boolean
version: string | null
gpu_name: string | null
}
whisper: {
model_size: string
transcriber_type: string
/** 新增模型是否已完整下载fast-whisper 看 model.bin / mlx 看 config.json */
downloaded: boolean
}
ffmpeg: {
available: boolean

View File

@@ -5,6 +5,10 @@ export interface TranscriberConfig {
whisper_model_size: string
available_types: { value: string; label: string }[]
whisper_model_sizes: string[]
/** 内置模型映射size → HF repo_id */
whisper_builtin_models?: Record<string, string>
/** 用户自定义模型映射:名称 → HF repo_id 或本地路径 */
whisper_custom_models?: Record<string, string>
mlx_whisper_available: boolean
}
@@ -41,3 +45,23 @@ export const downloadModel = async (data: {
}) => {
return await request.post('/transcriber_download', data)
}
export interface WhisperModelsResponse {
builtin: Record<string, string>
custom: Record<string, string>
}
/** 列出内置 + 自定义 whisper 模型映射 */
export const listWhisperModels = async (): Promise<WhisperModelsResponse> => {
return await request.get('/whisper_models')
}
/** 新增自定义模型映射(名称 → HF repo_id 或本地路径) */
export const addWhisperModel = async (data: { name: string; target: string }) => {
return await request.post('/whisper_models', data)
}
/** 删除自定义模型映射(不会删除已下载的模型文件) */
export const deleteWhisperModel = async (name: string) => {
return await request.delete(`/whisper_models/${encodeURIComponent(name)}`)
}

View File

@@ -169,10 +169,25 @@ export const useTaskStore = create<TaskStore>()(
if (!task) return
const newFormData = payload || task.formData
await generateNote({
...newFormData,
task_id: id,
})
try {
await generateNote({
...newFormData,
task_id: id,
})
} catch (e: any) {
// 就绪门禁:转写模型未下载好。不要把任务标成 PENDING会一直转
// 给提示让用户先去下载。
if (e?.data?.reason === 'transcriber_model_not_ready') {
toast.error(
e?.data?.downloading
? '转写模型正在下载中,请稍候再重试'
: '转写模型尚未下载,请先去「设置 → 音频转写配置」页下载',
)
return
}
console.error('重试任务失败:', e)
return
}
set(state => ({
tasks: state.tasks.map(t =>

View File

@@ -8,6 +8,14 @@ export interface IResponse<T = any> {
data: T;
}
// 允许调用方在 axios 配置里带 suppressToast: true让拦截器对【预期内的失败】
// 不弹全局红 toast例如 onboarding 撞名重试、轮询健康检查)。业务代码自己 catch 处理。
declare module 'axios' {
export interface AxiosRequestConfig {
suppressToast?: boolean
}
}
// 模拟一个消息提示函数 (实际项目中会使用UI库的组件如 Ant Design 的 message 或 Element UI 的 ElMessage)
// This function simulates a message display (in real projects, you'd use a UI library's component)
@@ -28,25 +36,24 @@ request.interceptors.response.use(
// showMessage('success', res.msg || '操作成功'); // 如果需要显示成功消息
return res.data; // 返回data部分简化后续业务代码
} else {
// 业务错误,统一显示后端返回的错误消息
// Business error, uniformly display the error message returned from the backend
toast.error(res.msg || '操作失败,请稍后再试');
// 业务错误,统一显示后端返回的错误消息(除非调用方显式 suppressToast
if (!response.config?.suppressToast) {
toast.error(res.msg || '操作失败,请稍后再试');
}
return Promise.reject(res); // 拒绝Promise让业务代码可以捕获并处理
}
},
(error) => {
const suppress = error?.config?.suppressToast === true
// 网络/服务器错误
const res = error?.response?.data as IResponse | undefined;
if (res) {
// 如果后端有返回错误信息,则显示后端信息
// If the backend returns an error message, display it
toast.error(res.msg || '服务器错误,请稍后再试');
if (!suppress) toast.error(res.msg || '服务器错误,请稍后再试');
return Promise.reject(res);
} else {
// 没有响应数据(如网络中断),显示通用网络错误
// No response data (e.g., network disconnected), display generic network error
toast.error( '请求失败,请检查网络连接或稍后再试')
if (!suppress) toast.error('请求失败,请检查网络连接或稍后再试')
return Promise.reject({
code: -1,
msg: '请求失败,请检查网络连接',

View File

@@ -1 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string

View File

@@ -1,11 +1,25 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import tailwindcss from '@tailwindcss/vite'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function readAppVersion() {
const fallbackVersion = '0.0.0'
try {
const tauriConfigPath = path.resolve(__dirname, 'src-tauri/tauri.conf.json')
const tauriConfig = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf-8')) as { version?: string }
return tauriConfig.version || fallbackVersion
}
catch {
return fallbackVersion
}
}
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
// 在 Docker 环境中,父目录可能没有 .env 文件,使用当前目录
@@ -14,9 +28,13 @@ export default defineConfig(({ mode }) => {
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://127.0.0.1:8483'
const port = parseInt(env.VITE_FRONTEND_PORT || '3015', 10)
const appVersion = env.VITE_APP_VERSION || process.env.VITE_APP_VERSION || readAppVersion()
return {
base: './',
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
plugins: [react(), tailwindcss()],
resolve: {
alias: {

View File

@@ -2,6 +2,80 @@
本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [2.4.0] - 2026-06-07
### Added
- **可配置 Whisper 模型**:转写设置支持自定义 Whisper 模型与名称映射,可指定自定义 HuggingFace repo 或本地路径(新增 `backend/app/transcriber/whisper_models.py` + 测试),转写设置页可选择 / 配置模型。
- **关注公众号获取交流群**:关于页群二维码改为公众号二维码,关注公众号后回复「交流群」即可获取最新群二维码,避免群码过期失效。
### Fixed
- **浏览器扩展**修复标题显示异常优化脑图Markmap导出。
### Docs
- **GPU/CUDA 部署**README 补全 GPU/CUDA 部署说明。
## [2.3.4] - 2026-05-27
### Added
- **一对一搭建服务二维码**新增「BiliNote AI 笔记系统一对一搭建服务」二维码README + 关于页),扫码加微信、备注「搭建服务」即可咨询。
## [2.3.3] - 2026-05-22
### Fixed
- **预构建 Docker 镜像数据持久化**:文档的 `docker run` 只挂了 `data/`(媒体缓存),而 SQLite 数据库LLM 供应商配置 + 笔记历史)和笔记文件不在该卷下,导致删除 / 升级容器时丢失配置与历史。现将数据库重定向到 `/app/backend/data/bili_note.db`、笔记到 `data/note_results`(随 data 卷持久化README 更新为挂载 `data` / `config` / `static` / `models` 四个数据卷,并提示**勿**挂整个 `/app/backend`(命名卷会固化镜像内代码,导致 `docker pull` 升级不生效)。`docker-compose` 路径本就正确(`./backend:/app` 整目录绑挂),未受影响。
## [2.3.2] - 2026-05-22
### Fixed
- **后端启动崩溃Docker**`python:3.11-slim` 基础镜像升级到 Debian 13 / glibc 2.41 后,`ctranslate2` 4.5.0 预编译库带「可执行栈」标记被 glibc 拒绝加载(`cannot enable executable stack ... Invalid argument`)。由于 `from faster_whisper import WhisperModel` 在顶层 importimport 失败直接拖垮整个后端启动 → 容器反复重启。升级 `ctranslate2` 4.5.0→4.6.0wheel 加入 `noexecstack` 链接标志,从二进制层根治)
- **whisper 模型误报「离线模式找不到模型」**下载modelscope 自定义目录与加载faster-whisper HF cache布局不一致导致命不中缓存。统一为下载 / 加载 / 完整性检测 / 损坏自愈都走 HF cache 布局,并向后兼容老 modelscope 目录
- **桌面端构建产物版本恒为 2.0.0**Release 工作流在 `pnpm tauri build` 前从 git tag 注入版本号到 `tauri.conf.json`,使产物版本与 Release 版本对齐
## [2.3.1] - 2026-05-22
### Changed
- **更新微信交流群二维码**:旧二维码即将失效,替换 README 中 5 个交流群(群 1-5的入群二维码。
## [2.3.0] - 2026-05-14
主线一波部署与运行时韧性专项——Docker / 桌面端 / 在线引擎三端的"装不上、起不来、跑一半挂"问题集中清理,并新增全局代理与转写模型就绪门禁。
### Added
- **全局代理**:新增 `ProxyConfigManager``config/proxy.json` 持久化 + `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` 环境变量兜底)。一处配置同时作用于 LLM API、转写 APIGroq 等、yt-dlp 视频下载、youtube-transcript-api 字幕拉取。前端「设置 → 下载配置」页新增代理卡片,会显示当前实际生效值(含 env 兜底来源提示)
- **转写模型就绪门禁**`/generate_note` 在排队前检查本地转写引擎fast-whisper / mlx-whisper的模型是否已下载完整未就绪直接拦截并返回 `reason=transcriber_model_not_ready`,不再让任务静默卡在首次大文件下载;前端引导用户去「音频转写配置」页下载
- **桌面端后端健康监控韧性**Tauri 侧 spawn sidecar 后以 HTTP 探针轮询 `/api/sys_check` 判就绪并 emit `backend-ready``RunEvent::Exit` 钩子在 app 退出前 kill sidecar杜绝孤儿进程占用 8483 端口;启动失败对话框展示原因 + 最近 stderr + 一键重启 / 复制日志
- `/sys_health` 重构为结构化健康响应 `{backend, ffmpeg, db, whisper_model}`;部署监控页显示 Whisper 模型本地下载状态
- 所有 Dockerfile 新增 `BASE_REGISTRY` build-arg国内拉不到 docker.io 时可换 daocloud 等镜像源
### Fixed
- **whisper 模型损坏自愈**`model.bin` 截断 / 损坏导致 `Unable to open file 'model.bin'` 死循环——加载失败时删除损坏目录、重新下载、重试一次mlx-whisper 同样按 `config.json` 判定完整性
- **空 API Key 天书报错**:空 key 会让 httpx 拼出非法 header `Bearer ` 并抛 `LocalProtocolError: Illegal header value b'Bearer '`。新增 `build_openai_client` 在入口校验给出「xxx 的 API Key 未配置」的清晰提示
- **新模型 temperature 不兼容**OpenAI o1 / o3 / gpt-5 系列拒绝自定义 `temperature`,命中后就地去掉该参数重试,不消耗重试预算
- **桌面端「后端加载中」死循环**`useCheckBackend` 重写——60s 总超时取代 `while(true)` 无限轮询,订阅 Tauri `backend-ready` / `backend-terminated` / `backend-startup-timeout` 事件;裸 `fetch` 探测避免启动期 toast 叠堆
- **CORS 漏配桌面端 origin**:补全 `tauri://localhost` / `https://tauri.localhost`,修桌面端 fetch 拿到 200 却被浏览器 CORS 拒绝读响应(表现为"连不上后端"但后端日志全 200
- `/api/api/sys_health``/api` 前缀导致健康检查 404
- `docker-compose``restart: on-failure:3` 改为 `unless-stopped`避免短暂崩溃后容器被永久打死GPU compose 补齐 `healthcheck` / `restart` / `mem_limit`
- `Dockerfile.complete` 的 supervisord 用 `%(ENV_*)s` 透传环境变量给 backend 子进程(此前只白名单 2 个,`docker run -e` 配的变量后端看不到)
- `.env.example`:修正 `VITE_API_BASE_URL` 端口8000→8483`WHISPER_MODEL_SIZE`medium→tiny首次启动不被 ~1.5GB 下载卡住)
- Onboarding第 1 步后端连通检测改为自动重试 + Tauri 事件触发 + 手动重检按钮;第 2 步撞预置供应商名时改为更新已存在供应商而非报错
- 模型供应商列表卡片整行可点击切换(此前仅 icon 区域响应)
- `connect_test` 改用真实 chat completion 探测而非 `/v1/models`(后者在 key 无 inference 权限 / 供应商不实现该端点时会误判)
### Internal
- `backend/main.py` lifespan 拆为 `[startup 1/5]…[startup 5/5]` 分段日志,启动期异常可一眼定位死在哪一步
- `request.ts` 新增 `suppressToast` 配置位,预期内的失败(如 onboarding 撞名重试)不弹全局红 toast
- `CLAUDE.md` 勘误:移除不存在的 `app/messaging/` / `app/i18n/` / `worker_registry.py` 描述,修正 `events/` 路径,补 `pytest` / 前端 `typecheck` 命令
## [2.2.3] - 2026-05-09
### Fixed

View File

@@ -13,6 +13,8 @@ BiliNote is an AI video note generation tool. It extracts content from video lin
cd backend
pip install -r requirements.txt
python main.py # Starts on 0.0.0.0:8483
pytest # Run tests in backend/tests/
pytest tests/test_request_chunker.py::test_name # Run a single test
```
### Frontend (React 19 + Vite + TypeScript)
@@ -43,6 +45,8 @@ pnpm install
pnpm dev # watch mode → ./extension/
pnpm build # production build → ./extension/
pnpm typecheck
pnpm test # Vitest unit tests
pnpm test:e2e # Playwright e2e
```
Load unpacked at `chrome://extensions/` → select `BillNote_extension/extension/`. Talks to the same backend at `http://localhost:8483` (configurable in the options page). CORS in `backend/main.py` already accepts `chrome-extension://` and `moz-extension://` via regex.
@@ -56,15 +60,15 @@ Load unpacked at `chrome://extensions/` → select `BillNote_extension/extension
- `chat_service.py` + `chat_tools.py` + `vector_store.py` — RAG-based AI Q&A with Function Calling, indexing transcripts and video metadata
- `cookie_manager.py` — per-platform cookie storage; injected into yt-dlp by downloaders (e.g. Bilibili)
- `transcriber_config_manager.py` — persisted transcriber settings
- `worker_registry.py`**optional** Nacos registration + heartbeat for distributed worker mode (no-op when `NACOS_SERVER_ADDR` unset)
- `app/messaging/`**optional** RabbitMQ producer/consumer publishing task progress/results to `bilinote.task.feedback` exchange. Silently degrades when `RABBITMQ_URL` is unset; always import-safe.
- `app/downloaders/` — Platform adapters (bilibili, youtube, douyin, kuaishou, local) with shared `base.py` interface
- `app/transcriber/` — Speech-to-text engines (fast-whisper, groq, bcut, kuaishou, mlx-whisper) with factory in `transcriber_provider.py`. YouTube path prefers existing subtitles and skips audio download when available.
- `app/gpt/` — LLM integration with factory pattern (`gpt_factory.py`), prompt templates (`prompt.py`, `prompt_builder.py`), and `request_chunker.py` for long transcripts
- `app/db/` — SQLite + SQLAlchemy: DAO pattern (`provider_dao.py`, `model_dao.py`, `video_task_dao.py`), models in `models/`
- `app/utils/``response.py` (ResponseWrapper for consistent JSON), `video_helper.py` (screenshots via FFmpeg), `export.py` (PDF/DOCX), `ppt_generator.py`, `minio_client.py`
- `app/i18n/` — backend localization
- `events/` (root level) — Blinker signal system for post-processing (e.g., temp file cleanup after transcription)
- `app/validators/video_url_validator.py` — URL → platform detection (mirrored client-side in the extension)
- `app/exceptions/``BizException` + handlers wired in `main.py` via `register_exception_handlers`
- `backend/events/` — Blinker signal system for post-processing (e.g., temp file cleanup after transcription); registered in `lifespan` startup
- `backend/ffmpeg_helper.py``ensure_ffmpeg_or_raise` is called at startup; respects `FFMPEG_BIN_PATH`
**Frontend** (`BillNote_frontend/src/`) — React 19 + Vite + Tailwind + shadcn/ui:
- `pages/HomePage/` — Main note generation UI: `NoteForm.tsx` (input), `MarkdownViewer.tsx` (preview), `MarkmapComponent.tsx` (mind map)
@@ -94,8 +98,8 @@ Load unpacked at `chrome://extensions/` → select `BillNote_extension/extension
- **Environment**: Root `.env` (copy from `.env.example`). LLM API keys are configured through the UI, not env vars.
- **Database**: SQLite at `backend/app/db/bili_note.db`, auto-initialized on first run
- **FFmpeg**: Required system dependency for video/audio processing
- **Vite proxy**: Dev server proxies `/api` and `/static` to backend (configured in `vite.config.ts`, reads env from parent dir)
- **Distributed mode (optional)**: Setting `NACOS_SERVER_ADDR` enables Nacos worker registration; setting `RABBITMQ_URL` enables MQ feedback. Both are no-ops when unset — single-node deployment works without either. Other knobs: `WORKER_ID`, `WORKER_SELF_URL`, `WORKER_MAX_CONCURRENT`, `TASK_MAX_WORKERS`.
- **Vite proxy**: Dev server proxies `/api` and `/static` to backend (configured in `vite.config.ts`, reads env from parent dir; falls back to current dir when `DOCKER_BUILD` is set)
- **CORS**: `backend/main.py` uses a regex (`CORS_ORIGIN_REGEX`) that allows localhost, `tauri.localhost`, and `chrome-extension://` / `moz-extension://` origins — required for the desktop app and the browser extension.
## Code Style

View File

@@ -1,5 +1,9 @@
# BASE_REGISTRY 默认 docker.io国内拉不到可换镜像源
# docker build --build-arg BASE_REGISTRY=docker.m.daocloud.io -f Dockerfile.complete .
ARG BASE_REGISTRY=docker.io
# === 阶段1构建 Backend ===
FROM python:3.11-slim AS backend-builder
FROM ${BASE_REGISTRY}/library/python:3.11-slim AS backend-builder
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
@@ -28,7 +32,12 @@ COPY ./backend /tmp/backend
# === 阶段2构建 Frontend ===
# Node 18-alpine 跑不动 Tailwind v4 / Vite 6前者要求 Node 20+,后者推荐 Node 20+
# 升到 node:20-alpine。alpine 走 muslpnpm 会按 lockfile 拉 *-linux-x64-musl native binary。
FROM node:20-alpine AS frontend-builder
ARG BASE_REGISTRY=docker.io
FROM ${BASE_REGISTRY}/library/node:20-alpine AS frontend-builder
# 可由发布 workflow 从 git tag 注入,用于前端 About 页展示版本;未传时由 Vite 回退读取 tauri.conf.json。
ARG VITE_APP_VERSION=
ENV VITE_APP_VERSION=${VITE_APP_VERSION}
# pnpm 版本 pin 到 9 系列:
# - lockfile (BillNote_frontend/pnpm-lock.yaml) 是 lockfileVersion '9.0',由 pnpm 9 生成
@@ -50,7 +59,8 @@ ENV DOCKER_BUILD=1
RUN pnpm run build
# === 阶段3完整应用镜像 ===
FROM python:3.11-slim
ARG BASE_REGISTRY=docker.io
FROM ${BASE_REGISTRY}/library/python:3.11-slim
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
@@ -85,13 +95,20 @@ RUN rm -rf /etc/nginx/conf.d/default.conf
COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf
# 创建 supervisor 配置
RUN mkdir -p /var/log/supervisor
# 关键点supervisord 默认 *不* 把自己的环境变量传给子进程。
# 在 [supervisord] 块用 environment= 设兜底默认值;在 [program:backend] 用
# %(ENV_*)s 显式引用,等价于「把 host 通过 docker run -e 或 env_file 传进来的
# 变量再透传给 python main.py」。漏掉这一步就是用户「改 .env 没反应」的根因。
# /app/backend/data 用于持久化数据库与笔记(见下方 DATABASE_URL / NOTE_OUTPUT_DIR
# 预建好目录,避免不挂卷启动时 sqlite 因父目录不存在而创建库失败。
RUN mkdir -p /var/log/supervisor /app/backend/data
COPY <<EOF /etc/supervisor/conf.d/supervisord.conf
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
environment=BACKEND_PORT="8483",BACKEND_HOST="0.0.0.0",TRANSCRIBER_TYPE="fast-whisper",WHISPER_MODEL_SIZE="tiny",FFMPEG_BIN_PATH="",HF_ENDPOINT="https://hf-mirror.com",STATIC="/static",OUT_DIR="./static/screenshots",DATA_DIR="data",NOTE_OUTPUT_DIR="data/note_results",DATABASE_URL="sqlite:////app/backend/data/bili_note.db",IMAGE_BASE_URL="/static/screenshots",ENV="production",GROQ_TRANSCRIBER_MODEL="whisper-large-v3-turbo"
[program:nginx]
command=nginx -g "daemon off;"
@@ -107,7 +124,7 @@ stdout_logfile=/var/log/supervisor/backend.log
stderr_logfile=/var/log/supervisor/backend.log
autorestart=true
priority=20
environment=BACKEND_PORT="8483",BACKEND_HOST="0.0.0.0"
environment=BACKEND_PORT="%(ENV_BACKEND_PORT)s",BACKEND_HOST="%(ENV_BACKEND_HOST)s",TRANSCRIBER_TYPE="%(ENV_TRANSCRIBER_TYPE)s",WHISPER_MODEL_SIZE="%(ENV_WHISPER_MODEL_SIZE)s",FFMPEG_BIN_PATH="%(ENV_FFMPEG_BIN_PATH)s",HF_ENDPOINT="%(ENV_HF_ENDPOINT)s",STATIC="%(ENV_STATIC)s",OUT_DIR="%(ENV_OUT_DIR)s",DATA_DIR="%(ENV_DATA_DIR)s",NOTE_OUTPUT_DIR="%(ENV_NOTE_OUTPUT_DIR)s",DATABASE_URL="%(ENV_DATABASE_URL)s",IMAGE_BASE_URL="%(ENV_IMAGE_BASE_URL)s",ENV="%(ENV_ENV)s",GROQ_TRANSCRIBER_MODEL="%(ENV_GROQ_TRANSCRIBER_MODEL)s"
EOF
# 修改 nginx 配置以使用本地 backend
@@ -115,5 +132,9 @@ RUN sed -i 's/proxy_pass http:\/\/backend:8483/proxy_pass http:\/\/127.0.0.1:848
sed -i 's/proxy_pass http:\/\/frontend:80/proxy_pass http:\/\/127.0.0.1:8080/g' /etc/nginx/conf.d/default.conf
# 启动 supervisor
# 推荐启动方式(覆盖默认 env
# docker run -d --name bilinote --env-file .env -p 8080:80 bilinote-aio
# 单个变量覆盖:
# docker run -d -e TRANSCRIBER_TYPE=groq -e WHISPER_MODEL_SIZE=base ...
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

181
README.md
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 v2.2.3</h1>
<h1 align="center" > BiliNote v2.4.0</h1>
</div>
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
@@ -18,23 +18,51 @@
<img src="https://img.shields.io/github/stars/jefferyhcool/BiliNote?style=social" />
</p>
<p align="center">
<a href="https://www.bilinote.app/"><b>🚀 BiliNote Pro · 在线版</b></a>
</p>
<p align="center">
<b>不想折腾部署?</b>访问 <a href="https://www.bilinote.app/"><b>www.bilinote.app</b></a> 即开即用 —— 免安装、免配置环境、免下模型,注册即可把视频转成笔记。
<br/>
本地部署遇到的依赖、代理、模型下载这些坑,云端版统统不用管。
</p>
<p align="center">
<a href="https://www.bilinote.app/">
<img src="https://img.shields.io/badge/%E7%AB%8B%E5%8D%B3%E4%BD%93%E9%AA%8C-BiliNote%20Pro-ff5c5c?style=for-the-badge" alt="立即体验 BiliNote Pro" />
</a>
</p>
## ✨ 项目简介
BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、YouTube、抖音等视频链接自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转、AI 问答等功能。
> 💡 **想直接用、不想本地部署?** —— [BiliNote Pro 在线版 www.bilinote.app](https://www.bilinote.app/) 已上线,云端托管、开箱即用,省去依赖安装 / 代理配置 / 模型下载的全部麻烦。
## 🌐 在线使用(推荐)
直接访问 **[www.bilinote.app](https://www.bilinote.app/)** 即可使用 BiliNote Pro 在线版,无需本地部署。
## 📝 使用文档
详细文档可以查看[这里](https://docs.bilinote.app/)
## 体验地址
可以通过访问 [这里](https://www.bilinote.app/) 进行体验,速度略慢,不支持长视频。
## 📦 桌面版下载
本项目提供了 Windows 和 macOS 桌面客户端,可在 [Releases](https://github.com/JefferyHcool/BiliNote/releases) 页面下载最新版本。
> Windows 用户请注意:一定要在没有中文路径的环境下运行。
## 💎 BiliNote AI笔记系统一对一搭建服务
提供 **BiliNote AI笔记系统一对一搭建服务**:专人一对一远程协助,从环境部署、模型配置到上手使用全程陪跑,帮你快速跑通整套系统。扫码添加微信,备注「搭建服务」即可咨询:
<table align="center">
<tr>
<td align="center"><img src="./doc/remote-install-wechat.png" alt="BiliNote AI笔记系统一对一搭建服务" width="220" /><br/>BiliNote AI笔记系统一对一搭建服务</td>
</tr>
</table>
## 🔧 功能特性
- 支持多平台Bilibili、YouTube、本地视频、抖音、快手
@@ -53,6 +81,14 @@ BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、Y
- 笔记顶部视频封面 Banner 展示
- 工作区和生成历史面板支持折叠/展开
### v2.3.0 新增
- 全局代理:一处配置同时作用于 AI 模型接口、转写接口Groq 等、YouTube 下载(设置 → 下载配置页),支持 `HTTP_PROXY` 环境变量兜底
- 转写模型就绪门禁:本地引擎模型没下载好时拦截视频任务,引导先去下载,不再静默卡在首次下载
- 桌面端后端健康监控韧性:退出自动清理 sidecar、启动失败展示原因 + 日志、不再无限「加载中」
- whisper 模型损坏自愈:`model.bin` 截断时自动删除重下;空 API Key / 新模型 temperature 不兼容给出清晰提示
- Docker 部署韧性:`BASE_REGISTRY` 可换国内镜像源、restart 策略修正、`.env.example` 端口与默认模型修正、新增部署 FAQ
### v2.2.3 修订
-vite build 在 CI 中报 'Rollup failed to resolve import @tauri-apps/api/event'(缺直接依赖声明)
@@ -137,22 +173,90 @@ docker pull ghcr.io/jefferyhcool/bilinote:latest
docker run -d -p 80:80 \
-v bilinote-data:/app/backend/data \
-v bilinote-config:/app/backend/config \
-v bilinote-static:/app/backend/static \
-v bilinote-models:/app/backend/models \
--name bilinote \
ghcr.io/jefferyhcool/bilinote:latest
```
上面四个卷分别持久化:`data`SQLite 数据库 + 生成的笔记)、`config`LLM 供应商配置 / Cookie / 转写设置)、`static`(笔记引用的视频截图)、`models`Whisper 模型缓存,可选,避免每次重新下载)。这样 `docker pull` 升级新镜像、删旧容器重建后,配置和历史都不会丢。
> ⚠️ **不要**用 `-v 卷名:/app/backend` 挂整个后端目录——命名卷会用首次启动时的镜像内容固化,之后 `docker pull` 升级也会被旧代码盖住,导致「升级不生效」。只挂上面这些数据子目录即可。
访问:`http://localhost`
也可以使用 docker-compose 本地构建:
```bash
# 标准部署
docker-compose up -d
cp .env.example .env # 第一次部署务必先创建 .env否则 BACKEND_PORT/APP_PORT 等变量为空会启动失败
docker-compose up --build -d
# GPU 加速部署(需要 NVIDIA GPU
docker-compose -f docker-compose.gpu.yml up -d
# GPU 加速部署(需要 NVIDIA GPU + NVIDIA Container Toolkit
docker-compose -f docker-compose.gpu.yml up --build -d
```
#### Docker 部署常见问题FAQ
社区反馈最集中的几个坑,遇到先按下面排查:
**0. 国内拉不到 docker.iobuild 阶段报 `dial tcp ... i/o timeout`**
`docker-compose build``python:3.11-slim` / `node:20-alpine` / `nginx:1.25-alpine` 时连 `auth.docker.io` 超时。三种解法,按推荐顺序:
- **方法 A直接用预构建镜像最省事**——不要本地 build跳到上面的 `docker pull ghcr.io/jefferyhcool/bilinote:latest` 路径ghcr.io 在国内通常比 docker.io 顺。
- **方法 B配置 Docker daemon 镜像加速器**——编辑 `~/.docker/daemon.json`Linux 在 `/etc/docker/daemon.json`),加:
```json
{
"registry-mirrors": ["https://docker.m.daocloud.io"]
}
```
然后重启 Docker Desktop / `sudo systemctl restart docker`。这是一劳永逸的做法。
- **方法 C临时切换 base image 镜像源**——本项目所有 Dockerfile 都暴露了 `BASE_REGISTRY` build-arg
```bash
BASE_REGISTRY=docker.m.daocloud.io docker-compose build
docker-compose up -d
```
或永久写到 `.env``echo 'BASE_REGISTRY=docker.m.daocloud.io' >> .env`。
注意Chinese 公共 docker 镜像源时常被关停2025-2026 之间可用的列表会变;如果 `docker.m.daocloud.io` 不通,搜一下"Docker 镜像加速 可用"找最新可用源即可。
**1. 容器一直 restart / unhealthy**
先看后端日志:
```bash
docker logs -f bilinote-backend
```
后端启动会按顺序打印 `[startup 1/5] ... [startup 5/5] 启动完成`。若日志卡在某一步或出现 `[startup FAILED]`,就是那一步的问题,常见:
- **卡在 `[startup 3/5]`**:转写器配置读不到。检查 `.env` 里 `TRANSCRIBER_TYPE` 是否写错,`mlx-whisper` 只能在 Apple Silicon 用Linux/Docker 请用 `fast-whisper` 或 `groq`。
- **首次跑视频时容器被 kill**whisper 模型下载触发 OOM。先把 `.env` 里 `WHISPER_MODEL_SIZE` 改成 `tiny`,跑通后再去前端「音频转写配置」里逐档升。
**2. 改了 `.env` 没生效**
区分两类变量:
- `VITE_*` 是**构建时**变量(前端 bundle 里硬编码),改完必须 `docker-compose build frontend && docker-compose up -d`。只 `restart` 不会重新打包。
- 其他后端变量(`TRANSCRIBER_TYPE`、`WHISPER_MODEL_SIZE`、`FFMPEG_BIN_PATH` 等)是**运行时**变量,改完 `docker-compose up -d` 即可。
注意:**LLM API key 不要写 `.env`**,从前端「模型供应商」页面录入,会保存到 SQLite 数据库并持久化。
**3. 数据存在哪?删容器会丢吗?**
`docker-compose` 用的是 `./backend:/app` 绑挂,下面这些文件都在宿主机的 `./backend/` 目录里、删容器不会丢:
- `./backend/bili_note.db` —— SQLite 库(含 LLM 供应商配置、笔记历史)
- `./backend/config/transcriber.json` —— 转写器运行时配置
- `./backend/static/screenshots/` —— 视频截图
- `./backend/uploads/` —— 上传的本地视频
要彻底重置就 `docker-compose down && rm backend/bili_note.db backend/config/transcriber.json`。
**4. 前端打开是空白页 / 报 502**
通常是 nginx 起来了但 backend 还没 healthy。`docker ps` 看 backend 容器 STATUS 是不是 `(healthy)`;若长期 `(unhealthy)`,按问题 1 排查后端日志。
**5. 不要用 `restart: on-failure:N`**
如果你 fork 后改过 compose 文件、把 restart 策略改成了 `on-failure:3`:任何 3 次连续崩溃都会让容器永远不再启动,之后改 `.env` 也没用。本项目自带的 compose 已经统一用 `unless-stopped`。
### 方式二:源码部署
#### 1. 克隆仓库
@@ -199,10 +303,42 @@ sudo apt install ffmpeg
>
> Docker 部署已内置 FFmpeg无需额外安装。
### 🚀 CUDA 加速(可选)
若你希望更快地执行音频转写任务,可使用具备 NVIDIA GPU 的机器,并启用 fast-whisper + CUDA 加速版本:
### 🚀 CUDA / GPU 加速(可选)
具体 `fast-whisper` 配置方法,请参考:[fast-whisper 项目地址](http://github.com/SYSTRAN/faster-whisper#requirements)
本地 **Faster Whisper** 转写可用 NVIDIA GPU 加速(在线引擎 Groq / 必剪 / 快手 与 GPU 无关)。仓库已自带 GPU 镜像与编排,**无需改代码、无需手动配置 device**——后端会自动检测 CUDA可用就走 GPU否则回退 CPU。
**1. 宿主机前提**
- NVIDIA 显卡 + 较新驱动CUDA ≥ 12.4),宿主机 `nvidia-smi` 能正常输出;
- 安装 [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)(最易漏的一步,没它 Docker 进不去 GPU。装完验证
```bash
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
```
能列出显卡即 OK。
**2. 切换到 GPU 编排**(在源码目录里)
CPU 与 GPU 两套 compose 用了相同的容器名,先停掉当前栈再起 GPU 栈:
```bash
docker-compose down # 停掉当前CPU
docker-compose -f docker-compose.gpu.yml up --build -d # 用 GPU 栈重建
```
- GPU 栈用 `backend/Dockerfile.gpu`CUDA 12.4.1 + cuDNN 基础镜像,并额外装 torch 用于 CUDA 检测compose 已声明 `deploy...devices: nvidia` 自动透传 GPU。
- **数据不丢**:两套 compose 都把 `./backend` 整目录绑挂进容器,数据库 / 配置 / 已下载模型都保留。
- 首次构建较大较慢CUDA 基础镜像数 GB + torch耐心等。
**3. 启用并确认**
- 「设置 → 音频转写配置」转写引擎选 **Faster Whisper本地**GPU 下可放心选大模型(如 `large-v3`)。
- 确认真的走了 GPU`docker logs bilinote-backend | grep -i cuda` 看到 `CUDA 可用,使用 GPU`;或转写时宿主机 `nvidia-smi` 能看到 python 进程占显存。
**国内镜像**GPU compose 支持 `BASE_REGISTRY` / `APT_MIRROR` / `PIP_INDEX` 这几个 build-arg注意 `BASE_REGISTRY` 选的源必须支持 `nvidia/cuda` 命名空间,否则拉不到 CUDA 基础镜像)。
**起来了但没走 GPU** 依次排查:① 宿主机 `nvidia-smi` 是否正常 → ② NVIDIA Container Toolkit 是否装好(上面 `--gpus all` 测试是否通过)→ ③ `docker logs bilinote-backend` 是否有 CUDA / cuDNN 报错(驱动 CUDA 版本需 ≥ 12.4)。
`fast-whisper` 本身的 GPU 依赖说明可参考:[faster-whisper 项目](https://github.com/SYSTRAN/faster-whisper#requirements)
### 🐳 使用 Docker 一键部署
@@ -215,10 +351,17 @@ docker pull ghcr.io/jefferyhcool/bilinote:latest
# 运行容器
docker run -d -p 80:80 \
-v bilinote-data:/app/backend/data \
-v bilinote-config:/app/backend/config \
-v bilinote-static:/app/backend/static \
-v bilinote-models:/app/backend/models \
--name bilinote \
ghcr.io/jefferyhcool/bilinote:latest
```
上面四个卷分别持久化:`data`SQLite 数据库 + 生成的笔记)、`config`LLM 供应商配置 / Cookie / 转写设置)、`static`(笔记引用的视频截图)、`models`Whisper 模型缓存,可选,避免每次重新下载)。这样 `docker pull` 升级新镜像、删旧容器重建后,配置和历史都不会丢。
> ⚠️ **不要**用 `-v 卷名:/app/backend` 挂整个后端目录——命名卷会用首次启动时的镜像内容固化,之后 `docker pull` 升级也会被旧代码盖住,导致「升级不生效」。只挂上面这些数据子目录即可。
访问:`http://localhost`
也可以使用 docker-compose 本地构建:
@@ -227,8 +370,8 @@ docker run -d -p 80:80 \
# 标准部署
docker-compose up -d
# GPU 加速部署(需要 NVIDIA GPU
docker-compose -f docker-compose.gpu.yml up -d
# GPU 加速部署(需要 NVIDIA GPU + NVIDIA Container Toolkit详见上方「CUDA / GPU 加速」)
docker-compose -f docker-compose.gpu.yml up --build -d
```
## 🧠 TODO
@@ -243,11 +386,13 @@ docker-compose -f docker-compose.gpu.yml up -d
### Contact and Join-联系和加入社区
扫码加入 BiliNote 交流微信群(如二维码失效,请到 [Issues](https://github.com/JefferyHcool/BiliNote/issues) 反馈
扫描下方公众号二维码,关注后回复 **「交流群」** 即可获取最新的微信交流群二维码(群码会自动更新,避免过期失效
<p align="center">
<img src="./doc/wechat.png" alt="BiliNote 交流微信群" width="240" />
</p>
<table align="center">
<tr>
<td align="center"><img src="./doc/wechat-gzh.png" alt="BiliNote 公众号" width="200" /><br/>BiliNote 公众号</td>
</tr>
</table>

View File

@@ -1,4 +1,8 @@
FROM python:3.11-slim
# BASE_REGISTRY 默认走 docker.io国内拉不到 docker.io 时可换 daocloud / 阿里云 / 自建镜像源:
# docker-compose build --build-arg BASE_REGISTRY=docker.m.daocloud.io
# 或写到 docker-compose.yml 的 build.args / 环境变量里
ARG BASE_REGISTRY=docker.io
FROM ${BASE_REGISTRY}/library/python:3.11-slim
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple

View File

@@ -1,4 +1,6 @@
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
# BASE_REGISTRY 默认走 docker.io国内可换 daocloud / 阿里云镜像(注意所选镜像需支持 nvidia/cuda 命名空间)
ARG BASE_REGISTRY=docker.io
FROM ${BASE_REGISTRY}/nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple

View File

@@ -1,6 +1,9 @@
import base64
import os
import json
import logging
import random
import string
import tempfile
from abc import ABC
from typing import Union, Optional, List
@@ -18,6 +21,54 @@ from app.services.cookie_manager import CookieConfigManager
logger = logging.getLogger(__name__)
def _patch_bilibili_extractor():
"""
Monkey-patch yt-dlp's BilibiliBaseIE._download_playinfo to inject the
dm_img_* / web_location risk-control parameters that Bilibili's
wbi/playurl gateway started requiring (returns HTTP 412 without them).
The parameter format (string.printable source + [:-2] base64 truncation)
mirrors yt-dlp's own implementation for the same fields in the channel
search endpoint (yt_dlp/extractor/bilibili.py, BiliBiliSpaceIE).
"""
try:
from yt_dlp.extractor.bilibili import BilibiliBaseIE
# Guard: skip if already patched (e.g. module reloaded in tests)
if getattr(BilibiliBaseIE._download_playinfo, '_bili_dm_patched', False):
return
_original_download_playinfo = BilibiliBaseIE._download_playinfo
def _patched_download_playinfo(self, bvid, cid, headers=None, query=None):
# Inject dummy risk-control fingerprints expected by Bilibili's gateway.
# The [:-2] truncation and string.printable source intentionally match
# yt-dlp's own pattern used for the x/space/wbi/arc/search endpoint.
extra = {
'web_location': 1550101,
'dm_img_list': '[]',
'dm_img_str': base64.b64encode(
''.join(random.choices(string.printable, k=random.randint(16, 64))).encode()
)[:-2].decode(),
'dm_cover_img_str': base64.b64encode(
''.join(random.choices(string.printable, k=random.randint(32, 128))).encode()
)[:-2].decode(),
'dm_img_inter': '{"ds":[],"wh":[6093,6631,31],"of":[430,760,380]}',
}
# Caller-supplied query params take precedence over the dummy values
merged_query = {**extra, **(query or {})}
return _original_download_playinfo(self, bvid, cid, headers=headers, query=merged_query)
_patched_download_playinfo._bili_dm_patched = True
BilibiliBaseIE._download_playinfo = _patched_download_playinfo
logger.info("Applied Bilibili wbi/playurl dm_img patch to yt-dlp BilibiliBaseIE")
except Exception as e:
logger.warning("Failed to apply Bilibili dm_img patch: %s", e)
_patch_bilibili_extractor()
class BilibiliDownloader(Downloader, ABC):
def __init__(self):
super().__init__()

View File

@@ -9,12 +9,22 @@ from app.downloaders.base import Downloader, DownloadQuality
from app.downloaders.youtube_subtitle import YouTubeSubtitleFetcher
from app.models.notes_model import AudioDownloadResult
from app.models.transcriber_model import TranscriptResult
from app.services.proxy_config_manager import ProxyConfigManager
from app.utils.path_helper import get_data_dir
from app.utils.url_parser import extract_video_id
logger = logging.getLogger(__name__)
def _apply_proxy(ydl_opts: dict) -> dict:
"""YouTube 在国内需要代理。配置了全局代理就塞进 yt-dlp opts。"""
proxy = ProxyConfigManager().get_proxy_url()
if proxy:
ydl_opts['proxy'] = proxy
logger.info(f"yt-dlp 走代理: {proxy}")
return ydl_opts
class YoutubeDownloader(Downloader, ABC):
def __init__(self):
@@ -46,6 +56,7 @@ class YoutubeDownloader(Downloader, ABC):
if skip_download:
ydl_opts['skip_download'] = True
_apply_proxy(ydl_opts)
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=not skip_download)
video_id = info.get("id")
@@ -91,6 +102,7 @@ class YoutubeDownloader(Downloader, ABC):
'merge_output_format': 'mp4', # 确保合并成 mp4
}
_apply_proxy(ydl_opts)
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
video_id = info.get("id")

View File

@@ -8,6 +8,7 @@ from typing import Optional, List
from youtube_transcript_api import YouTubeTranscriptApi
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
from app.services.proxy_config_manager import ProxyConfigManager
from app.utils.logger import get_logger
logger = get_logger(__name__)
@@ -17,7 +18,21 @@ class YouTubeSubtitleFetcher:
"""通过 youtube-transcript-api 获取 YouTube 字幕。"""
def __init__(self):
self._api = YouTubeTranscriptApi()
# 配了全局代理就给 youtube-transcript-api 套一个带 proxies 的 requests.Session
# 否则国内拉字幕同样会超时。代理未配置时退回默认无代理客户端。
proxy = ProxyConfigManager().get_proxy_url()
if proxy:
try:
import requests
session = requests.Session()
session.proxies = {"http": proxy, "https": proxy}
self._api = YouTubeTranscriptApi(http_client=session)
logger.info(f"YouTube 字幕走代理: {proxy}")
except Exception as e:
logger.warning(f"为 youtube-transcript-api 注入代理失败,回退无代理: {e}")
self._api = YouTubeTranscriptApi()
else:
self._api = YouTubeTranscriptApi()
def fetch_subtitles(
self,

View File

@@ -1,6 +1,6 @@
from typing import List
from app.gpt.base import GPT
from openai import OpenAI
from app.utils.openai_client import build_openai_client
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT
from app.gpt.utils import fix_markdown
from app.models.gpt_model import GPTSource
@@ -15,7 +15,7 @@ class DeepSeekGPT(GPT):
self.base_url = getenv("DEEP_SEEK_API_BASE_URL")
self.model=getenv('DEEP_SEEK_MODEL')
print(self.model)
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
self.client = build_openai_client(self.api_key, self.base_url, key_label="DeepSeek 的 API Key")
self.screenshot = False
def _format_time(self, seconds: float) -> str:

View File

@@ -1,13 +1,13 @@
from typing import Optional, Union
from openai import OpenAI
from app.utils.logger import get_logger
from app.utils.openai_client import build_openai_client
logging= get_logger(__name__)
class OpenAICompatibleProvider:
def __init__(self, api_key: str, base_url: str, model: Union[str, None]=None):
self.client = OpenAI(api_key=api_key, base_url=base_url)
# build_openai_client注入全局代理 + 校验 api_key 非空
self.client = build_openai_client(api_key, base_url, key_label="模型供应商的 API Key")
self.model = model
@property
@@ -15,17 +15,27 @@ class OpenAICompatibleProvider:
return self.client
@staticmethod
def test_connection(api_key: str, base_url: str) -> bool:
def test_connection(api_key: str, base_url: str, model: str) -> bool:
"""发一条最小化 chat completion 验证 key / base_url / model 三方都通。
为什么不用 client.models.list()
- 部分代理 / 自建供应商不实现 /v1/models如某些 OpenAI 兼容网关)
- 部分供应商 key 在没有 inference 权限时 /v1/models 仍返回 200
最终用户跑的就是 chat.completions.create所以直接测它最忠实。
max_tokens=1 + temperature=0 让请求开销 < 0.0001 美元、延迟 < 2s。
"""
try:
client = OpenAI(api_key=api_key, base_url=base_url)
model = client.models.list()
# for segment in model:
# print(segment)
# print(model)
logging.info("连通性测试成功")
client = build_openai_client(
api_key, base_url, key_label="模型供应商的 API Key", timeout=15.0,
)
client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "ping"}],
max_tokens=1,
temperature=0,
)
logging.info(f"连通性测试成功model={model}")
return True
except Exception as e:
logging.info(f"连通性测试失败:{e}")
# print(f"Error connecting to OpenAI API: {e}")
logging.warning(f"连通性测试失败model={model}{e}")
return False

View File

@@ -185,15 +185,40 @@ class UniversalGPT(GPT):
status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
return status in {408, 409, 429, 500, 502, 503, 504, 524}
@staticmethod
def _is_temperature_unsupported_error(exc: Exception) -> bool:
"""OpenAI o1/o3/gpt-5 系列等新模型不接受自定义 temperature
只允许默认值 1传 0.7 会报 `'temperature' does not support 0.7 ...`。"""
raw = str(exc).lower()
return "temperature" in raw and (
"does not support" in raw
or "unsupported_value" in raw
or "only the default" in raw
)
def _do_create(self, messages: list):
"""单次调用。如果模型拒绝自定义 temperature就地去掉该参数再试一次
(不消耗外层的重试次数预算),仍失败则把异常抛给外层重试逻辑。"""
try:
return self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=self.temperature,
)
except Exception as exc:
if self._is_temperature_unsupported_error(exc):
print(f"[universal_gpt] 模型 {self.model} 不支持自定义 temperature改用默认值重试")
return self.client.chat.completions.create(
model=self.model,
messages=messages,
)
raise
def _chat_completion_create(self, messages: list):
last_exc = None
for attempt in range(self._max_retry_attempts):
try:
return self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=self.temperature
)
return self._do_create(messages)
except Exception as exc:
last_exc = exc
if attempt == self._max_retry_attempts - 1 or not self._is_retryable_error(exc):

View File

@@ -61,16 +61,53 @@ WHISPER_MODEL_SIZES = ["tiny", "base", "small", "medium", "large-v3", "large-v3-
@router.get("/transcriber_config")
def get_transcriber_config():
from app.transcriber.transcriber_provider import MLX_WHISPER_AVAILABLE
from app.transcriber.whisper_models import get_registry, BUILTIN_WHISPER_MODELS
registry = get_registry()
config = transcriber_config_manager.get_config()
return R.success(data={
**config,
"available_types": AVAILABLE_TRANSCRIBER_TYPES,
"whisper_model_sizes": WHISPER_MODEL_SIZES,
# 内置可见档位 + 用户自定义模型,供前端下拉
"whisper_model_sizes": registry.visible_model_names(),
"whisper_builtin_models": BUILTIN_WHISPER_MODELS,
"whisper_custom_models": registry.get_custom_models(),
"mlx_whisper_available": MLX_WHISPER_AVAILABLE,
})
class WhisperCustomModelRequest(BaseModel):
name: str
target: str # HF repo_id如 Systran/faster-whisper-large-v3或本地模型目录路径
@router.get("/whisper_models")
def list_whisper_models():
"""列出内置 + 用户自定义的 whisper 模型映射。"""
from app.transcriber.whisper_models import get_registry, BUILTIN_WHISPER_MODELS
reg = get_registry()
return R.success(data={"builtin": BUILTIN_WHISPER_MODELS, "custom": reg.get_custom_models()})
@router.post("/whisper_models")
def add_whisper_model(data: WhisperCustomModelRequest):
"""新增自定义 whisper 模型映射(名称 → HF repo_id 或本地路径)。"""
from app.transcriber.whisper_models import get_registry
try:
custom = get_registry().add_custom_model(data.name, data.target)
except ValueError as e:
return R.error(msg=str(e))
return R.success(data={"custom": custom}, msg="已添加自定义模型")
@router.delete("/whisper_models/{name}")
def delete_whisper_model(name: str):
"""删除自定义 whisper 模型映射(不会删除已下载的模型文件)。"""
from app.transcriber.whisper_models import get_registry
custom = get_registry().remove_custom_model(name)
return R.success(data={"custom": custom}, msg="已删除自定义模型")
@router.post("/transcriber_config")
def update_transcriber_config(data: TranscriberConfigRequest):
config = transcriber_config_manager.update_config(
@@ -80,6 +117,36 @@ def update_transcriber_config(data: TranscriberConfigRequest):
return R.success(data=config)
# ---- 全局代理配置(作用于 LLM API + 转写 API + yt-dlp 下载)----
class ProxyConfigRequest(BaseModel):
enabled: bool
url: Optional[str] = None
@router.get("/proxy_config")
def get_proxy_config():
from app.services.proxy_config_manager import ProxyConfigManager
mgr = ProxyConfigManager()
cfg = mgr.get_config()
# effective 给前端展示「当前实际生效的代理」——可能来自配置,也可能来自 env 兜底
return R.success(data={
**cfg,
"effective": mgr.get_proxy_url() or "",
})
@router.post("/proxy_config")
def update_proxy_config(data: ProxyConfigRequest):
from app.services.proxy_config_manager import ProxyConfigManager
mgr = ProxyConfigManager()
cfg = mgr.update_config(enabled=data.enabled, url=data.url)
return R.success(data={
**cfg,
"effective": mgr.get_proxy_url() or "",
})
# ---- Whisper 模型下载状态 & 下载触发 ----
# 用于跟踪正在进行的下载任务
@@ -87,17 +154,63 @@ _downloading: dict[str, str] = {} # model_size -> status ("downloading" | "done
def _check_whisper_model_exists(model_size: str, subdir: str = "whisper") -> bool:
"""检查指定 whisper 模型是否已下载到本地。"""
model_dir = get_model_dir(subdir)
model_path = os.path.join(model_dir, f"whisper-{model_size}")
return Path(model_path).exists()
"""检查指定 whisper 模型是否已下载完整到本地。
先把模型名 resolve 成可加载标识,再按类型判定:
- 本地路径模型 → 直接看该目录下有没有 model.bin
- HF repo_id → 看 HF cache 布局
<model_dir>/models--{org}--{name}/snapshots/<hash>/model.bin
(历史 modelscope 布局 <model_dir>/whisper-{size}/model.bin 也兼容识别)
"""
from app.transcriber.whisper_models import (
resolve_whisper_model,
is_local_target,
hf_cache_dirname,
)
try:
target = resolve_whisper_model(model_size)
except Exception:
return False
if is_local_target(target):
return (Path(target) / "model.bin").exists()
model_dir = Path(get_model_dir(subdir))
# HF cache 布局(适配任意 org/repo不再写死 Systran
hf_repo_dir = model_dir / hf_cache_dirname(target) / "snapshots"
if hf_repo_dir.exists():
for snapshot in hf_repo_dir.iterdir():
if (snapshot / "model.bin").exists():
return True
# 历史 modelscope 布局(向后兼容老用户)
legacy = model_dir / f"whisper-{model_size}" / "model.bin"
return legacy.exists()
def _check_mlx_whisper_model_exists(model_size: str) -> bool:
"""检查 mlx-whisper 模型是否已下载完整到本地。
与 fast-whisper 的目录布局不同mlx 模型按 HuggingFace repo_id
(如 mlx-community/whisper-tiny-mlx落盘且没有 model.bin
用 config.json 作为「下载完成」的判据,和 mlx_whisper_transcriber.py 保持一致。
"""
try:
from app.transcriber.mlx_whisper_transcriber import MLX_MODEL_MAP
except Exception:
return False
repo_id = MLX_MODEL_MAP.get(model_size)
if not repo_id:
return False
model_dir = get_model_dir("mlx-whisper")
model_path = os.path.join(model_dir, repo_id)
return (Path(model_path) / "config.json").exists()
@router.get("/transcriber_models_status")
def get_transcriber_models_status():
"""返回所有 whisper 模型的下载状态。"""
"""返回所有 whisper 模型的下载状态(含用户自定义模型)"""
from app.transcriber.whisper_models import get_registry
statuses = []
for size in WHISPER_MODEL_SIZES:
for size in get_registry().visible_model_names():
downloaded = _check_whisper_model_exists(size, "whisper")
download_status = _downloading.get(size)
statuses.append({
@@ -113,11 +226,9 @@ def get_transcriber_models_status():
from app.transcriber.mlx_whisper_transcriber import MLX_MODEL_MAP
for size in WHISPER_MODEL_SIZES:
mlx_key = f"mlx-{size}"
model_dir = get_model_dir("mlx-whisper")
repo_id = MLX_MODEL_MAP.get(size)
# 模型在本地按 repo_id如 mlx-community/whisper-small-mlx落盘
model_path = os.path.join(model_dir, repo_id) if repo_id else None
downloaded = bool(model_path and Path(model_path).exists())
# 用 config.json 判定,和 _check_mlx_whisper_model_exists / 加载逻辑保持一致
downloaded = _check_mlx_whisper_model_exists(size)
mlx_statuses.append({
"model_size": size,
"downloaded": downloaded,
@@ -138,23 +249,48 @@ class ModelDownloadRequest(BaseModel):
def _do_download_whisper(model_size: str):
"""后台下载 faster-whisper 模型"""
from app.transcriber.whisper import MODEL_MAP
from modelscope import snapshot_download
"""后台下载 faster-whisper 模型(支持内置 size / 自定义 repo_id / 本地路径)。
模型名先 resolve
- 本地路径模型:无需下载,目录里有 model.bin 即 done否则 failed
- HF repo_idsnapshot_download 到 HF cache 布局cache_dir=model_dir
与加载逻辑 WhisperModel(download_root=model_dir) 完全对齐。
"""
from huggingface_hub import snapshot_download
from app.transcriber.whisper_models import resolve_whisper_model, is_local_target
try:
_downloading[model_size] = "downloading"
model_dir = get_model_dir("whisper")
model_path = os.path.join(model_dir, f"whisper-{model_size}")
if Path(model_path).exists():
# 已经下好就不重复下
if _check_whisper_model_exists(model_size, "whisper"):
_downloading[model_size] = "done"
return
repo_id = MODEL_MAP.get(model_size)
if not repo_id:
_downloading[model_size] = "failed"
target = resolve_whisper_model(model_size)
if is_local_target(target):
# 本地模型不下载,只校验 model.bin 是否就位
ok = (Path(target) / "model.bin").exists()
_downloading[model_size] = "done" if ok else "failed"
if not ok:
logger.warning(f"本地模型 {model_size} 路径 {target} 下没有 model.bin无法使用")
return
logger.info(f"开始下载 whisper 模型: {model_size}")
snapshot_download(repo_id, local_dir=model_path)
logger.info(f"开始下载 whisper 模型: {model_size}{target}")
# 跟 faster-whisper utils.py 用同样的 allow_patterns避免多下无关文件
# 不传 local_dir 让它走 HF 默认 cache 布局(与加载逻辑对齐)
snapshot_download(
target,
cache_dir=model_dir,
allow_patterns=[
"config.json",
"preprocessor_config.json",
"model.bin",
"tokenizer.json",
"vocabulary.*",
],
)
logger.info(f"whisper 模型下载完成: {model_size}")
_downloading[model_size] = "done"
except Exception as e:
@@ -179,7 +315,8 @@ def _do_download_mlx_whisper(model_size: str):
model_dir = get_model_dir("mlx-whisper")
model_path = os.path.join(model_dir, repo_id)
if Path(model_path).exists():
# 用 config.json 判定而非目录存在:半成品目录不能算「已下载」
if (Path(model_path) / "config.json").exists():
_downloading[key] = "done"
return
logger.info(f"开始下载 mlx-whisper 模型: {model_size}{repo_id}")
@@ -193,11 +330,11 @@ def _do_download_mlx_whisper(model_size: str):
@router.post("/transcriber_download")
def download_transcriber_model(data: ModelDownloadRequest, background_tasks: BackgroundTasks):
"""触发后台下载指定的 whisper 模型。"""
if data.model_size not in WHISPER_MODEL_SIZES:
return R.error(msg=f"不支持的模型大小: {data.model_size}")
"""触发后台下载指定的 whisper 模型fast-whisper 支持内置档位 + 自定义模型)"""
if data.transcriber_type == "mlx-whisper":
# mlx 只认内置档位mlx-community 的固定映射)
if data.model_size not in WHISPER_MODEL_SIZES:
return R.error(msg=f"MLX 不支持的模型大小: {data.model_size}")
if platform.system() != "Darwin":
return R.error(msg="MLX Whisper 仅支持 macOS")
key = f"mlx-{data.model_size}"
@@ -205,6 +342,10 @@ def download_transcriber_model(data: ModelDownloadRequest, background_tasks: Bac
return R.success(msg="模型正在下载中")
background_tasks.add_task(_do_download_mlx_whisper, data.model_size)
else:
# fast-whisper内置档位 / 自定义 repo_id / 本地路径都允许
from app.transcriber.whisper_models import get_registry
if not get_registry().is_known(data.model_size):
return R.error(msg=f"不支持的模型: {data.model_size}(请先在自定义模型中登记)")
if _downloading.get(data.model_size) == "downloading":
return R.success(msg="模型正在下载中")
background_tasks.add_task(_do_download_whisper, data.model_size)
@@ -214,46 +355,119 @@ def download_transcriber_model(data: ModelDownloadRequest, background_tasks: Bac
@router.get("/sys_health")
async def sys_health():
"""结构化健康状态——任何子项异常都不应让整个 endpoint 5xx。
每个字段:'ok' | 'missing' | 'error'
前端 useCheckBackend 用 /sys_check 做存活判定(不依赖外部依赖),
/sys_health 用来在设置页区分「后端没起」vs「后端起了但 ffmpeg 缺」vs「DB 写不进去」等更细的状态。
"""
ffmpeg_status = "ok"
try:
ensure_ffmpeg_or_raise()
return R.success()
except EnvironmentError:
return R.error(msg="系统未安装 ffmpeg 请先进行安装")
except Exception:
ffmpeg_status = "missing"
db_status = "ok"
try:
from app.db.engine import engine
from sqlalchemy import text
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
except Exception:
db_status = "error"
# 当前转写器配置 + 模型是否已下载(用 model.bin 落盘判定,与 transcriber 加载逻辑一致)
whisper_info: dict = {"size": None, "type": None, "downloaded": False, "checked": False}
try:
cfg = transcriber_config_manager.get_config()
size = cfg["whisper_model_size"]
ttype = cfg["transcriber_type"]
whisper_info["size"] = size
whisper_info["type"] = ttype
# 只有本地引擎才有「下载」概念groq / bcut / kuaishou 在线引擎跳过
if ttype == "fast-whisper":
whisper_info["downloaded"] = _check_whisper_model_exists(size, "whisper")
whisper_info["checked"] = True
elif ttype == "mlx-whisper":
whisper_info["downloaded"] = _check_mlx_whisper_model_exists(size)
whisper_info["checked"] = True
except Exception:
pass
return R.success(data={
"backend": "ok",
"ffmpeg": ffmpeg_status,
"db": db_status,
"whisper_model": whisper_info,
})
@router.get("/sys_check")
async def sys_check():
"""轻量存活判定:后端进程能响应这个 endpoint 就算「起来了」,不查外部依赖。
给桌面端 useCheckBackend / Tauri ready-probe 用。
"""
return R.success()
@router.get("/deploy_status")
async def deploy_status():
"""返回部署监控所需的所有状态信息"""
import torch
"""返回部署监控所需的所有状态信息
所有子项都用 try 包起来——监控页本身不应该被任何一个子项打死。
特别是 torch它只在 fast-whisper 路径用得到,用 Groq / 必剪 / 快手在线
引擎的轻量部署完全可以不装,那种情况这个 endpoint 不应该 500。
"""
import os
# CUDA 状态
cuda_available = torch.cuda.is_available()
cuda_info = {
"available": cuda_available,
"version": torch.version.cuda if cuda_available else None,
"gpu_name": torch.cuda.get_device_name(0) if cuda_available else None,
}
# Whisper 模型状态(从配置文件读取,与前端设置同步)
transcriber_cfg = transcriber_config_manager.get_config()
model_size = transcriber_cfg["whisper_model_size"]
transcriber_type = transcriber_cfg["transcriber_type"]
try:
import torch
cuda_available = torch.cuda.is_available()
cuda_info = {
"available": cuda_available,
"torch_installed": True,
"version": torch.version.cuda if cuda_available else None,
"gpu_name": torch.cuda.get_device_name(0) if cuda_available else None,
}
except Exception:
cuda_info = {
"available": False,
"torch_installed": False,
"version": None,
"gpu_name": None,
}
# Whisper 模型 / 转写器配置 + 本地下载状态
try:
transcriber_cfg = transcriber_config_manager.get_config()
size = transcriber_cfg["whisper_model_size"]
ttype = transcriber_cfg["transcriber_type"]
if ttype == "fast-whisper":
downloaded = _check_whisper_model_exists(size, "whisper")
elif ttype == "mlx-whisper":
downloaded = _check_mlx_whisper_model_exists(size)
else:
downloaded = False # 在线引擎无下载概念
whisper_info = {
"model_size": size,
"transcriber_type": ttype,
"downloaded": downloaded,
}
except Exception:
whisper_info = {"model_size": None, "transcriber_type": None, "downloaded": False}
# FFmpeg 状态
try:
ensure_ffmpeg_or_raise()
ffmpeg_ok = True
except:
except Exception:
ffmpeg_ok = False
return R.success(data={
"backend": {"status": "running", "port": int(os.getenv("BACKEND_PORT", 8483))},
"cuda": cuda_info,
"whisper": {"model_size": model_size, "transcriber_type": transcriber_type},
"whisper": whisper_info,
"ffmpeg": {"available": ffmpeg_ok},
})

View File

@@ -180,6 +180,24 @@ async def upload(file: UploadFile = File(...)):
@router.post("/generate_note")
def generate_note(data: VideoRequest, background_tasks: BackgroundTasks):
try:
# 就绪门禁本地转写引擎fast-whisper / mlx-whisper必须等模型下载完才能跑视频
# 否则任务会卡在首次下载(慢 / OOM / 截断),用户只看到一个静默失败的任务。
# 客户端已抓好字幕prefetched_transcript则不需要转写跳过检查。
if not data.prefetched_transcript:
from app.services.transcriber_config_manager import TranscriberConfigManager
readiness = TranscriberConfigManager().is_model_ready()
if not readiness["ready"]:
logger.warning(f"拒绝 generate_note{readiness['reason']}")
return R.error(
msg=readiness["reason"],
code=300102,
data={
"reason": "transcriber_model_not_ready",
"transcriber_type": readiness["transcriber_type"],
"model_size": readiness["model_size"],
"downloading": readiness["downloading"],
},
)
video_id = extract_video_id(data.video_url, data.platform)
# if not video_id:

View File

@@ -20,6 +20,8 @@ class ProviderRequest(BaseModel):
class TestRequest(BaseModel):
id: str
# 可选:指定用哪个 model 跑连通性测试;不传则用该 provider 在 DB 里的第一个模型
model: Optional[str] = None
class ProviderUpdateRequest(BaseModel):
id: str
name: Optional[str] = None
@@ -91,5 +93,5 @@ def update_provider(data: ProviderUpdateRequest):
@router.post('/connect_test')
def gpt_connect_test(data: TestRequest):
ModelService().connect_test(data.id)
ModelService().connect_test(data.id, model=data.model)
return R.success(msg='连接成功')

View File

@@ -100,23 +100,46 @@ class ModelService:
logger.error(f"[{provider_id}] 获取模型失败: {e}")
return []
@staticmethod
def connect_test(id: str) -> bool:
def connect_test(id: str, model: str | None = None) -> bool:
"""连通性测试:发一条最小化 chat completion。
model 优先级:
1. 调用方显式传入前端可在「模型选择」UI 里挑一个再测)
2. DB 中该 provider 已保存的第一个模型
3. 都没有 → 抛错让用户先加一个模型
"""
provider = ProviderService.get_provider_by_id(id)
if provider:
if not provider.get('api_key'):
raise ProviderError(code=ProviderErrorEnum.NOT_FOUND.code, message=ProviderErrorEnum.NOT_FOUND.message)
result = OpenAICompatibleProvider.test_connection(
api_key=provider.get('api_key'),
base_url=provider.get('base_url')
if not provider:
raise ProviderError(
code=ProviderErrorEnum.NOT_FOUND.code,
message=ProviderErrorEnum.NOT_FOUND.message,
)
if not provider.get('api_key'):
raise ProviderError(
code=ProviderErrorEnum.NOT_FOUND.code,
message=ProviderErrorEnum.NOT_FOUND.message,
)
if result:
return True
else:
raise ProviderError(code=ProviderErrorEnum.WRONG_PARAMETER.code,message=ProviderErrorEnum.WRONG_PARAMETER.message)
raise ProviderError(code=ProviderErrorEnum.NOT_FOUND.code, message=ProviderErrorEnum.NOT_FOUND.message)
if not model:
saved_models = ModelService.get_enabled_models_by_provider(provider["id"])
if not saved_models:
raise ProviderError(
code=ProviderErrorEnum.WRONG_PARAMETER.code,
message="请先为该供应商添加至少一个模型再测试连通性",
)
model = saved_models[0]["model_name"]
ok = OpenAICompatibleProvider.test_connection(
api_key=provider.get('api_key'),
base_url=provider.get('base_url'),
model=model,
)
if ok:
return True
raise ProviderError(
code=ProviderErrorEnum.WRONG_PARAMETER.code,
message=ProviderErrorEnum.WRONG_PARAMETER.message,
)

View File

@@ -129,6 +129,10 @@ class ProviderService:
try:
# 过滤掉空值
filtered_data = {k: v for k, v in data.items() if v is not None and k != 'id'}
# 防御掩码污染:前端展示时 api_key 被 mask_key() 处理过(如 a92f****...2d3a
# 如果用户未重新输入直接保存,带星号的值不应覆盖原 key。
if 'api_key' in filtered_data and '*' in str(filtered_data.get('api_key', '')):
filtered_data.pop('api_key')
print('更新模型供应商',filtered_data)
update_provider(id, **filtered_data)
# 获取更新后的供应商信息

View File

@@ -0,0 +1,60 @@
import json
import os
from pathlib import Path
from typing import Any, Dict, Optional
class ProxyConfigManager:
"""全局代理配置,存 JSON 文件,支持前端动态修改。
作用范围LLM API + 转写 APIGroq 等)+ yt-dlp 视频下载。
优先级:配置文件里 enabled=true 的 url > 环境变量 HTTP_PROXY/HTTPS_PROXY/ALL_PROXY。
这样桌面端/web 用户在设置页填docker/服务器部署用环境变量兜底。
"""
def __init__(self, filepath: str = "config/proxy.json"):
self.path = Path(filepath)
self.path.parent.mkdir(parents=True, exist_ok=True)
def _read(self) -> Dict[str, Any]:
if not self.path.exists():
return {}
try:
with self.path.open("r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def _write(self, data: Dict[str, Any]):
with self.path.open("w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def get_config(self) -> Dict[str, Any]:
data = self._read()
return {
"enabled": bool(data.get("enabled", False)),
"url": data.get("url", "") or "",
}
def update_config(self, enabled: bool, url: Optional[str] = None) -> Dict[str, Any]:
data = self._read()
data["enabled"] = bool(enabled)
if url is not None:
data["url"] = url.strip()
self._write(data)
return self.get_config()
def get_proxy_url(self) -> Optional[str]:
"""返回当前生效的代理 URL没有则 None。
- 配置文件 enabled=true 且 url 非空 → 用配置的 url
- 否则回退到环境变量(标准的 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY大小写都认
"""
cfg = self.get_config()
if cfg["enabled"] and cfg["url"]:
return cfg["url"]
for key in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"):
val = os.environ.get(key)
if val:
return val
return None

View File

@@ -61,3 +61,54 @@ class TranscriberConfigManager:
def get_whisper_model_size(self) -> str:
return self.get_config()["whisper_model_size"]
def is_model_ready(self) -> Dict[str, Any]:
"""当前转写器是否就绪可用。
返回 {ready, transcriber_type, model_size, downloading, reason}
- 在线引擎 (groq/bcut/kuaishou):永远 ready不需要本地模型
- fast-whisper检查 whisper-{size}/model.bin 落盘
- mlx-whisper检查 {repo_id}/config.json 落盘
给 /generate_note 入口做「开始视频前先确认模型下载好」的门禁用。
"""
cfg = self.get_config()
ttype = cfg["transcriber_type"]
size = cfg["whisper_model_size"]
result = {
"ready": True,
"transcriber_type": ttype,
"model_size": size,
"downloading": False,
"reason": "",
}
if ttype not in ("fast-whisper", "mlx-whisper"):
return result # 在线引擎无需本地模型
# 延迟 import 避免与 routers.config 的循环依赖;只取纯函数,不触发路由副作用
try:
from app.routers.config import (
_check_whisper_model_exists,
_check_mlx_whisper_model_exists,
_downloading,
)
except Exception as e:
# 拿不到检查函数时保守放行,不要把用户卡死
result["reason"] = f"无法检查模型状态: {e}"
return result
if ttype == "fast-whisper":
downloaded = _check_whisper_model_exists(size, "whisper")
downloading = _downloading.get(size) == "downloading"
else: # mlx-whisper
downloaded = _check_mlx_whisper_model_exists(size)
downloading = _downloading.get(f"mlx-{size}") == "downloading"
result["downloading"] = downloading
if downloaded:
return result
result["ready"] = False
result["reason"] = (
f"转写模型 {ttype} / {size} 尚未下载就绪"
+ (",正在下载中,请稍候" if downloading else ",请先在「设置 → 音频转写配置」页下载")
)
return result

View File

@@ -5,7 +5,7 @@ from app.decorators.timeit import timeit
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
from app.services.provider import ProviderService
from app.transcriber.base import Transcriber
from openai import OpenAI
from app.utils.openai_client import build_openai_client
import ffmpeg
import tempfile
from dotenv import load_dotenv
@@ -30,12 +30,14 @@ class GroqTranscriber(Transcriber, ABC):
print(f"压缩完成,临时路径:{file_path}")
provider = ProviderService.get_provider_by_id('groq')
if not provider:
raise Exception("Groq 供应商未配置,请配置以后使用。")
client = OpenAI(
# build_openai_client 会校验 api_key 非空(空 key 会抛天书般的
# `Illegal header value b'Bearer '`),并自动注入全局代理
client = build_openai_client(
api_key=provider.get('api_key'),
base_url=provider.get('base_url')
base_url=provider.get('base_url'),
key_label="Groq 转写引擎的 API Key",
)
filename = file_path

View File

@@ -58,9 +58,16 @@ class MLXWhisperTranscriber(Transcriber):
# 设置模型路径
model_dir = get_model_dir("mlx-whisper")
self.model_path = os.path.join(model_dir, self.model_name)
# 检查并下载模型
if not Path(self.model_path).exists():
logger.info(f"模型 {self.model_name} 不存在,开始下载...")
# 用 config.json 而非目录存在作为「下载完成」的判据,
# 同 fast-whisper 的 model.bin避免半成品目录把后续下载吞掉
config_file = Path(self.model_path) / "config.json"
if not config_file.exists():
if Path(self.model_path).exists():
logger.warning(
f"MLX 模型目录 {self.model_path} 存在但 config.json 缺失(上次下载未完成),重新下载"
)
else:
logger.info(f"模型 {self.model_name} 不存在,开始下载...")
snapshot_download(
self.model_name,
local_dir=self.model_path,

View File

@@ -3,6 +3,11 @@ from faster_whisper import WhisperModel
from app.decorators.timeit import timeit
from app.models.transcriber_model import TranscriptSegment, TranscriptResult
from app.transcriber.base import Transcriber
from app.transcriber.whisper_models import (
resolve_whisper_model,
is_local_target,
hf_cache_dirname,
)
from app.utils.env_checker import is_cuda_available, is_torch_installed
from app.utils.logger import get_logger
from app.utils.path_helper import get_model_dir
@@ -10,8 +15,7 @@ from app.utils.path_helper import get_model_dir
from events import transcription_finished
from pathlib import Path
import os
from tqdm import tqdm
from modelscope import snapshot_download
import shutil
'''
@@ -19,19 +23,16 @@ from modelscope import snapshot_download
'''
logger=get_logger(__name__)
MODEL_MAP={
"tiny": "pengzhendong/faster-whisper-tiny",
'base':'pengzhendong/faster-whisper-base',
'small':'pengzhendong/faster-whisper-small',
'medium':'pengzhendong/faster-whisper-medium',
'large-v1':'pengzhendong/faster-whisper-large-v1',
'large-v2':'pengzhendong/faster-whisper-large-v2',
'large-v3':'pengzhendong/faster-whisper-large-v3',
'large-v3-turbo':'pengzhendong/faster-whisper-large-v3-turbo',
}
# 历史遗留:之前用 modelscope 下载到自定义目录然后把路径传给 WhisperModel。
# 但 faster-whisper 1.1.1 的 download_modelutils.py:76逻辑是
# 只要 size_or_id 里含 "/" 就当 HF repo_id 处理,没有「本地目录直接返回」分支。
# 我们传 /app/models/whisper/whisper-tiny 进去 → 被当成不存在的 HF repo →
# 在线请求失败 → fallback local_files_only=True → HF cache 找不到(因为是
# modelscope 目录布局不是 HF→ LocalEntryNotFoundError误导说"离线模式"。
# 解法:彻底让 faster-whisper 自己处理下载——传 size name配 download_root
# 作为 HF cache 根目录HF_ENDPOINT 已经在 Dockerfile 里指到 hf-mirror.com
# 国内能用。删掉 modelscope 那一套,避免布局不匹配。
class WhisperTranscriber(Transcriber):
# TODO:修改为可配置
def __init__(
self,
model_size: str = "base",
@@ -47,25 +48,53 @@ class WhisperTranscriber(Transcriber):
print('没有 cuda 使用 cpu进行计算')
self.compute_type = compute_type or ("float16" if self.device == "cuda" else "int8")
self.model_size = model_size
model_dir = get_model_dir("whisper")
model_path = os.path.join(model_dir, f"whisper-{model_size}")
if not Path(model_path).exists():
logger.info(f"模型 whisper-{model_size} 不存在,开始下载...")
repo_id = MODEL_MAP[model_size]
model_path = snapshot_download(
repo_id,
try:
self.model = self._build_model(model_size, model_dir)
except Exception as e:
# 自愈:损坏 / 截断 / 半成品 cache → 删掉对应 HF cache 重下一次
logger.warning(f"加载 whisper-{model_size} 失败:{e};清理 cache 后重新下载")
self._purge_cache(model_dir, model_size)
self.model = self._build_model(model_size, model_dir)
local_dir=model_path,
)
logger.info("模型下载完成")
self.model = WhisperModel(
model_size_or_path=model_path,
def _build_model(self, model_size: str, model_dir: str) -> WhisperModel:
# resolve 把模型名映射成可加载标识:内置 size→Systran repo_id、自定义映射、
# 直通的 repo_id 或本地路径。faster-whisper 对本地目录走 os.path.isdir 分支,
# 对 repo_id 走 download_model(cache_dir=download_root),两者都吃 model_size_or_path。
target = resolve_whisper_model(model_size)
return WhisperModel(
model_size_or_path=target,
device=self.device,
compute_type=self.compute_type,
download_root=model_dir
download_root=model_dir,
)
@staticmethod
def _purge_cache(model_dir: str, model_size: str) -> None:
"""加载失败时清掉对应 HF cache 的 snapshot 目录,强制下次重下。
关键:本地路径模型**绝不删**——那是用户自己的文件,删了就是数据丢失;
只清 HF cache 布局 <model_dir>/models--{org}--{name}/(含历史 modelscope 目录)。
"""
try:
target = resolve_whisper_model(model_size)
except Exception:
target = model_size
if is_local_target(target):
logger.warning(
f"模型 {model_size} 指向本地路径 {target},加载失败不清理用户文件,请检查该目录是否完整"
)
return
candidates = [
Path(model_dir) / hf_cache_dirname(target), # HF cache: models--org--name
Path(model_dir) / f"whisper-{model_size}", # 历史 modelscope 目录,顺手清掉
]
for path in candidates:
if path.exists():
logger.info(f"清理损坏 cache: {path}")
shutil.rmtree(path, ignore_errors=True)
@staticmethod
def is_torch_installed() -> bool:
try:

View File

@@ -0,0 +1,156 @@
"""fast-whisper 模型名 → 可加载标识HF repo_id 或本地路径)的映射注册表。
背景faster-whisper 加载时 `WhisperModel(model_size_or_path=...)` 接受三种入参——
内置 size 名、HF repo_id"/")、或本地模型目录(`os.path.isdir` 命中则直接用)。
此前后端把「size → Systran/faster-whisper-{size}」这层约定**隐式**散落在加载/下载/
检测三处,用户想用命名不符合该约定的模型(比如社区微调版、或自己下到本地的模型)就接不上。
本模块把映射**显式化 + 可配置**(对齐 mlx_whisper_transcriber.MLX_MODEL_MAP 的模式):
- 内置size → Systran/faster-whisper-{size}
- 自定义:用户在 config/whisper_models.json 登记 {名称: "<repo_id 或本地路径>"}
JSON 持久化Docker 下随 config 卷持久化)
解析优先级resolve自定义 > 内置 > 直通(含 "/" 当 repo_id已存在目录当本地路径
加载 / 下载 / 完整性检测三处统一调用 resolve路径不再各写各的。
"""
import json
import os
from pathlib import Path
from typing import Dict, List
from app.utils.logger import get_logger
logger = get_logger(__name__)
# 内置模型size → faster-whisper 兼容的 HF repo_idCTranslate2 转换版Systran 官方维护)。
BUILTIN_WHISPER_MODELS: Dict[str, str] = {
"tiny": "Systran/faster-whisper-tiny",
"base": "Systran/faster-whisper-base",
"small": "Systran/faster-whisper-small",
"medium": "Systran/faster-whisper-medium",
"large-v1": "Systran/faster-whisper-large-v1",
"large-v2": "Systran/faster-whisper-large-v2",
"large-v3": "Systran/faster-whisper-large-v3",
"large-v3-turbo": "Systran/faster-whisper-large-v3-turbo",
}
# 前端下拉默认展示的内置档位(保持与历史 WHISPER_MODEL_SIZES 一致,不把 8 个全列出来)
DEFAULT_VISIBLE_BUILTINS: List[str] = ["tiny", "base", "small", "medium", "large-v3", "large-v3-turbo"]
def is_local_target(target: str) -> bool:
"""判断解析出的 target 是本地路径而非 HF repo_id。
HF repo_id 形如 'Org/Name'(恰一个斜杠、无前导斜杠、非已存在目录)。
本地路径:绝对路径 / 以 . 或 ~ 开头 / 已存在的目录。
"""
if not target:
return False
if os.path.isabs(target) or target.startswith(".") or target.startswith("~"):
return True
return os.path.isdir(target)
def hf_cache_dirname(repo_id: str) -> str:
"""huggingface_hub snapshot 的本地缓存目录名Org/Name → models--Org--Name。"""
return "models--" + repo_id.replace("/", "--")
class WhisperModelRegistry:
"""内置 + 用户自定义的 whisper 模型映射,自定义部分持久化到 JSON。"""
def __init__(self, filepath: str = "config/whisper_models.json"):
self.path = Path(filepath)
self.path.parent.mkdir(parents=True, exist_ok=True)
# ---- 持久化 ----
def _read_custom(self) -> Dict[str, str]:
if not self.path.exists():
return {}
try:
with self.path.open("r", encoding="utf-8") as f:
data = json.load(f) or {}
except Exception as e:
logger.warning(f"读取自定义 whisper 模型配置失败,按空处理: {e}")
return {}
out: Dict[str, str] = {}
for name, val in data.items():
if isinstance(val, str) and val.strip():
out[name] = val.strip()
elif isinstance(val, dict) and isinstance(val.get("target"), str):
out[name] = val["target"].strip()
return out
def _write_custom(self, data: Dict[str, str]) -> None:
with self.path.open("w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# ---- 查询 ----
def get_custom_models(self) -> Dict[str, str]:
return self._read_custom()
def visible_model_names(self) -> List[str]:
"""给前端下拉 / 下载状态用:默认可见内置档位 + 全部自定义名称。"""
names = list(DEFAULT_VISIBLE_BUILTINS)
for name in self._read_custom():
if name not in names:
names.append(name)
return names
def is_known(self, name: str) -> bool:
try:
self.resolve(name)
return True
except ValueError:
return False
def resolve(self, name: str) -> str:
"""模型名 → 可加载标识HF repo_id 或本地路径)。
优先级:自定义映射 > 内置映射 > 直通(含 "/" 的 repo_id / 已存在的本地目录)。
无法识别时抛 ValueError。
"""
name = (name or "").strip()
custom = self._read_custom()
if name in custom:
return custom[name]
if name in BUILTIN_WHISPER_MODELS:
return BUILTIN_WHISPER_MODELS[name]
# 直通:用户直接把 repo_id含 "/")或本地已存在目录当 model_size 传进来
if "/" in name or os.path.isdir(name):
return name
raise ValueError(
f"未知 whisper 模型 '{name}'。内置可选: {', '.join(BUILTIN_WHISPER_MODELS)}"
"或在「音频转写配置」添加自定义模型HF repo_id 或本地路径)。"
)
# ---- 增删 ----
def add_custom_model(self, name: str, target: str) -> Dict[str, str]:
name = (name or "").strip()
target = (target or "").strip()
if not name or not target:
raise ValueError("模型名称与目标HF repo_id 或本地路径)都不能为空")
if name in BUILTIN_WHISPER_MODELS:
raise ValueError(f"'{name}' 与内置模型重名,请换一个名称")
data = self._read_custom()
data[name] = target
self._write_custom(data)
return data
def remove_custom_model(self, name: str) -> Dict[str, str]:
data = self._read_custom()
data.pop((name or "").strip(), None)
self._write_custom(data)
return data
# 模块级单例
_registry = WhisperModelRegistry()
def get_registry() -> WhisperModelRegistry:
return _registry
def resolve_whisper_model(name: str) -> str:
return _registry.resolve(name)

View File

@@ -0,0 +1,45 @@
"""统一构造 OpenAI 兼容客户端:注入全局代理 + 校验 api_key。
为什么要这一层:
- 代理openai SDK 默认只认进程级 HTTP_PROXY 环境变量,桌面端用户在 UI 里
填的代理需要显式塞进 httpx.Client 才生效。
- api_key 校验:空 key 会让 httpx 拼出非法 header `Bearer `,抛出
`httpx.LocalProtocolError: Illegal header value b'Bearer '` 这种天书报错。
在入口挡掉给用户「xxx 的 API Key 未配置」这种能看懂的提示。
"""
from typing import Optional
from openai import OpenAI
from app.services.proxy_config_manager import ProxyConfigManager
from app.utils.logger import get_logger
logger = get_logger(__name__)
def build_openai_client(
api_key: Optional[str],
base_url: Optional[str],
*,
key_label: str = "API Key",
timeout: Optional[float] = None,
) -> OpenAI:
"""构造 OpenAI 客户端。api_key 为空直接抛清晰错误;代理已配置则注入。
key_label 用于错误提示,例如 "Groq 的 API Key" / "OpenAI 供应商的 API Key"
"""
if not api_key or not str(api_key).strip():
raise ValueError(f"{key_label} 未配置,请先在「设置」里填写后再使用")
kwargs = {"api_key": str(api_key).strip(), "base_url": base_url}
if timeout is not None:
kwargs["timeout"] = timeout
proxy_url = ProxyConfigManager().get_proxy_url()
if proxy_url:
# 延迟 import httpx仅在确实要走代理时才需要
import httpx
kwargs["http_client"] = httpx.Client(proxy=proxy_url, timeout=timeout or 600.0)
logger.info(f"OpenAI 客户端走代理: {proxy_url}")
return OpenAI(**kwargs)

View File

@@ -1,11 +1,41 @@
import os
import subprocess
import sys
from dotenv import load_dotenv
from app.utils.logger import get_logger
logger = get_logger(__name__)
load_dotenv()
def _load_dotenv_from_multiple_paths():
"""尝试多个位置加载 .env适配源码运行和 PyInstaller 打包场景。
PyInstaller 打包后当前工作目录是 EXE 所在目录,而源码运行时 .env
通常在项目根目录或 backend/ 同级。遍历常见候选路径确保能命中。
"""
candidates = []
# 1. 当前工作目录EXE 所在目录)
candidates.append(os.path.join(os.getcwd(), '.env'))
# 2. 本脚本所在目录backend/
script_dir = os.path.dirname(os.path.abspath(__file__))
candidates.append(os.path.join(script_dir, '.env'))
# 3. 项目根目录backend/../.env
candidates.append(os.path.join(script_dir, '..', '.env'))
# 4. PyInstaller 打包后的 _internal/ 子目录
if getattr(sys, 'frozen', False):
exe_dir = os.path.dirname(sys.executable)
candidates.append(os.path.join(exe_dir, '_internal', '.env'))
for path in candidates:
normalized = os.path.normpath(path)
if os.path.isfile(normalized):
load_dotenv(normalized)
return
# 都没找到fallback 到默认行为(从 CWD 找)
load_dotenv()
_load_dotenv_from_multiple_paths()
def check_ffmpeg_exists() -> bool:
"""
检查 ffmpeg 是否可用。优先使用 FFMPEG_BIN_PATH 环境变量指定的路径。

View File

@@ -39,24 +39,50 @@ if not os.path.exists(out_dir):
@asynccontextmanager
async def lifespan(app: FastAPI):
register_handler()
init_db()
# 转写器不再在启动时强制初始化,而是在首次生成笔记时按需创建
# 如果配置了不可用的类型(如 mlx-whisper 未安装),会在使用时报错而非静默回退
_cfg = TranscriberConfigManager().get_config()
logger.info(f"当前转写器配置: type={_cfg['transcriber_type']}, model_size={_cfg['whisper_model_size']}")
seed_default_providers()
# 启动序列拆成 5 步、每步独立日志 + 异常时打明确的 [startup N/5 FAILED] 标记。
# 目的:用户 docker logs 一眼能看出后端死在哪一步,避免「容器一直重启但看不出原因」。
try:
logger.info("[startup 1/5] register_handler() — 注册事件处理器")
register_handler()
logger.info("[startup 2/5] init_db() — 初始化 SQLite 数据库")
init_db()
logger.info("[startup 3/5] TranscriberConfigManager — 读取转写器配置")
# 转写器不再在启动时强制初始化,而是在首次生成笔记时按需创建。
# 如果配置了不可用的类型(如 mlx-whisper 未安装),会在使用时报错而非静默回退。
_cfg = TranscriberConfigManager().get_config()
logger.info(
f" 当前转写器: type={_cfg['transcriber_type']}, "
f"model_size={_cfg['whisper_model_size']}"
)
logger.info("[startup 4/5] seed_default_providers() — 初始化默认 LLM 供应商")
seed_default_providers()
logger.info("[startup 5/5] 启动完成,等待请求")
except Exception:
logger.exception("[startup FAILED] 后端启动期异常,详见堆栈;容器会退出并由 restart 策略决定是否重试")
raise
yield
app = create_app(lifespan=lifespan)
# 允许的源:本地 web 端 + Tauri 桌面端 + 浏览器扩展chrome/edge/firefox
# 用 regex 是因为 chrome-extension://<id> 的 id 在每次开发版加载时不固定
# Tauri 2 不同平台 webview origin 不一样,必须全列:
# - macOS: tauri://localhost (自定义协议)
# - Windows: https://tauri.localhost Edge WebView2
# - Linux: http://tauri.localhost WebKitGTK
# 漏掉哪个都会导致桌面端 fetch 返回 200 但 browser 因为 CORS 拒绝读响应,
# 表现为前端「连不上后端」但后端日志一片 200 OK。
CORS_ORIGIN_REGEX = (
r"^chrome-extension://[a-z]+$"
r"|^moz-extension://.+$"
r"|^http://(localhost|127\.0\.0\.1)(:\d+)?$"
r"|^http://tauri\.localhost$"
r"|^tauri://localhost$"
r"|^https?://tauri\.localhost$"
)
app.add_middleware(

View File

@@ -24,7 +24,7 @@ click-repl==0.3.0
colorama==0.4.6
coloredlogs==15.0.1
cssselect2==0.8.0
ctranslate2==4.5.0
ctranslate2==4.6.0
distro==1.9.0
dnspython==2.7.0
email_validator==2.2.0

View File

@@ -0,0 +1,132 @@
"""Unit tests for app.transcriber.whisper_modelswhisper 模型名→标识 的映射注册表)。
直接按文件路径加载被测模块,并桩掉 app.utils.logger避免触发 app/__init__.py
(会 import faster_whisper / ctranslate2 等重依赖),使本测试无需安装转写依赖即可运行。
"""
import importlib.util
import logging
import os
import pathlib
import sys
import tempfile
import types
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
MODULE_PATH = ROOT / "app" / "transcriber" / "whisper_models.py"
def _load_module():
if "app" not in sys.modules:
app_pkg = types.ModuleType("app")
app_pkg.__path__ = [] # 标记为 package
sys.modules["app"] = app_pkg
if "app.utils" not in sys.modules:
utils_pkg = types.ModuleType("app.utils")
utils_pkg.__path__ = []
sys.modules["app.utils"] = utils_pkg
if "app.utils.logger" not in sys.modules:
logger_mod = types.ModuleType("app.utils.logger")
logger_mod.get_logger = lambda name=None: logging.getLogger(name or "test")
sys.modules["app.utils.logger"] = logger_mod
spec = importlib.util.spec_from_file_location("whisper_models_under_test", MODULE_PATH)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
wm = _load_module()
class TestResolve(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.cfg = os.path.join(self.tmp.name, "whisper_models.json")
self.reg = wm.WhisperModelRegistry(filepath=self.cfg)
def tearDown(self):
self.tmp.cleanup()
def test_builtin_resolves_to_systran(self):
self.assertEqual(self.reg.resolve("tiny"), "Systran/faster-whisper-tiny")
self.assertEqual(self.reg.resolve("large-v3-turbo"), "Systran/faster-whisper-large-v3-turbo")
def test_passthrough_repo_id(self):
# 用户直接把 HF repo_id 当 model_size 传进来(含 "/"
self.assertEqual(self.reg.resolve("SomeOrg/my-whisper-ct2"), "SomeOrg/my-whisper-ct2")
def test_unknown_raises(self):
with self.assertRaises(ValueError):
self.reg.resolve("definitely-not-a-model")
def test_custom_overrides_and_persists(self):
self.reg.add_custom_model("myhf", "someorg/whisper-ct2")
self.assertEqual(self.reg.resolve("myhf"), "someorg/whisper-ct2")
# 新实例读同一文件 → 确认持久化Docker 下随 config 卷保留)
reg2 = wm.WhisperModelRegistry(filepath=self.cfg)
self.assertEqual(reg2.resolve("myhf"), "someorg/whisper-ct2")
def test_custom_can_override_builtin_key_resolution(self):
# 自定义优先级高于内置:把 "tiny" 强行指到别的 reporesolve 层允许add 层禁止重名)
self.reg._write_custom({"tiny": "Other/tiny-ct2"})
self.assertEqual(self.reg.resolve("tiny"), "Other/tiny-ct2")
def test_local_path_resolution_and_detection(self):
model_dir = os.path.join(self.tmp.name, "mymodel")
os.makedirs(model_dir)
self.reg.add_custom_model("local1", model_dir)
self.assertEqual(self.reg.resolve("local1"), model_dir)
self.assertTrue(wm.is_local_target(self.reg.resolve("local1")))
def test_bare_existing_dir_passthrough(self):
# 没登记、但直接传一个已存在目录 → 直通为本地路径
model_dir = os.path.join(self.tmp.name, "bare")
os.makedirs(model_dir)
self.assertEqual(self.reg.resolve(model_dir), model_dir)
def test_add_rejects_builtin_collision_and_empty(self):
with self.assertRaises(ValueError):
self.reg.add_custom_model("tiny", "x/y") # 与内置重名
with self.assertRaises(ValueError):
self.reg.add_custom_model("", "x/y")
with self.assertRaises(ValueError):
self.reg.add_custom_model("ok", "")
def test_remove(self):
self.reg.add_custom_model("tmpm", "a/b")
self.assertIn("tmpm", self.reg.get_custom_models())
self.reg.remove_custom_model("tmpm")
self.assertNotIn("tmpm", self.reg.get_custom_models())
def test_visible_includes_builtin_and_custom(self):
self.reg.add_custom_model("zzz", "a/b")
names = self.reg.visible_model_names()
self.assertIn("tiny", names)
self.assertIn("large-v3", names)
self.assertIn("zzz", names)
def test_is_known(self):
self.assertTrue(self.reg.is_known("base"))
self.assertTrue(self.reg.is_known("Org/Name"))
self.assertFalse(self.reg.is_known("nope-not-real"))
class TestHelpers(unittest.TestCase):
def test_hf_cache_dirname(self):
self.assertEqual(
wm.hf_cache_dirname("Systran/faster-whisper-tiny"),
"models--Systran--faster-whisper-tiny",
)
self.assertEqual(wm.hf_cache_dirname("Org/Name"), "models--Org--Name")
def test_is_local_target(self):
self.assertTrue(wm.is_local_target("/abs/path"))
self.assertTrue(wm.is_local_target("./rel"))
self.assertTrue(wm.is_local_target("~/home/model"))
self.assertFalse(wm.is_local_target("Org/Name")) # repo_id 不是本地路径
self.assertFalse(wm.is_local_target(""))
if __name__ == "__main__":
unittest.main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

BIN
doc/wechat-group-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

BIN
doc/wechat-group-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

BIN
doc/wechat-group-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

BIN
doc/wechat-group-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

BIN
doc/wechat-group-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

BIN
doc/wechat-gzh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -6,6 +6,8 @@ services:
context: .
dockerfile: backend/Dockerfile.gpu
args:
# 国内拉不到 docker.io 时设置 BASE_REGISTRY注意所选镜像需要支持 nvidia/cuda 命名空间
BASE_REGISTRY: ${BASE_REGISTRY:-docker.io}
APT_MIRROR: ${APT_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
PIP_INDEX: ${PIP_INDEX:-https://pypi.tuna.tsinghua.edu.cn/simple}
env_file:
@@ -14,9 +16,20 @@ services:
- BACKEND_PORT=${BACKEND_PORT}
- BACKEND_HOST=${BACKEND_HOST}
volumes:
# 同 docker-compose.yml./backend 绑到 /appDB / 转写器配置 / 截图 / 上传都持久化
- ./backend:/app
expose:
- "${BACKEND_PORT}" # 不再对外暴露,用于 nginx 内部通信
# 用 unless-stopped 避免短暂崩溃把容器永久打死后再也读不到 .env 修改
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${BACKEND_PORT}/api/sys_health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s # GPU 镜像首次加载 CUDA 比 CPU 慢,给久一点
# GPU 部署默认跑较大模型,把内存限制提到 8g 避免 host OOM
mem_limit: 8g
deploy:
resources:
reservations:
@@ -30,10 +43,14 @@ services:
build:
context: .
dockerfile: BillNote_frontend/Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY:-docker.io}
env_file:
- .env
expose:
- "80" # 不暴露给宿主机,只供 nginx 访问
restart: unless-stopped
mem_limit: 512m
nginx:
container_name: bilinote-nginx
@@ -43,5 +60,9 @@ services:
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- backend
- frontend
backend:
condition: service_healthy
frontend:
condition: service_started
restart: unless-stopped
mem_limit: 256m

View File

@@ -6,6 +6,8 @@ services:
context: .
dockerfile: backend/Dockerfile
args:
# 国内拉不到 docker.io 时设置 BASE_REGISTRY=docker.m.daocloud.io或其他可用镜像
BASE_REGISTRY: ${BASE_REGISTRY:-docker.io}
APT_MIRROR: ${APT_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
PIP_INDEX: ${PIP_INDEX:-https://pypi.tuna.tsinghua.edu.cn/simple}
env_file:
@@ -14,16 +16,25 @@ services:
- BACKEND_PORT=${BACKEND_PORT}
- BACKEND_HOST=${BACKEND_HOST}
volumes:
# 把整个 backend/ 目录绑到 /app意味着这些都持久化到宿主机、删容器不丢
# ./backend/bili_note.db — SQLite 数据库(含 LLM 供应商配置、笔记历史)
# ./backend/config/transcriber.json — 转写器运行时配置
# ./backend/static/screenshots/ — 视频截图
# ./backend/uploads/ — 上传的本地视频
- ./backend:/app
expose:
- "${BACKEND_PORT}" # 不再对外暴露,用于 nginx 内部通信
restart: on-failure:3
# 用 unless-stopped 而非 on-failure:N避免任何短暂崩溃把容器永久打死后
# 再也接收不到用户修过的 .env。手动 docker-compose stop 仍可正常停下。
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${BACKEND_PORT}/api/sys_health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
# WHISPER_MODEL_SIZE 选 medium 及以上请把这里调到 8g+
# 否则首次模型加载时容易被 host OOM-killer 干掉。
mem_limit: 4g
frontend:
@@ -31,11 +42,13 @@ services:
build:
context: .
dockerfile: BillNote_frontend/Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY:-docker.io}
env_file:
- .env
expose:
- "80" # 不暴露给宿主机,只供 nginx 访问
restart: on-failure:3
restart: unless-stopped
mem_limit: 512m
nginx:
@@ -50,5 +63,5 @@ services:
condition: service_healthy
frontend:
condition: service_started
restart: on-failure:3
restart: unless-stopped
mem_limit: 256m