mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-06-03 23:01:38 +08:00
Compare commits
42 Commits
feat/deskt
...
v2.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b927db363 | ||
|
|
c42ceaaa32 | ||
|
|
177ee4ba3a | ||
|
|
aae17abf9a | ||
|
|
33d44e32d2 | ||
|
|
ce58cb9352 | ||
|
|
2043d89288 | ||
|
|
56e075253a | ||
|
|
f1b091b846 | ||
|
|
37f7ee6e15 | ||
|
|
41f17592c2 | ||
|
|
88d25f8cc1 | ||
|
|
de630dadb3 | ||
|
|
7b5e6099e8 | ||
|
|
bb9a70eee2 | ||
|
|
e9d4740cc7 | ||
|
|
c5900a9026 | ||
|
|
63577aa1aa | ||
|
|
ec33ae35ed | ||
|
|
2e69d1179b | ||
|
|
7e5be46cda | ||
|
|
0742387235 | ||
|
|
604cdefa15 | ||
|
|
ff91f74bef | ||
|
|
9bbae2c0c4 | ||
|
|
5b5bf802af | ||
|
|
ecc2e56246 | ||
|
|
d8470bacbc | ||
|
|
0af2efb4de | ||
|
|
721bda5280 | ||
|
|
a928e0e38f | ||
|
|
b117ab9f71 | ||
|
|
c4abaf4e60 | ||
|
|
50f0816dab | ||
|
|
2bb69d1581 | ||
|
|
f6d299ce48 | ||
|
|
ed1ee0a151 | ||
|
|
c9497b502c | ||
|
|
26e23d0f2c | ||
|
|
64882e6a77 | ||
|
|
a46880f169 | ||
|
|
dbe7b89754 |
35
.env.example
35
.env.example
@@ -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
|
||||
|
||||
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
@@ -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
|
||||
|
||||
@@ -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.x:lockfile 是 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
21
BillNote_frontend/pnpm-lock.yaml
generated
21
BillNote_frontend/pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
961
BillNote_frontend/src-tauri/Cargo.lock
generated
961
BillNote_frontend/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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.10(commit 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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 关闭期统一杀 sidecar,take() 把 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 并 kill(kill 失败也继续,可能进程已经退了)
|
||||
{
|
||||
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 line,64 字节够了
|
||||
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)]
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 —— 它们比 lastError(hook 自己总结的那句)信息密度更高,
|
||||
// 通常 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">
|
||||
仍然无法解决?复制日志去
|
||||
<a
|
||||
href="https://github.com/JefferyHcool/BiliNote/issues"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
GitHub Issues
|
||||
</a>
|
||||
反馈。
|
||||
</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
|
||||
|
||||
@@ -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 + 转写 API(Groq 等)+ 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
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 失败都会弹一个红色 toast,2s 一次轮询会叠出十几个。
|
||||
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 用来强制重启 useEffect(retry 时 +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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
359
BillNote_frontend/src/pages/Onboarding/index.tsx
Normal file
359
BillNote_frontend/src/pages/Onboarding/index.tsx
Normal 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: '完全离线但首次需下载 ~75MB(tiny)至 ~3GB(large-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
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } = {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
19
BillNote_frontend/src/services/proxy.ts
Normal file
19
BillNote_frontend/src/services/proxy.ts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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: '请求失败,请检查网络连接',
|
||||
|
||||
95
CHANGELOG.md
95
CHANGELOG.md
@@ -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、转写 API(Groq 等)、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 个 checkbox(toc / 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'` 500:torch 改 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 工程化修复,无运行时行为变化。
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -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
|
||||
|
||||
|
||||
@@ -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 走 musl,pnpm 会按 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
144
README.md
@@ -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 版本没 pin,pnpm 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 构建去掉 Linux(17m+ 慢线退役;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.io(build 阶段报 `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>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
@@ -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:
|
||||
|
||||
@@ -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='连接成功')
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
60
backend/app/services/proxy_config_manager.py
Normal file
60
backend/app/services/proxy_config_manager.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class ProxyConfigManager:
|
||||
"""全局代理配置,存 JSON 文件,支持前端动态修改。
|
||||
|
||||
作用范围:LLM API + 转写 API(Groq 等)+ 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
45
backend/app/utils/openai_client.py
Normal file
45
backend/app/utils/openai_client.py
Normal 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)
|
||||
@@ -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
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
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
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
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
BIN
doc/wechat-group-5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 491 KiB |
@@ -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 绑到 /app,DB / 转写器配置 / 截图 / 上传都持久化
|
||||
- ./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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user