diff --git a/BillNote_frontend/src-tauri/src/lib.rs b/BillNote_frontend/src-tauri/src/lib.rs index f6407df..2c8be15 100644 --- a/BillNote_frontend/src-tauri/src/lib.rs +++ b/BillNote_frontend/src-tauri/src/lib.rs @@ -1,8 +1,14 @@ -use tauri::{Manager, Emitter}; +use tauri::{Manager, Emitter, State}; use tauri_plugin_shell::ShellExt; -use tauri_plugin_shell::process::CommandEvent; +use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use std::env; use std::collections::HashMap; +use std::path::Path; +use std::sync::Mutex; +use serde::Serialize; + +// Sidecar 子进程句柄,用 Mutex 包裹方便 restart 时杀旧进程 +struct SidecarHandle(Mutex>); #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -18,77 +24,31 @@ pub fn run() { } let exe_path = env::current_exe().expect("无法获取当前可执行文件路径"); - let sidecar_dir = exe_path.parent().expect("无法获取可执行文件的父目录"); - // 收集所有系统环境变量 - let mut all_env_vars = HashMap::new(); - for (key, value) in env::vars() { - all_env_vars.insert(key, value); + // 安装路径诊断:PyInstaller sidecar 在含非 ASCII / 空格的路径下经常炸(README 已警告但缺主动防御) + // 命中时把诊断信息 emit 给前端,由顶端横幅展示,不阻断启动 + let diag = analyze_install_path(&exe_path); + if diag.path_has_non_ascii || diag.path_has_space || !diag.parent_writable { + let app_handle = app.handle().clone(); + // 等前端首屏挂载好 listener;setup 阶段 window 已存在但 React 还没 render + // 用独立线程 + 标准 sleep,不引入 tokio 依赖 + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(1500)); + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.emit("backend-warning", &diag); + } + }); } - // 增强 PATH 环境变量,添加常见的二进制路径 - let current_path = all_env_vars.get("PATH").cloned().unwrap_or_default(); - let additional_paths = get_additional_binary_paths(); - let enhanced_path = enhance_path_variable(¤t_path, &additional_paths); - all_env_vars.insert("PATH".to_string(), enhanced_path); - - // 打印一些关键环境变量用于调试 - println!("Enhanced PATH: {}", all_env_vars.get("PATH").unwrap_or(&"Not found".to_string())); - println!("Total environment variables: {}", all_env_vars.len()); - // 检查 ffmpeg 是否在 PATH 中可用 check_ffmpeg_availability(); - // 启动 Python 后端侧车 - let mut sidecar_command = app.shell().sidecar("BiliNoteBackend").unwrap(); - - // 设置所有环境变量到 sidecar - for (key, value) in &all_env_vars { - sidecar_command = sidecar_command.env(key, value); - } - - let (mut rx, _child) = sidecar_command - .current_dir(sidecar_dir) - .spawn() - .expect("Failed to spawn sidecar"); - - // 获取主窗口句柄用于发送事件 - let window = app.get_webview_window("main").unwrap(); - - tauri::async_runtime::spawn(async move { - // 读取诸如 stdout 之类的事件 - while let Some(event) = rx.recv().await { - match event { - CommandEvent::Stdout(line) => { - let output = String::from_utf8_lossy(&line); - println!("Backend stdout: {}", output); - - // 发送到前端 - window - .emit("backend-message", Some(format!("'{}'", output))) - .expect("failed to emit event"); - } - CommandEvent::Stderr(line) => { - let error = String::from_utf8_lossy(&line); - eprintln!("Backend stderr: {}", error); - - window - .emit("backend-error", Some(format!("'{}'", error))) - .expect("failed to emit event"); - } - CommandEvent::Terminated(payload) => { - println!("Backend terminated with code: {:?}", payload.code); - window - .emit("backend-terminated", Some(payload.code)) - .expect("failed to emit event"); - break; - } - _ => { - println!("Backend event: {:?}", event); - } - } - } - }); + // 启动 Sidecar 并把 child handle 存到 state,方便后续 restart_backend_sidecar 使用 + let child = spawn_backend_sidecar(app.handle()).map_err(|e| { + eprintln!("Sidecar 启动失败: {}", e); + e + })?; + app.manage(SidecarHandle(Mutex::new(Some(child)))); Ok(()) }) @@ -96,7 +56,9 @@ pub fn run() { get_system_env_vars, find_executable_path, run_command_with_env, - test_ffmpeg_access + test_ffmpeg_access, + get_install_path_diagnostics, + restart_backend_sidecar ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -268,6 +230,150 @@ async fn test_ffmpeg_access() -> Result { run_command_with_env("ffmpeg".to_string(), vec!["-version".to_string()]).await } +// 启动后端 Sidecar:负责装环境变量、spawn、挂 stdout/stderr/terminated 监听并 emit 给前端。 +// 第一次启动 + restart_backend_sidecar 都走这里,保持单一启动路径。 +fn spawn_backend_sidecar(app_handle: &tauri::AppHandle) -> Result { + let exe_path = env::current_exe().map_err(|e| format!("无法获取可执行文件路径: {}", e))?; + let sidecar_dir = exe_path + .parent() + .ok_or("无法获取可执行文件父目录")? + .to_path_buf(); + + // 收集所有系统环境变量并增强 PATH(含 ffmpeg 常见安装位置) + let mut all_env_vars = HashMap::new(); + for (key, value) in env::vars() { + all_env_vars.insert(key, value); + } + let current_path = all_env_vars.get("PATH").cloned().unwrap_or_default(); + let additional_paths = get_additional_binary_paths(); + let enhanced_path = enhance_path_variable(¤t_path, &additional_paths); + all_env_vars.insert("PATH".to_string(), enhanced_path); + + let mut sidecar_command = app_handle + .shell() + .sidecar("BiliNoteBackend") + .map_err(|e| format!("找不到 BiliNoteBackend sidecar: {}", e))?; + for (key, value) in &all_env_vars { + sidecar_command = sidecar_command.env(key, value); + } + + let (mut rx, child) = sidecar_command + .current_dir(sidecar_dir) + .spawn() + .map_err(|e| format!("spawn sidecar 失败: {}", e))?; + + // 异步监听 stdout / stderr / terminated 事件,转发到前端 webview + let app_handle_for_listener = app_handle.clone(); + tauri::async_runtime::spawn(async move { + while let Some(event) = rx.recv().await { + // window 句柄每次重新取,允许窗口关闭重开 + let window = app_handle_for_listener.get_webview_window("main"); + match event { + CommandEvent::Stdout(line) => { + let output = String::from_utf8_lossy(&line).to_string(); + println!("Backend stdout: {}", output); + if let Some(w) = window { + let _ = w.emit("backend-message", Some(output)); + } + } + CommandEvent::Stderr(line) => { + let error = String::from_utf8_lossy(&line).to_string(); + eprintln!("Backend stderr: {}", error); + if let Some(w) = window { + let _ = w.emit("backend-error", Some(error)); + } + } + CommandEvent::Terminated(payload) => { + println!("Backend terminated with code: {:?}", payload.code); + if let Some(w) = window { + let _ = w.emit("backend-terminated", Some(payload.code)); + } + break; + } + _ => { + println!("Backend event: {:?}", event); + } + } + } + }); + + Ok(child) +} + +// 重启 sidecar:杀旧 child,spawn 新 child,回写到 state。 +#[tauri::command] +fn restart_backend_sidecar( + state: State<'_, SidecarHandle>, + app: tauri::AppHandle, +) -> Result<(), String> { + // 1. 拿出旧 child 并 kill(kill 失败也继续,可能进程已经退了) + { + let mut guard = state.0.lock().map_err(|e| format!("锁 sidecar state 失败: {}", e))?; + if let Some(child) = guard.take() { + let _ = child.kill(); + } + } + // 2. 重新 spawn + let new_child = spawn_backend_sidecar(&app)?; + { + let mut guard = state.0.lock().map_err(|e| format!("锁 sidecar state 失败: {}", e))?; + *guard = Some(new_child); + } + // 3. emit 一个事件让前端知道已重启 + if let Some(window) = app.get_webview_window("main") { + let _ = window.emit("backend-restarted", ()); + } + Ok(()) +} + +// 安装路径诊断:PyInstaller 在含非 ASCII / 空格的路径下加载 _internal/* 经常炸; +// 父目录不可写时模型 / 配置 / 日志也无法落盘 +#[derive(Serialize, Clone)] +struct InstallPathDiagnostics { + exe_path: String, + path_has_non_ascii: bool, + path_has_space: bool, + parent_writable: bool, + platform: String, +} + +fn analyze_install_path(exe_path: &Path) -> InstallPathDiagnostics { + let path_str = exe_path.to_string_lossy().to_string(); + // 不在 ASCII 范围内的字符(中文 / 日文 / 西里尔等都会命中 PyInstaller 路径解析坑) + let has_non_ascii = path_str.chars().any(|c| !c.is_ascii()); + // 空格本身在 Windows shell 引号场景偶尔出问题,且 macOS path 里也偶尔触发 sidecar 启动失败 + let has_space = path_str.contains(' '); + // 父目录可写:PyInstaller 解压 _internal/、写日志、写配置都需要这个 + let parent = exe_path.parent(); + let parent_writable = parent + .and_then(|p| { + let probe = p.join(".bilinote_write_probe"); + match std::fs::write(&probe, b"x") { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + Some(true) + } + Err(_) => Some(false), + } + }) + .unwrap_or(false); + + InstallPathDiagnostics { + exe_path: path_str, + path_has_non_ascii: has_non_ascii, + path_has_space: has_space, + parent_writable, + platform: std::env::consts::OS.to_string(), + } +} + +// Tauri 命令:让前端按需重新查询诊断结果(比如用户卸载到新目录后重启) +#[tauri::command] +fn get_install_path_diagnostics() -> InstallPathDiagnostics { + let exe_path = env::current_exe().unwrap_or_default(); + analyze_install_path(&exe_path) +} + // 可选:添加一个函数来动态更新 sidecar 的环境变量 #[tauri::command] async fn update_sidecar_environment( diff --git a/BillNote_frontend/src/App.tsx b/BillNote_frontend/src/App.tsx index ea47b7f..682a75a 100644 --- a/BillNote_frontend/src/App.tsx +++ b/BillNote_frontend/src/App.tsx @@ -5,11 +5,23 @@ import { useTaskPolling } from '@/hooks/useTaskPolling.ts' import { useCheckBackend } from '@/hooks/useCheckBackend.ts' import { systemCheck } from '@/services/system.ts' import BackendInitDialog from '@/components/BackendInitDialog' +import StartupBanner from '@/components/SystemDiagnostic/StartupBanner' +import BackendHealthIndicator from '@/components/BackendHealth/BackendHealthIndicator' 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 + 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')) @@ -34,6 +46,7 @@ function App() { if (!initialized) { return ( <> + ) @@ -42,10 +55,13 @@ function App() { // 后端已初始化,渲染主应用 return ( <> + + 加载中…}> - }> + } /> + }> } /> }> } /> diff --git a/BillNote_frontend/src/components/BackendHealth/BackendHealthIndicator.tsx b/BillNote_frontend/src/components/BackendHealth/BackendHealthIndicator.tsx new file mode 100644 index 0000000..ce8217e --- /dev/null +++ b/BillNote_frontend/src/components/BackendHealth/BackendHealthIndicator.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react' +import { useBackendEvents } from './useBackendEvents' +import BackendLogPanel from './BackendLogPanel' + +// 健康度判定: +// - 绿:sidecar running 且 /sys_health 通 +// - 黄:sidecar running 但 /sys_health 失败 (ffmpeg 缺等) +// - 红:sidecar terminated 或 /sys_health 连续 3 次失败 + +type Health = 'green' | 'yellow' | 'red' | 'unknown' + +const HEALTH_POLL_MS = 5000 +const SYS_HEALTH_PATH = '/api/sys_health' + +function backendBase(): string { + // 与 services/request.ts 用的一致 + const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined + return (fromEnv ?? '').replace(/\/$/, '') +} + +const BackendHealthIndicator = () => { + const { status, isTauri, exitCode, logs, restart, copyLogs } = useBackendEvents() + const [open, setOpen] = useState(false) + const [healthCheckFailures, setHealthCheckFailures] = useState(0) + const [lastHealthOk, setLastHealthOk] = useState(null) + + // 仅在 Tauri 环境挂指示器;纯 web 用户由 useCheckBackend 接管 + useEffect(() => { + if (!isTauri) return + let mounted = true + + async function ping() { + try { + const res = await fetch(`${backendBase()}${SYS_HEALTH_PATH}`) + const ok = res.ok + if (!mounted) return + if (ok) { + setHealthCheckFailures(0) + setLastHealthOk(true) + } + else { + setHealthCheckFailures(c => c + 1) + setLastHealthOk(false) + } + } + catch { + if (!mounted) return + setHealthCheckFailures(c => c + 1) + setLastHealthOk(false) + } + } + + ping() + const t = setInterval(ping, HEALTH_POLL_MS) + return () => { + mounted = false + clearInterval(t) + } + }, [isTauri]) + + if (!isTauri) return null + + const health: Health = (() => { + if (status === 'terminated') return 'red' + if (healthCheckFailures >= 3) return 'red' + if (lastHealthOk === false) return 'yellow' + if (lastHealthOk === true) return 'green' + return 'unknown' + })() + + const colorMap: Record = { + green: 'bg-green-500', + yellow: 'bg-amber-500', + red: 'bg-red-500', + unknown: 'bg-gray-400', + } + + const labelMap: Record = { + green: '后端运行正常', + yellow: '后端运行中(部分检查未通过)', + red: status === 'terminated' ? `后端已退出 (code=${exitCode ?? 'unknown'})` : '后端无响应', + unknown: '后端状态未知', + } + + return ( + <> + + + {open && ( + setOpen(false)} + /> + )} + + ) +} + +export default BackendHealthIndicator diff --git a/BillNote_frontend/src/components/BackendHealth/BackendLogPanel.tsx b/BillNote_frontend/src/components/BackendHealth/BackendLogPanel.tsx new file mode 100644 index 0000000..8683400 --- /dev/null +++ b/BillNote_frontend/src/components/BackendHealth/BackendLogPanel.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef, useState } from 'react' +import type { LogEntry, BackendStatus } from './useBackendEvents' + +interface Props { + status: BackendStatus + exitCode: number | null + logs: LogEntry[] + health: 'green' | 'yellow' | 'red' | 'unknown' + onRestart: () => Promise + onCopyLogs: () => Promise + onClose: () => void +} + +const BackendLogPanel = ({ status, exitCode, logs, health, onRestart, onCopyLogs, onClose }: Props) => { + const [restarting, setRestarting] = useState(false) + const [copied, setCopied] = useState(false) + const scrollRef = useRef(null) + + // 新日志进来自动滚到底 + useEffect(() => { + if (scrollRef.current) + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + }, [logs]) + + async function handleRestart() { + setRestarting(true) + try { await onRestart() } + catch { /* errors already in log via useBackendEvents */ } + finally { setRestarting(false) } + } + + async function handleCopy() { + const ok = await onCopyLogs() + setCopied(ok) + setTimeout(() => setCopied(false), 1500) + } + + return ( + <> + {/* 半透明遮罩 */} +
+ + + + ) +} + +export default BackendLogPanel diff --git a/BillNote_frontend/src/components/BackendHealth/useBackendEvents.ts b/BillNote_frontend/src/components/BackendHealth/useBackendEvents.ts new file mode 100644 index 0000000..cd2a5d2 --- /dev/null +++ b/BillNote_frontend/src/components/BackendHealth/useBackendEvents.ts @@ -0,0 +1,119 @@ +import { useEffect, useRef, useState } from 'react' + +// 桌面端 Sidecar 健康度。监听 Tauri 侧的 backend-message / backend-error / +// backend-terminated / backend-restarted 事件,把 stdout/stderr 缓冲成 ring buffer, +// 同时维护进程运行状态。 + +const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window + +export type LogLevel = 'info' | 'error' + +export interface LogEntry { + level: LogLevel + text: string + ts: number +} + +export type BackendStatus = 'running' | 'terminated' + +const MAX_LOG_LINES = 200 + +interface BackendEvents { + status: BackendStatus + exitCode: number | null + logs: LogEntry[] + /** 调 Tauri 命令重启 sidecar */ + restart: () => Promise + /** 复制全部日志到剪贴板 */ + copyLogs: () => Promise + isTauri: boolean +} + +export function useBackendEvents(): BackendEvents { + const [status, setStatus] = useState('running') + const [exitCode, setExitCode] = useState(null) + const [logs, setLogs] = useState([]) + // 用 ref 持有最新 logs 数组,append 时不被闭包陷阱卡到旧值 + const logsRef = useRef([]) + + function append(entry: LogEntry) { + const next = logsRef.current.concat(entry) + if (next.length > MAX_LOG_LINES) + next.splice(0, next.length - MAX_LOG_LINES) + logsRef.current = next + setLogs(next) + } + + useEffect(() => { + if (!isTauri) return + + let unlisteners: Array<() => void> = [] + + ;(async () => { + const { listen } = await import('@tauri-apps/api/event') + + const offMsg = await listen('backend-message', event => { + append({ level: 'info', text: stripQuotes(event.payload), ts: Date.now() }) + }) + const offErr = await listen('backend-error', event => { + append({ level: 'error', text: stripQuotes(event.payload), ts: Date.now() }) + }) + const offTerm = await listen('backend-terminated', event => { + setStatus('terminated') + setExitCode(event.payload ?? null) + append({ + level: 'error', + text: `[Backend terminated] code=${event.payload ?? 'unknown'}`, + ts: Date.now(), + }) + }) + const offRestart = await listen('backend-restarted', () => { + setStatus('running') + setExitCode(null) + append({ level: 'info', text: '[Backend restarted]', ts: Date.now() }) + }) + + unlisteners = [offMsg, offErr, offTerm, offRestart] + })() + + return () => { + unlisteners.forEach(fn => fn()) + } + }, []) + + async function restart() { + if (!isTauri) return + const { invoke } = await import('@tauri-apps/api/core') + try { + await invoke('restart_backend_sidecar') + } + catch (e) { + append({ level: 'error', text: `[Restart failed] ${(e as Error).message ?? e}`, ts: Date.now() }) + throw e + } + } + + async function copyLogs() { + const text = logsRef.current + .map(l => `${new Date(l.ts).toISOString().slice(11, 19)} ${l.level === 'error' ? 'E' : 'I'} ${l.text}`) + .join('\n') + try { + await navigator.clipboard.writeText(text) + return true + } + catch { + return false + } + } + + return { status, exitCode, logs, restart, copyLogs, isTauri } +} + +// Rust 早期版本 emit 时把 stdout 包了一层 '...',新版本已经直接 emit 原文。 +// 这里做兼容:去掉外层单引号(如果有的话)。 +function stripQuotes(s: string): string { + if (typeof s !== 'string') return String(s) + if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) + return s.slice(1, -1) + return s +} diff --git a/BillNote_frontend/src/components/SystemDiagnostic/StartupBanner.tsx b/BillNote_frontend/src/components/SystemDiagnostic/StartupBanner.tsx new file mode 100644 index 0000000..221ceef --- /dev/null +++ b/BillNote_frontend/src/components/SystemDiagnostic/StartupBanner.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from 'react' + +// 桌面端启动诊断横幅。监听 Tauri 侧 emit 的 backend-warning / backend-error / backend-terminated。 +// 只在 Tauri 环境生效;纯 web 环境(无 window.__TAURI_INTERNALS__)下静默不挂载。 + +type Severity = 'info' | 'warning' | 'error' + +interface DiagnosticPayload { + exe_path?: string + path_has_non_ascii?: boolean + path_has_space?: boolean + parent_writable?: boolean + platform?: string +} + +interface BannerState { + severity: Severity + title: string + detail: string + payload?: DiagnosticPayload + dismissible: boolean +} + +const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window + +function describeWarning(payload: DiagnosticPayload): { title: string; detail: string } { + const parts: string[] = [] + if (payload.path_has_non_ascii) { + parts.push('安装路径包含非 ASCII 字符(中文 / 日文等)') + } + if (payload.path_has_space) { + parts.push('安装路径包含空格') + } + if (payload.parent_writable === false) { + parts.push('安装目录不可写(缺少权限或只读)') + } + return { + title: '检测到可能导致后端启动失败的安装路径', + detail: + `${parts.join(';')}。\n` + + '建议把 BiliNote 重新安装到一个纯英文、无空格、可写的路径下(如 C:\\BiliNote\\ 或 /Applications/)。\n' + + `当前路径:${payload.exe_path || '未知'}`, + } +} + +const StartupBanner = () => { + const [banner, setBanner] = useState(null) + + useEffect(() => { + if (!isTauri) return + + let unlisteners: Array<() => void> = [] + + ;(async () => { + const { listen } = await import('@tauri-apps/api/event') + + const offWarning = await listen('backend-warning', event => { + const { title, detail } = describeWarning(event.payload || {}) + setBanner({ + severity: 'warning', + title, + detail, + payload: event.payload, + dismissible: true, + }) + }) + + const offTerminated = await listen('backend-terminated', event => { + setBanner({ + severity: 'error', + title: '后端进程已退出', + detail: `退出码:${event.payload ?? '未知'}。打开「部署监控」或重启应用以恢复。`, + dismissible: false, + }) + }) + + // backend-error 是 sidecar stderr,量大噪音多,这里不直接展示,留给 P2 的日志面板。 + unlisteners = [offWarning, offTerminated] + })() + + return () => { + unlisteners.forEach(fn => fn()) + } + }, []) + + if (!banner) return null + + const colorByLevel: Record = { + info: 'bg-blue-50 border-blue-300 text-blue-900', + warning: 'bg-amber-50 border-amber-300 text-amber-900', + error: 'bg-red-50 border-red-300 text-red-900', + } + + const iconByLevel: Record = { + info: 'ℹ️', + warning: '⚠️', + error: '✕', + } + + return ( +
+ {iconByLevel[banner.severity]} +
+
{banner.title}
+
+          {banner.detail}
+        
+
+ {banner.dismissible && ( + + )} +
+ ) +} + +export default StartupBanner diff --git a/BillNote_frontend/src/pages/Onboarding/index.tsx b/BillNote_frontend/src/pages/Onboarding/index.tsx new file mode 100644 index 0000000..d60a6c1 --- /dev/null +++ b/BillNote_frontend/src/pages/Onboarding/index.tsx @@ -0,0 +1,265 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { addProvider, addModel, getProviderList, testConnection } from '@/services/model' +import { getTranscriberConfig, updateTranscriberConfig } from '@/services/transcriber' +import logo from '@/assets/icon.svg' + +// 桌面端首启 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(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(null) + const [savingProvider, setSavingProvider] = useState(false) + + // step 3 + const [transcriberType, setTranscriberType] = useState('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(() => { + if (step !== 1) return + let cancelled = false + ;(async () => { + setPinging(true) + try { + await getProviderList() + if (!cancelled) setBackendOk(true) + } + catch { + if (!cancelled) setBackendOk(false) + } + finally { + if (!cancelled) setPinging(false) + } + })() + return () => { cancelled = true } + }, [step]) + + 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 { + // 复用桌面 web 端的 add_provider;type 必须是 'custom'(backend 强制) + const res: any = await addProvider({ + name: providerName.trim(), + api_key: apiKey.trim(), + base_url: baseUrl.trim(), + type: 'custom', + logo: 'custom', + }) + const newId = (res?.data ?? res) as string | undefined + if (!newId) throw new Error('后端未返回 provider id') + setProviderId(newId) + + // 加一个默认 model + await addModel({ provider_id: newId, model_name: modelName.trim() }) + + // 测试连通 + try { await testConnection({ id: newId }) } + catch (e: any) { + // 测试失败不阻断流程,让用户自己决定继续 + console.warn('测试连接失败:', e?.message ?? e) + } + next() + } + catch (e: any) { + setError(`保存失败:${e?.message ?? 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(`保存失败:${e?.message ?? e}`) + } + finally { + setSavingTranscriber(false) + } + } + + function finish() { + markOnboarded() + navigate('/', { replace: true }) + } + + return ( +
+
+
+ logo +
+

