42 Commits

Author SHA1 Message Date
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
7b5e6099e8 chore(release): v2.2.3
补 P1/P2 用了 @tauri-apps/api 但没声明为直接依赖导致的 vite build CI 失败。
2026-05-09 14:53:01 +08:00
huangjianwu
bb9a70eee2 fix(frontend): @tauri-apps/api 提升为直接依赖,修 vite build CI 失败
v2.2.0 加的 P1/P2 桌面端组件(StartupBanner / useBackendEvents)用了
'await import(\"@tauri-apps/api/event\")' 与 '@tauri-apps/api/core',但
@tauri-apps/api 只是 @tauri-apps/plugin-shell 的 transitive,没在
BillNote_frontend/package.json 直接声明。

本地开发能跑(pnpm 节点解析容忍 transitive),但 vite 6 的 production
rollup 静态分析时报:
  Rollup failed to resolve import "@tauri-apps/api/event"

之前我只跑了 typecheck 没跑 build,所以这个问题先在 v2.2.0 / v2.2.1 / v2.2.2
的 CI 上才暴露出来。本地 DOCKER_BUILD=1 pnpm run build 已复现并修复验证。

修:BillNote_frontend/package.json 把 '@tauri-apps/api' 加为直接依赖
(^2.10.1,与 lockfile 中已存在的 transitive 版本对齐),lockfile 同步更新。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:52:56 +08:00
huangjianwu
e9d4740cc7 chore(release): merge hotfix/desktop-pnpm-version into develop 2026-05-09 14:49:53 +08:00
huangjianwu
c5900a9026 chore(release): v2.2.2
补 v2.2.1 漏掉的桌面端 Tauri 构建 pnpm 版本 pin。
2026-05-09 14:49:39 +08:00
huangjianwu
63577aa1aa fix(ci): pin pnpm 9.15.0 in main.yml,修 v2.2.0 桌面端 Tauri 构建失败
v2.2.1 hotfix 只 pin 了 Docker 那两个 Dockerfile 里的 pnpm,但 .github/workflows/main.yml
(Tauri 桌面端 macOS + Windows 构建)的 'pnpm/action-setup@v4 with: version: latest' 没改,
于是 v2.2.1 tag 触发的桌面端 build 仍然挂在 'Install frontend dependencies' 步:

  ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite
  Node.js v20.20.2

修:main.yml 的 pnpm version 'latest' → '9.15.0',与 Docker 侧 + extension workflow
保持一致(lockfile 是 pnpm 9 生成的)。

release-extension.yml 已经是 pinned 9,无需改。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:49:33 +08:00
huangjianwu
ec33ae35ed chore(release): merge hotfix/docker-pnpm-version into develop 2026-05-09 14:47:25 +08:00
huangjianwu
2e69d1179b chore(release): v2.2.1
补 v2.2.0 ghcr.io 镜像构建失败(pnpm 版本不兼容 Node 20)。
2026-05-09 14:47:11 +08:00
huangjianwu
7e5be46cda fix(docker): pin pnpm 到 9.15.0,修复 v2.2.0 ghcr.io 镜像构建失败
v2.2.0 tag 触发的 ghcr.io 推送挂在 frontend-builder 第 5/7 步
'pnpm install --frozen-lockfile',错误:

  code: 'ERR_UNKNOWN_BUILTIN_MODULE'
  Node.js v20.20.2

根因:'corepack prepare pnpm@latest' 拉到 pnpm 11.0.9,pnpm 11+ 要求 Node 22+,
跟我们 node:20-alpine 不兼容。lockfile 本身是 lockfileVersion '9.0' 由 pnpm 9
生成,理应跟 pnpm 9 配。

修:Dockerfile.complete + BillNote_frontend/Dockerfile 都 pin 到 pnpm@9.15.0;
不再用 @latest,避免上游再次升级悄悄破坏 CI。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:47:06 +08:00
huangjianwu
0742387235 chore(release): merge release/2.2.0 back into develop 2026-05-09 14:43:48 +08:00
huangjianwu
604cdefa15 chore(release): v2.2.0
主线:浏览器插件功能与 web 端 NoteForm 完整对齐;桌面客户端 UX 与错误恢复一波重炼。
详见 CHANGELOG.md。
2026-05-09 14:43:38 +08:00
huangjianwu
ff91f74bef docs: v2.2.0 CHANGELOG + README 版本
主线:浏览器插件功能与 web 端 NoteForm 完整对齐;桌面客户端 UX 与错误恢复一波重炼。
详见 CHANGELOG.md。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:43:25 +08:00
huangjianwu
9bbae2c0c4 fix(backend): 把 deploy-resilience 合入 release/2.2.0
P0 修:whisper 半成品目录死循环 + /deploy_status 硬依赖 torch。
原 PR fix/backend-deploy-resilience 未走 develop,直接随本次发版上 master,
回灌时 develop 也拿到。
2026-05-09 14:42:11 +08:00
Jianwu Huang
5b5bf802af Merge pull request #363 from JefferyHcool/feat/desktop-onboarding
feat(desktop): 桌面端首启 4 步引导
2026-05-09 14:37:53 +08:00
Jianwu Huang
ecc2e56246 Merge pull request #362 from JefferyHcool/feat/desktop-backend-health
feat(desktop): Sidecar 健康度面板 + 重启后端能力
2026-05-09 14:36:50 +08:00
Jianwu Huang
d8470bacbc Merge pull request #360 from JefferyHcool/feat/desktop-startup-diagnostics
feat(desktop): 启动期路径诊断 + 顶端横幅,主动暴露已知失败因素
2026-05-09 14:35:51 +08:00
Jianwu Huang
0af2efb4de Merge pull request #359 from JefferyHcool/feat/desktop-transcriber-defaults
feat(transcriber): 默认 size 改 tiny + 切本地引擎前 confirm 模型下载
2026-05-09 14:35:08 +08:00
huangjianwu
721bda5280 feat(transcriber): 默认 size 改 tiny + 切本地引擎前 confirm 模型下载
桌面端用户首次跑视频时挂在 fast-whisper 模型下载(默认 medium ~1.5GB),
两处改动:

1. backend/app/services/transcriber_config_manager.py:
   默认 whisper_model_size 从 'medium' (~1.5GB) → 'tiny' (~75MB)。
   新装用户没主动设置时不再被首次下载卡住;想要更高精度的用户去配置页主动切。

2. BillNote_frontend/src/pages/SettingPage/transcriber.tsx:
   handleSave 在保存前判断:选了 fast-whisper / mlx-whisper 且当前 size 在
   modelStatuses 里既未下载也不在下载中 → window.confirm 弹一个体积提示,
   推荐改用 Groq / 必剪 / 快手 等在线引擎;用户取消则不保存。

不改业务逻辑;零回归风险(已有用户 transcriber.json 里写了什么就还是什么)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:32:41 +08:00
huangjianwu
a928e0e38f feat(desktop): 桌面端首启 4 步引导
桌面端用户安装后空白进 web 主页面,提示填模型但不知道从哪填、转写引擎从哪选、
为什么 fast-whisper 在下东西。新增首启 onboarding wizard,把四件事拉成一条线:

1. 后端连通性自检(启动后调 /api/get_all_providers,OK 才能进下一步)
2. LLM 供应商 + 模型:填 OpenAI 兼容 base_url + key + model_name,调
   /add_provider 创建并 addModel 默认 model,附带 testConnection
3. 转写引擎:四选一,**默认推荐 Groq**(在线、免下载本地模型);
   选 fast-whisper 时显式提示"将下载模型"
4. Cookie 同步说明:桌面端无 chrome.cookies API,引导手动配;并指向插件版

实现:
- 新页 src/pages/Onboarding/index.tsx,单文件 stateful wizard
- App.tsx 加 /onboarding 路由 + OnboardingGuard 路由守卫:
  · 仅 Tauri 桌面端(__TAURI_INTERNALS__)拦截,纯 web 端透传,不打扰
  · localStorage('bilinote-onboarded') 不为 '1' 时强制跳 /onboarding
- 完成第 4 步 markOnboarded() 写 localStorage 后 navigate('/')

回归风险:纯 web 用户无感知;旧桌面端用户的 localStorage 没这个 key,
首次升级到含此 PR 的版本时会跳一次 onboarding(建议在升级 release notes 里
说明,避免老用户疑惑)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:30:05 +08:00
Jianwu Huang
b117ab9f71 Merge pull request #358 from JefferyHcool/revert-357-fix/backend-deploy-resilience
Revert "Fix/backend deploy resilience"
2026-05-09 14:25:49 +08:00
Jianwu Huang
c4abaf4e60 Revert "Fix/backend deploy resilience" 2026-05-09 14:25:37 +08:00
Jianwu Huang
50f0816dab Merge pull request #357 from JefferyHcool/fix/backend-deploy-resilience
Fix/backend deploy resilience
2026-05-09 14:25:29 +08:00
huangjianwu
2bb69d1581 fix(backend): 部署友好性——whisper 半成品目录与 deploy_status 硬依赖 torch
两处部署反馈来的问题:

1. WhisperTranscriber 反复抛 'Unable to open file model.bin in
   model whisper-base'
   · 原因:__init__ 只看目录是否存在判定模型已下载(Path(model_path).exists()),
     但首次下载若中断 / 网络异常会留下空 / 半成品目录,下次启动绕过下载分支直接
     进 WhisperModel 加载,于是死循环报错
   · 修:判定条件换成 'model.bin' 落盘存在;目录在但 model.bin 缺失时打 warn
     并触发重新下载
   · routers/config.py 的 _check_whisper_model_exists 同步改用 model.bin 判定,
     避免「已下载」状态在监控页误报