欢迎使用 BiliNote

+

几步配置后就可以开始把视频转笔记。

+
+
+ + {/* Stepper */} +
+ {[1, 2, 3, 4].map(s => ( +
+
= s ? 'border-blue-600 bg-blue-600 text-white' : 'border-gray-300 bg-white text-gray-400'}`} + >{s}
+ {s < 4 &&
s ? 'bg-blue-600' : 'bg-gray-300'}`} />} +
+ ))} +
+ + {step === 1 && ( +
+

第 1 步 · 后端连通性

+

桌面端会自动启动 Python 后端进程。检查连通中…

+ {pinging &&
检测中…
} + {backendOk === true &&
✓ 后端已就绪
} + {backendOk === false && ( +
+ ✗ 暂时连不上后端。可能正在初始化(首次启动会下载依赖),等 1-2 分钟再试。 + 右下角的「后端」状态点会持续监控。 +
+ )} +
+ +
+
+ )} + + {step === 2 && ( +
+

第 2 步 · 模型供应商

+

填一个 OpenAI 兼容供应商:DeepSeek / Qwen / Claude / 自托管 / OpenAI 都行。

+ + + + + {error &&
{error}
} +
+ + +
+
+ )} + + {step === 3 && ( +
+

第 3 步 · 音频转写引擎

+

把视频音频转成文字。推荐在线引擎,避免本地下载 ~600MB 的模型。

+
+ {[ + { 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 => ( + + ))} +
+ {error &&
{error}
} +
+ + +
+
+ )} + + {step === 4 && ( +
+

第 4 步 · Cookie 同步(可选)

+

+ 想总结 B 站 / 抖音 / 快手 等需要登录态的平台时,需要把浏览器 cookie 复制到「下载配置」页。 +
+ YouTube 一般不需要 cookie。先跳过也没问题,到时再去配。 +

+
+ 提示:插件版(BillNote_extension)支持一键 cookie 同步;桌面版需手动复制。 +
+
+ + +
+
+ )} +
+
+ ) +} + +export default Onboarding diff --git a/BillNote_frontend/src/pages/SettingPage/transcriber.tsx b/BillNote_frontend/src/pages/SettingPage/transcriber.tsx index e13eb93..17f298c 100644 --- a/BillNote_frontend/src/pages/SettingPage/transcriber.tsx +++ b/BillNote_frontend/src/pages/SettingPage/transcriber.tsx @@ -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 = { + '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 } = { diff --git a/CHANGELOG.md b/CHANGELOG.md index f4134ad..c7cc405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ 本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [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 工程化修复,无运行时行为变化。 diff --git a/README.md b/README.md index eb98c75..d4a8252 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

BiliNote Banner

-

BiliNote v2.1.4

+

BiliNote v2.2.0

AI 视频笔记生成工具 让 AI 为你的视频做笔记

@@ -53,6 +53,16 @@ BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、Y - 笔记顶部视频封面 Banner 展示 - 工作区和生成历史面板支持折叠/展开 +### 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 镜像) diff --git a/backend/app/services/transcriber_config_manager.py b/backend/app/services/transcriber_config_manager.py index d3a69e4..8205372 100644 --- a/backend/app/services/transcriber_config_manager.py +++ b/backend/app/services/transcriber_config_manager.py @@ -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"), ), }