2. /api/deploy_status 在没装 torch 的部署上 500
     ModuleNotFoundError: No module named 'torch'
   · 原因:endpoint 顶部直接 import torch,仅 fast-whisper 才用得到的依赖被强制为
     全局必需。轻量部署 / 用户切到 Groq / 必剪 / 快手 在线引擎时无 torch 也合理
   · 修:torch 改为 try/except,未装或 cuda 检测异常时返回
     {available: false, torch_installed: false};同时把 transcriber 配置 +
     ffmpeg 都包在 try 里,保证整个监控 endpoint 不会被任一子项打死

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:57:34 +08:00
Jianwu Huang
f6d299ce48 Merge pull request #353 from JefferyHcool/feature/extension-video-understanding
feat(extension): 多模态视频理解开关 + 抽帧/拼图参数(对齐 web NoteForm)
2026-05-07 17:28:00 +08:00
Jianwu Huang
ed1ee0a151 Merge pull request #352 from JefferyHcool/feature/extension-form-parity
Feature/extension form parity
2026-05-07 17:27:35 +08:00
huangjianwu
c9497b502c chore(release): v2.1.4
CI 工程化修复,无运行时行为变化。详见 CHANGELOG.md。
2026-05-07 16:44:59 +08:00
huangjianwu
26e23d0f2c Release v2.1.3
修 issue #282 (DeepSeek 等非多模态供应商被 400 拒绝)。详见 CHANGELOG.md。
2026-05-07 14:14:33 +08:00
huangjianwu
64882e6a77 Release v2.1.2
补 v2.1.1 ghcr.io 镜像构建失败。详见 CHANGELOG.md。
2026-05-07 14:06:26 +08:00
huangjianwu
a46880f169 Release v2.1.1
工程化与文档收尾,无运行时行为变化。详见 CHANGELOG.md。
2026-05-07 13:54:50 +08:00
huangjianwu
dbe7b89754 Release v2.1.0
详见 CHANGELOG.md。主线:
- 浏览器插件(Chrome/Edge/Firefox MV3)
- B 站字幕优先链路
- mlx-whisper 仓库 ID 修复
- 后端 CORS regex 兼容扩展源
2026-05-07 13:10:28 +08:00
55 changed files with 2429 additions and 753 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

@@ -45,10 +45,12 @@ jobs:
fi
# 设置 pnpm
# 不能用 'latest'pnpm 11+ 要求 Node 22+,与下方 Node 20 不兼容ERR_UNKNOWN_BUILTIN_MODULE
# lockfile 是 pnpm 9 生成;统一 pin 到 9.15.0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 'latest'
version: '9.15.0'
# 设置 Node 环境
- name: Set up Node.js

View File

@@ -1,8 +1,12 @@
# === 前端构建阶段 ===
# 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
RUN corepack enable && corepack prepare pnpm@latest --activate
# pnpm pin 到 9.xlockfile 是 v9 生成pnpm 11 要求 Node 22+ 与 node:20 不兼容
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
@@ -15,7 +19,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,7 +25,8 @@
"@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.1.3",
"@tauri-apps/plugin-shell": "~2.2.2",
"@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",

View File

@@ -53,9 +53,12 @@ importers:
'@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))
'@tauri-apps/api':
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)
@@ -1587,8 +1590,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==}
@@ -1666,8 +1669,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==}
@@ -6460,7 +6463,7 @@ snapshots:
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)
'@tauri-apps/api@2.10.1': {}
'@tauri-apps/api@2.11.0': {}
'@tauri-apps/cli-darwin-arm64@2.10.1':
optional: true
@@ -6509,9 +6512,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:

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

@@ -11,7 +11,17 @@ import Index from '@/pages/Index.tsx'
import { HomePage } from './pages/HomePage/Home.tsx'
// 非首屏页面使用 React.lazy 按需加载
const Onboarding = lazy(() => import('@/pages/Onboarding'))
const SettingPage = lazy(() => import('./pages/SettingPage/index.tsx'))
// 桌面端首启引导守卫:未完成 onboarding 时强制跳到 /onboarding
function OnboardingGuard({ children }: { children: React.ReactNode }) {
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
// 仅在 Tauri 桌面端拦截;纯 web 端不打扰用户
if (!isTauri) return <>{children}</>
if (localStorage.getItem('bilinote-onboarded') !== '1') return <Navigate to="/onboarding" replace />
return <>{children}</>
}
const Model = lazy(() => import('@/pages/SettingPage/Model.tsx'))
const ProviderForm = lazy(() => import('@/components/Form/modelForm/Form.tsx'))
const AboutPage = lazy(() => import('@/pages/SettingPage/about.tsx'))
@@ -23,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(() => {
@@ -32,12 +42,17 @@ function App() {
}
}, [initialized])
// 如果后端还未初始化,显示初始化对话框
// 如果后端还未初始化,显示初始化对话框loading 或 failed 都展示,由 dialog 内部决定渲染哪一态)
if (!initialized) {
return (
<>
<StartupBanner />
<BackendInitDialog open={loading} />
<BackendInitDialog
open={loading}
failed={failed}
lastError={lastError}
onRetry={retry}
/>
</>
)
}
@@ -50,7 +65,8 @@ function App() {
<BrowserRouter>
<Suspense fallback={<div className="flex h-screen items-center justify-center"></div>}>
<Routes>
<Route path="/" element={<Index />}>
<Route path="/onboarding" element={<Onboarding />} />
<Route path="/" element={<OnboardingGuard><Index /></OnboardingGuard>}>
<Route index element={<HomePage />} />
<Route path="settings" element={<SettingPage />}>
<Route index element={<Navigate to="model" replace />} />

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

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

@@ -0,0 +1,359 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
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. 后端连通性自检
// 2. LLM 供应商 + 模型(最简:只引导填一个 OpenAI-兼容供应商 + 一个 model 名)
// 3. 转写引擎选择(推荐 Groq 在线,避开本地模型下载坑)
// 4. 可选Cookie 同步说明(仅当用户关注 B 站等需要登录态的平台时)
const ONBOARD_KEY = 'bilinote-onboarded'
export function isOnboarded(): boolean {
return localStorage.getItem(ONBOARD_KEY) === '1'
}
function markOnboarded() {
localStorage.setItem(ONBOARD_KEY, '1')
}
const Onboarding = () => {
const navigate = useNavigate()
const [step, setStep] = useState(1)
const [error, setError] = useState('')
// step 1
const [pinging, setPinging] = useState(false)
const [backendOk, setBackendOk] = useState<boolean | null>(null)
// step 2
const [providerName, setProviderName] = useState('OpenAI')
const [apiKey, setApiKey] = useState('')
const [baseUrl, setBaseUrl] = useState('https://api.openai.com/v1')
const [modelName, setModelName] = useState('gpt-4o-mini')
const [providerId, setProviderId] = useState<string | null>(null)
const [savingProvider, setSavingProvider] = useState(false)
// step 3
const [transcriberType, setTranscriberType] = useState<string>('groq')
const [savingTranscriber, setSavingTranscriber] = useState(false)
function next() {
setError('')
setStep(s => s + 1)
}
function prev() {
setError('')
setStep(s => Math.max(1, s - 1))
}
// 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 () => {
const ok = await doPing()
if (cancelled) return
if (!ok) {
// 后端可能正在解压/启动2s 后再试一次
timerId = setTimeout(() => { if (!cancelled) doPing() }, 2000)
}
// 桌面端订阅 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
if (timerId) clearTimeout(timerId)
offReady?.()
offRestarted?.()
}
}, [step, doPing])
async function saveProvider() {
setError('')
if (!apiKey.trim()) { setError('请填 API Key'); return }
if (!baseUrl.trim()) { setError('请填 API 地址'); return }
if (!providerName.trim()) { setError('请填供应商名'); return }
if (!modelName.trim()) { setError('请填模型名'); return }
setSavingProvider(true)
try {
const name = providerName.trim()
let pid: string | undefined
// 后端 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 })
}
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('测试连接失败:', errText(e))
}
next()
}
catch (e: any) {
setError(`保存失败:${errText(e)}`)
}
finally {
setSavingProvider(false)
}
}
async function saveTranscriber() {
setError('')
setSavingTranscriber(true)
try {
// fast-whisper / mlx-whisper 需指定 model size在线 (groq/bcut/kuaishou) 不用
const needsSize = transcriberType === 'fast-whisper' || transcriberType === 'mlx-whisper'
await updateTranscriberConfig({
transcriber_type: transcriberType,
...(needsSize ? { whisper_model_size: 'tiny' } : {}),
} as any)
next()
}
catch (e: any) {
setError(`保存失败:${errText(e)}`)
}
finally {
setSavingTranscriber(false)
}
}
function finish() {
markOnboarded()
navigate('/', { replace: true })
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-pink-50 p-6">
<div className="w-full max-w-xl rounded-xl border bg-white p-6 shadow-lg">
<div className="flex items-center gap-3 mb-4">
<img src={logo} alt="logo" className="h-10 w-10" />
<div>
<h1 className="text-xl font-bold">使 BiliNote</h1>
<p className="text-xs text-gray-500"></p>
</div>
</div>
{/* Stepper */}
<div className="mb-5 flex items-center gap-2 text-xs text-gray-500">
{[1, 2, 3, 4].map(s => (
<div key={s} className="flex items-center gap-2">
<div
className={`flex h-6 w-6 items-center justify-center rounded-full border ${step >= s ? 'border-blue-600 bg-blue-600 text-white' : 'border-gray-300 bg-white text-gray-400'}`}
>{s}</div>
{s < 4 && <div className={`h-px w-8 ${step > s ? 'bg-blue-600' : 'bg-gray-300'}`} />}
</div>
))}
</div>
{step === 1 && (
<section className="flex flex-col gap-3">
<h2 className="font-semibold"> 1 · </h2>
<p className="text-sm text-gray-600"> Python </p>
{pinging && <div className="text-sm text-gray-500"></div>}
{backendOk === true && <div className="rounded bg-green-50 p-2 text-sm text-green-700"> </div>}
{backendOk === false && (
<div className="rounded bg-red-50 p-2 text-sm text-red-700">
1-2
</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>
</div>
</section>
)}
{step === 2 && (
<section className="flex flex-col gap-3">
<h2 className="font-semibold"> 2 · </h2>
<p className="text-sm text-gray-600"> OpenAI DeepSeek / Qwen / Claude / / OpenAI </p>
<label className="flex flex-col gap-1 text-sm">
<span className="text-gray-600"></span>
<input className="input border rounded px-2 py-1" value={providerName} onChange={e => setProviderName(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-gray-600">API </span>
<input className="input border rounded px-2 py-1" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-gray-600">API Key</span>
<input type="password" className="input border rounded px-2 py-1" value={apiKey} onChange={e => setApiKey(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-gray-600"> gpt-4o-mini / deepseek-chat / qwen-turbo</span>
<input className="input border rounded px-2 py-1" value={modelName} onChange={e => setModelName(e.target.value)} />
</label>
{error && <div className="text-xs text-red-600">{error}</div>}
<div className="flex gap-2 justify-between">
<button className="text-sm text-gray-500 hover:text-gray-800" onClick={prev}></button>
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50" disabled={savingProvider} onClick={saveProvider}>
{savingProvider ? '保存中…' : '保存并下一步'}
</button>
</div>
</section>
)}
{step === 3 && (
<section className="flex flex-col gap-3">
<h2 className="font-semibold"> 3 · </h2>
<p className="text-sm text-gray-600"><strong>线</strong> ~600MB </p>
<div className="grid gap-2">
{[
{ value: 'groq', title: 'Groq在线推荐', desc: '注册 https://groq.com/ 拿免费 key速度快、英文语料佳。无需本地模型。' },
{ value: 'bcut', title: '必剪(在线,免登)', desc: '免登,中文表现好;偶尔限流。' },
{ value: 'kuaishou', title: '快手(在线,免登)', desc: '与必剪类似,备选。' },
{ value: 'fast-whisper', title: 'Faster Whisper本地', desc: '完全离线但首次需下载 ~75MBtiny至 ~3GBlarge-v3的模型。CPU 慢。' },
].map(opt => (
<label key={opt.value} className={`flex gap-3 p-3 rounded border cursor-pointer ${transcriberType === opt.value ? 'border-blue-600 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
<input type="radio" name="transcriber" value={opt.value} checked={transcriberType === opt.value} onChange={e => setTranscriberType(e.target.value)} />
<div>
<div className="text-sm font-medium">{opt.title}</div>
<div className="text-xs text-gray-500 mt-0.5">{opt.desc}</div>
</div>
</label>
))}
</div>
{error && <div className="text-xs text-red-600">{error}</div>}
<div className="flex gap-2 justify-between">
<button className="text-sm text-gray-500 hover:text-gray-800" onClick={prev}></button>
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50" disabled={savingTranscriber} onClick={saveTranscriber}>
{savingTranscriber ? '保存中…' : '保存并下一步'}
</button>
</div>
</section>
)}
{step === 4 && (
<section className="flex flex-col gap-3">
<h2 className="font-semibold"> 4 · Cookie </h2>
<p className="text-sm text-gray-600">
<strong>B / / </strong> cookie
<br />
YouTube cookie
</p>
<div className="rounded bg-gray-50 p-3 text-xs text-gray-600">
<a className="text-blue-600 underline" href="https://github.com/JefferyHcool/BiliNote/tree/develop/BillNote_extension" target="_blank" rel="noreferrer">BillNote_extension</a> cookie
</div>
<div className="flex gap-2 justify-between">
<button className="text-sm text-gray-500 hover:text-gray-800" onClick={prev}></button>
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" onClick={finish}>
BiliNote
</button>
</div>
</section>
)}
</div>
</div>
)
}
export default Onboarding

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

@@ -73,6 +73,28 @@ export default function Transcriber() {
}, [modelStatuses, mlxModelStatuses, fetchModelsStatus])
const handleSave = async () => {
// 切到本地 whisper 引擎且选了未下载的模型时,提前 confirm避免用户保存后到首次任务才发现要下 GB 级模型
if (isWhisperType(selectedType)) {
const pool = selectedType === 'mlx-whisper' ? mlxModelStatuses : modelStatuses
const target = pool.find(m => m.model_size === selectedModelSize)
if (target && !target.downloaded && !target.downloading) {
const sizeHint: Record<string, string> = {
'tiny': '~75MB',
'base': '~150MB',
'small': '~500MB',
'medium': '~1.5GB',
'large-v3': '~3GB',
'large-v3-turbo': '~1.6GB',
}
const ok = window.confirm(
`选择 ${selectedType} / ${selectedModelSize} 后,首次转写时会下载该模型(${sizeHint[selectedModelSize] || '体积未知'})。\n` +
`网络较差时容易中断;推荐改用 Groq / 必剪 / 快手 等在线引擎。\n\n` +
'继续保存吗?',
)
if (!ok) return
}
}
setSaving(true)
try {
const payload: { transcriber_type: string; whisper_model_size?: string } = {

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

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

@@ -2,6 +2,101 @@
本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [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
- 前端 vite build 在 Docker / Tauri CI 中失败:`Rollup failed to resolve import '@tauri-apps/api/event'`。v2.2.0 加的 P1/P2 桌面端组件用了 `await import('@tauri-apps/api/event')``'@tauri-apps/api/core'`,但 `@tauri-apps/api` 只是 `@tauri-apps/plugin-shell` 的间接依赖,没在 `BillNote_frontend/package.json` 直接声明Rollup 在 production build 时静态分析报"无法解析"
- `BillNote_frontend/package.json`:把 `@tauri-apps/api` 加为直接依赖(`^2.10.1`,与 lockfile 中已有的 transitive 版本一致)
- 本地 `DOCKER_BUILD=1 pnpm run build` 复现 + 验证修复
## [2.2.2] - 2026-05-09
补 v2.2.1 漏掉的 Tauri 桌面端 build 修复。
### Fixed
- 桌面端 Tauri 构建失败v2.2.1 的 hotfix 只修了 Docker 镜像构建里的 pnpm 版本,`main.yml``pnpm/action-setup@v4 with: version: 'latest'` 没改,于是桌面端 build 仍然在 `Install frontend dependencies` 步报 `ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite`pnpm 11 要求 Node 22+,但 main.yml 用的 node 20。pin 到 `9.15.0`,与 Docker 侧一致。
## [2.2.1] - 2026-05-09
补 v2.2.0 ghcr.io 镜像构建失败。
### Fixed
- Docker 镜像构建失败:`v2.2.0` tag 触发的 ghcr.io 推送在 frontend-builder 第 5/7 步 `pnpm install --frozen-lockfile``ERR_UNKNOWN_BUILTIN_MODULE`。根因:`corepack prepare pnpm@latest` 拉到了 pnpm 11.0.9,而 pnpm 11 要求 Node 22+,跟我们的 `node:20-alpine` 不兼容。
- `Dockerfile.complete``BillNote_frontend/Dockerfile` 的 pnpm 版本 pin 到 `9.15.0`lockfile 由 pnpm 9 生成,匹配 Node 20
## [2.2.0] - 2026-05-09
主线:浏览器插件功能与 web 端 NoteForm 完整对齐;桌面客户端 UX 与错误恢复一波重炼。
### Added — 浏览器插件
- 笔记选项与 web 端 NoteForm 完整对齐:
- `style` 由自由文本改成 9 个预设下拉minimal / detailed / academic / tutorial / xiaohongshu / life_journal / task_oriented / business / meeting_minutes与 backend `prompt_builder.note_styles` 严格匹配(之前自由文本不命中 enum 等于没传——隐性 bug
- `format` 完整 4 个 checkboxtoc / link / screenshot / summary原来只有 screenshot/link
- `extras` 文本框:拼接到 prompt 末尾的 ad-hoc 提示
- 多模态视频理解:`video_understanding` 开关 + `video_interval`1-30 秒)+ `grid_size`[r,c]1-10抽帧拼图喂视觉模型提示需选视觉模型才生效
### Added — 桌面客户端
- **首启 4 步引导**`/onboarding`):后端连通性自检 → LLM 供应商 + 模型 → 转写引擎选择(默认推荐 Groq→ Cookie 同步说明。完成后 `localStorage('bilinote-onboarded')` 标记,纯 web 端不打扰
- **Sidecar 健康度面板**:右下角浮动状态点(绿/黄/红5s 轮询 `/sys_health`),点开抽屉看最近 200 行后端日志、一键重启后端(新增 Tauri command `restart_backend_sidecar`)、复制日志
- **启动期路径诊断**Tauri `setup` 中检测安装路径含非 ASCII / 含空格 / 父目录不可写时emit `backend-warning` 让前端顶端横幅显式告警,主动暴露 README 长期文字警告但无防御的"中文路径"等坑
### Changed
- Whisper 默认模型 size 从 `medium`~1.5GB)改为 `tiny`~75MB新装用户没主动设置时不再卡在首次大模型下载高精度可在「音频转写配置」页主动切
- 切到 `fast-whisper` / `mlx-whisper` 且当前 size 未下载时,「音频转写配置」页保存前 confirm 体积提示,并推荐改用在线引擎
- Tauri sidecar 启动逻辑抽出 `spawn_backend_sidecar()`child handle 存进 `SidecarHandle` state 以支持后续 restart
- sidecar stdout/stderr emit 时不再用 `format!("'{}'", ...)` 包引号,原文直传(前端 hook 兼容旧格式兜底剥引号)
### Fixed
- WhisperTranscriber 在半成品模型目录上死循环报 `Unable to open file 'model.bin'`:判定从「目录存在」改为「`model.bin` 落盘」半成品目录会被识别并重新下载PR `fix/backend-deploy-resilience`
- `/api/deploy_status` 在没装 torch 的部署上 `ModuleNotFoundError: No module named 'torch'` 500torch 改 try/except未装时返回 `{available: false, torch_installed: false}`transcriber 配置 + ffmpeg 也都裹 try单项失败不再打死整个监控页同上 PR
- `routers/config._check_whisper_model_exists` 同步改用 `model.bin` 判定,避免「已下载」状态在监控页误报
## [2.1.4] - 2026-05-07
CI 工程化修复,无运行时行为变化。

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,9 +32,14 @@ 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
RUN corepack enable && corepack prepare pnpm@latest --activate
# pnpm 版本 pin 到 9 系列:
# - lockfile (BillNote_frontend/pnpm-lock.yaml) 是 lockfileVersion '9.0',由 pnpm 9 生成
# - pnpm 11+ 要求 Node 22+,与 node:20 不兼容ERR_UNKNOWN_BUILTIN_MODULE
# - 不用 @latest 避免上游 pnpm 升级悄悄破坏 CI
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /tmp/frontend
@@ -46,7 +55,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
@@ -81,6 +91,10 @@ RUN rm -rf /etc/nginx/conf.d/default.conf
COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf
# 创建 supervisor 配置
# 关键点supervisord 默认 *不* 把自己的环境变量传给子进程。
# 在 [supervisord] 块用 environment= 设兜底默认值;在 [program:backend] 用
# %(ENV_*)s 显式引用,等价于「把 host 通过 docker run -e 或 env_file 传进来的
# 变量再透传给 python main.py」。漏掉这一步就是用户「改 .env 没反应」的根因。
RUN mkdir -p /var/log/supervisor
COPY <<EOF /etc/supervisor/conf.d/supervisord.conf
[supervisord]
@@ -88,6 +102,7 @@ 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="note_results",IMAGE_BASE_URL="/static/screenshots",ENV="production",GROQ_TRANSCRIBER_MODEL="whisper-large-v3-turbo"
[program:nginx]
command=nginx -g "daemon off;"
@@ -103,7 +118,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",IMAGE_BASE_URL="%(ENV_IMAGE_BASE_URL)s",ENV="%(ENV_ENV)s",GROQ_TRANSCRIBER_MODEL="%(ENV_GROQ_TRANSCRIBER_MODEL)s"
EOF
# 修改 nginx 配置以使用本地 backend
@@ -111,5 +126,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"]

144
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.1.4</h1>
<h1 align="center" > BiliNote v2.3.1</h1>
</div>
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
@@ -18,18 +18,36 @@
<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) 页面下载最新版本。
@@ -53,6 +71,36 @@ 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'(缺直接依赖声明)
### v2.2.2 修订
- 修复 v2.2.0 桌面端 Tauri 构建失败main.yml 的 pnpm 版本没 pinpnpm 11 不兼容 Node 20
### v2.2.1 修订
- 修复 v2.2.0 ghcr.io 镜像构建失败pnpm@latest 拉到 11与 Node 20 不兼容pin 到 pnpm 9.15.0
### v2.2.0 新增
- **浏览器插件**笔记选项与 web 端完整对齐style 9 个预设下拉、format 4 个 checkbox、extras 文本框、多模态视频理解开关
- **桌面客户端**首启 4 步引导(连通自检 → 供应商/模型 → 转写引擎 → Cookie 提示)
- **桌面客户端**右下角后端运行状态指示,点开看日志、一键重启
- **桌面客户端**启动期主动检测中文 / 空格 / 不可写安装路径,弹横幅告警
- Whisper 默认 size 从 medium~1.5GB)改为 tiny~75MB切大模型时显式 confirm
-whisper 半成品模型目录死循环;`/deploy_status` 在没装 torch 的部署 500
- 详见 [CHANGELOG.md](./CHANGELOG.md)
### v2.1.4 修订
- CI桌面端 Tauri 构建去掉 Linux17m+ 慢线退役Linux 用户继续走 Docker 镜像)
@@ -124,13 +172,74 @@ docker run -d -p 80:80 \
也可以使用 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. 克隆仓库
@@ -221,11 +330,20 @@ docker-compose -f docker-compose.gpu.yml up -d
### Contact and Join-联系和加入社区
扫码加入 BiliNote 交流微信群(如二维码失效请到 [Issues](https://github.com/JefferyHcool/BiliNote/issues) 反馈):
扫码加入 BiliNote 交流微信群(共 5 个群,任选一个即可;二维码会定期更新,如已失效请到 [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-group-1.png" alt="BiliNote 交流群 1" width="200" /><br/>交流群 1</td>
<td align="center"><img src="./doc/wechat-group-2.png" alt="BiliNote 交流群 2" width="200" /><br/>交流群 2</td>
<td align="center"><img src="./doc/wechat-group-3.png" alt="BiliNote 交流群 3" width="200" /><br/>交流群 3</td>
</tr>
<tr>
<td align="center"><img src="./doc/wechat-group-4.png" alt="BiliNote 交流群 4" width="200" /><br/>交流群 4</td>
<td align="center"><img src="./doc/wechat-group-5.png" alt="BiliNote 交流群 5" width="200" /><br/>交流群 5</td>
<td></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

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

@@ -80,6 +80,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,10 +117,33 @@ _downloading: dict[str, str] = {} # model_size -> status ("downloading" | "done
def _check_whisper_model_exists(model_size: str, subdir: str = "whisper") -> bool:
"""检查指定 whisper 模型是否已下载到本地。"""
"""检查指定 whisper 模型是否已下载完整到本地。
必须 model.bin 落盘才算完成,仅有空目录或半成品不能算「已下载」——
否则监控页会显示绿勾但加载时报「Unable to open file 'model.bin'」。
"""
model_dir = get_model_dir(subdir)
model_path = os.path.join(model_dir, f"whisper-{model_size}")
return Path(model_path).exists()
return (Path(model_path) / "model.bin").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")
@@ -113,11 +166,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,
@@ -146,7 +197,8 @@ def _do_download_whisper(model_size: str):
_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():
# 用 model.bin 判定而非目录存在:半成品目录不能算「已下载」
if (Path(model_path) / "model.bin").exists():
_downloading[model_size] = "done"
return
repo_id = MODEL_MAP.get(model_size)
@@ -179,7 +231,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}")
@@ -214,46 +267,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

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

@@ -25,7 +25,12 @@ class TranscriberConfigManager:
json.dump(data, f, ensure_ascii=False, indent=2)
def get_config(self) -> Dict[str, Any]:
"""获取当前转写器配置fallback 到环境变量默认值。"""
"""获取当前转写器配置fallback 到环境变量默认值。
whisper 默认 size 从 'medium' (~1.5GB) 改为 'tiny' (~75MB)
新装用户没主动设置时不应该被首次下载卡住。想要更高精度可在「音频转写配置」
页主动切换。
"""
data = self._read()
return {
"transcriber_type": data.get(
@@ -34,7 +39,7 @@ class TranscriberConfigManager:
),
"whisper_model_size": data.get(
"whisper_model_size",
os.getenv("WHISPER_MODEL_SIZE", "medium"),
os.getenv("WHISPER_MODEL_SIZE", "tiny"),
),
}
@@ -56,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

@@ -10,6 +10,7 @@ from app.utils.path_helper import get_model_dir
from events import transcription_finished
from pathlib import Path
import os
import shutil
from tqdm import tqdm
from modelscope import snapshot_download
@@ -50,22 +51,41 @@ class WhisperTranscriber(Transcriber):
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,
repo_id = MODEL_MAP[model_size]
local_dir=model_path,
)
# 第一步:目录 / model.bin 不在 → 下载。
# 关键判据用 model.bin 而不是目录存在:首次下载若被打断(网络中断 / 磁盘满 /
# 容器被 kill会留下半成品目录只看目录存在会跳过下载。
model_bin = Path(model_path) / "model.bin"
if not model_bin.exists():
if Path(model_path).exists():
logger.warning(f"模型目录 {model_path} 存在但 model.bin 缺失(上次下载未完成),重新下载")
else:
logger.info(f"模型 whisper-{model_size} 不存在,开始下载...")
model_path = snapshot_download(repo_id, local_dir=model_path)
logger.info("模型下载完成")
self.model = WhisperModel(
model_size_or_path=model_path,
device=self.device,
compute_type=self.compute_type,
download_root=model_dir
)
# 第二步加载。model.bin 可能存在但【内容截断】(下载到一半被 kill
# 此时 WhisperModel() 会抛 "File model.bin is incomplete: failed to read a buffer..."。
# 捕获后删掉损坏目录、重新下载、再试一次——自愈,避免 500 死循环。
try:
self.model = WhisperModel(
model_size_or_path=model_path,
device=self.device,
compute_type=self.compute_type,
download_root=model_dir,
)
except Exception as e:
logger.warning(f"加载 whisper-{model_size} 失败(疑似模型文件损坏 / 截断):{e};删除后重新下载")
shutil.rmtree(model_path, ignore_errors=True)
model_path = snapshot_download(repo_id, local_dir=model_path)
logger.info("模型重新下载完成,重试加载")
self.model = WhisperModel(
model_size_or_path=model_path,
device=self.device,
compute_type=self.compute_type,
download_root=model_dir,
)
@staticmethod
def is_torch_installed() -> bool:
try:

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

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

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

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