feat(desktop): 后端健康监控韧性 + onboarding 修复 + 全局代理 UI

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
huangjianwu
2026-05-14 19:01:37 +08:00
parent 41f17592c2
commit 37f7ee6e15
22 changed files with 1273 additions and 651 deletions

View File

@@ -25,8 +25,8 @@
"@radix-ui/react-tabs": "^1.1.9", "@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.1.3", "@tailwindcss/vite": "^4.1.3",
"@tauri-apps/api": "^2.10.1", "@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-shell": "~2.2.2", "@tauri-apps/plugin-shell": "~2.3.5",
"@uiw/react-markdown-preview": "^5.1.3", "@uiw/react-markdown-preview": "^5.1.3",
"antd": "^5.24.8", "antd": "^5.24.8",
"axios": "^1.8.4", "axios": "^1.8.4",

View File

@@ -54,11 +54,11 @@ importers:
specifier: ^4.1.3 specifier: ^4.1.3
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2.10.1 specifier: ^2.11.0
version: 2.10.1 version: 2.11.0
'@tauri-apps/plugin-shell': '@tauri-apps/plugin-shell':
specifier: ~2.2.2 specifier: ~2.3.5
version: 2.2.2 version: 2.3.5
'@uiw/react-markdown-preview': '@uiw/react-markdown-preview':
specifier: ^5.1.3 specifier: ^5.1.3
version: 5.1.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 5.1.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -1590,8 +1590,8 @@ packages:
peerDependencies: peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8 vite: ^5.2.0 || ^6 || ^7 || ^8
'@tauri-apps/api@2.10.1': '@tauri-apps/api@2.11.0':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
'@tauri-apps/cli-darwin-arm64@2.10.1': '@tauri-apps/cli-darwin-arm64@2.10.1':
resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==} resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==}
@@ -1669,8 +1669,8 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
'@tauri-apps/plugin-shell@2.2.2': '@tauri-apps/plugin-shell@2.3.5':
resolution: {integrity: sha512-fg9XKWfzRQsN8p+Zrk82WeHvXFvGVnG0/mTlujQdLWNnO5cM6WD9qCrHbFytScVS+WhmRAkuypQPcxeKKl3VBg==} resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -6463,7 +6463,7 @@ snapshots:
tailwindcss: 4.2.2 tailwindcss: 4.2.2
vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) vite: 6.4.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': '@tauri-apps/cli-darwin-arm64@2.10.1':
optional: true optional: true
@@ -6512,9 +6512,9 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-ia32-msvc': 2.10.1
'@tauri-apps/cli-win32-x64-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: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.11.0
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
dependencies: dependencies:

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
function App() { function App() {
useTaskPolling(3000) // 每 3 秒轮询一次 useTaskPolling(3000) // 每 3 秒轮询一次
const { loading, initialized } = useCheckBackend() const { loading, initialized, failed, lastError, retry } = useCheckBackend()
// 在后端初始化完成后执行系统检查 // 在后端初始化完成后执行系统检查
useEffect(() => { useEffect(() => {
@@ -42,12 +42,17 @@ function App() {
} }
}, [initialized]) }, [initialized])
// 如果后端还未初始化,显示初始化对话框 // 如果后端还未初始化,显示初始化对话框loading 或 failed 都展示,由 dialog 内部决定渲染哪一态)
if (!initialized) { if (!initialized) {
return ( return (
<> <>
<StartupBanner /> <StartupBanner />
<BackendInitDialog open={loading} /> <BackendInitDialog
open={loading}
failed={failed}
lastError={lastError}
onRetry={retry}
/>
</> </>
) )
} }

View File

@@ -10,12 +10,14 @@ import BackendLogPanel from './BackendLogPanel'
type Health = 'green' | 'yellow' | 'red' | 'unknown' type Health = 'green' | 'yellow' | 'red' | 'unknown'
const HEALTH_POLL_MS = 5000 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 { 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 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 = () => { const BackendHealthIndicator = () => {

View File

@@ -35,6 +35,9 @@ export function useBackendEvents(): BackendEvents {
const [logs, setLogs] = useState<LogEntry[]>([]) const [logs, setLogs] = useState<LogEntry[]>([])
// 用 ref 持有最新 logs 数组append 时不被闭包陷阱卡到旧值 // 用 ref 持有最新 logs 数组append 时不被闭包陷阱卡到旧值
const logsRef = useRef<LogEntry[]>([]) const logsRef = useRef<LogEntry[]>([])
// 主动重启期Rust 在 kill 老 sidecar 前会 emit 'backend-restarting'。
// 期间到达的 'backend-terminated' 是我们自己造成的,不要污染状态。
const ignoreNextTerminatedRef = useRef(false)
function append(entry: LogEntry) { function append(entry: LogEntry) {
const next = logsRef.current.concat(entry) const next = logsRef.current.concat(entry)
@@ -58,7 +61,23 @@ export function useBackendEvents(): BackendEvents {
const offErr = await listen<string>('backend-error', event => { const offErr = await listen<string>('backend-error', event => {
append({ level: 'error', text: stripQuotes(event.payload), ts: Date.now() }) 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 => { 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') setStatus('terminated')
setExitCode(event.payload ?? null) setExitCode(event.payload ?? null)
append({ append({
@@ -73,7 +92,7 @@ export function useBackendEvents(): BackendEvents {
append({ level: 'info', text: '[Backend restarted]', ts: Date.now() }) append({ level: 'info', text: '[Backend restarted]', ts: Date.now() })
}) })
unlisteners = [offMsg, offErr, offTerm, offRestart] unlisteners = [offMsg, offErr, offRestarting, offTerm, offRestart]
})() })()
return () => { return () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,7 @@ import { Textarea } from '@/components/ui/textarea.tsx'
import { noteStyles, noteFormats, videoPlatforms } from '@/constant/note.ts' import { noteStyles, noteFormats, videoPlatforms } from '@/constant/note.ts'
import { fetchModels } from '@/services/model.ts' import { fetchModels } from '@/services/model.ts'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
/* -------------------- 校验 Schema -------------------- */ /* -------------------- 校验 Schema -------------------- */
const formSchema = z const formSchema = z
@@ -229,8 +230,25 @@ const NoteForm = () => {
} }
// message.success('已提交任务') // message.success('已提交任务')
const data = await generateNote(payload) try {
addPendingTask(data.task_id, values.platform, payload) 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>) => { const onInvalid = (errors: FieldErrors<NoteFormValues>) => {
console.warn('表单校验失败:', errors) console.warn('表单校验失败:', errors)

View File

@@ -1,9 +1,36 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { addProvider, addModel, getProviderList, testConnection } from '@/services/model' import { addProvider, addModel, testConnection, getProviderList, updateProviderById } from '@/services/model'
import { getTranscriberConfig, updateTranscriberConfig } from '@/services/transcriber' import { getTranscriberConfig, updateTranscriberConfig } from '@/services/transcriber'
import logo from '@/assets/icon.svg' 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',路由守卫不再拦。 // 桌面端首启 4 步引导。完成后写 localStorage('bilinote-onboarded') = '1',路由守卫不再拦。
// //
// 1. 后端连通性自检 // 1. 后端连通性自检
@@ -52,24 +79,52 @@ const Onboarding = () => {
} }
// step 1: ping 后端 // 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(() => { useEffect(() => {
if (step !== 1) return if (step !== 1) return
let cancelled = false let cancelled = false
let timerId: ReturnType<typeof setTimeout> | null = null
let offReady: (() => void) | null = null
let offRestarted: (() => void) | null = null
;(async () => { ;(async () => {
setPinging(true) const ok = await doPing()
try { if (cancelled) return
await getProviderList() if (!ok) {
if (!cancelled) setBackendOk(true) // 后端可能正在解压/启动2s 后再试一次
timerId = setTimeout(() => { if (!cancelled) doPing() }, 2000)
} }
catch {
if (!cancelled) setBackendOk(false) // 桌面端订阅 Tauri 事件:后端真正就绪 / 重启完成时立刻再检查一次
} if (isTauri) {
finally { try {
if (!cancelled) setPinging(false) const { listen } = await import('@tauri-apps/api/event')
offReady = await listen('backend-ready', () => { if (!cancelled) doPing() })
offRestarted = await listen('backend-restarted', () => { if (!cancelled) doPing() })
}
catch { /* 拿不到事件 API 不致命 */ }
} }
})() })()
return () => { cancelled = true }
}, [step]) return () => {
cancelled = true
if (timerId) clearTimeout(timerId)
offReady?.()
offRestarted?.()
}
}, [step, doPing])
async function saveProvider() { async function saveProvider() {
setError('') setError('')
@@ -79,31 +134,61 @@ const Onboarding = () => {
if (!modelName.trim()) { setError('请填模型名'); return } if (!modelName.trim()) { setError('请填模型名'); return }
setSavingProvider(true) setSavingProvider(true)
try { try {
// 复用桌面 web 端的 add_providertype 必须是 'custom'backend 强制) const name = providerName.trim()
const res: any = await addProvider({ let pid: string | undefined
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 // 后端 seed_default_providers() 会预置 OpenAI / DeepSeek / Qwen 等同名供应商,
await addModel({ provider_id: newId, model_name: modelName.trim() }) // 直接 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!)
try { await testConnection({ id: newId }) }
// 加一个默认 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) { catch (e: any) {
// 测试失败不阻断流程,让用户自己决定继续 console.warn('测试连接失败:', errText(e))
console.warn('测试连接失败:', e?.message ?? e)
} }
next() next()
} }
catch (e: any) { catch (e: any) {
setError(`保存失败:${e?.message ?? e}`) setError(`保存失败:${errText(e)}`)
} }
finally { finally {
setSavingProvider(false) setSavingProvider(false)
@@ -123,7 +208,7 @@ const Onboarding = () => {
next() next()
} }
catch (e: any) { catch (e: any) {
setError(`保存失败:${e?.message ?? e}`) setError(`保存失败:${errText(e)}`)
} }
finally { finally {
setSavingTranscriber(false) setSavingTranscriber(false)
@@ -171,6 +256,15 @@ const Onboarding = () => {
</div> </div>
)} )}
<div className="flex gap-2 justify-end"> <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 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> </button>

View File

@@ -1,10 +1,11 @@
import Provider from '@/components/Form/modelForm/Provider.tsx'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import Options from '@/components/Form/DownloaderForm/Options.tsx' import Options from '@/components/Form/DownloaderForm/Options.tsx'
import ProxyConfig from '@/components/Form/DownloaderForm/ProxyConfig.tsx'
const Downloader = () => { const Downloader = () => {
return ( return (
<div className={'flex h-full bg-white'}> <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> <Options></Options>
</div> </div>
<div className={'flex-4/5'}> <div className={'flex-4/5'}>

View File

@@ -174,7 +174,11 @@ export default function Monitor() {
<AudioLines className="mr-2 inline h-5 w-5 text-purple-500" /> <AudioLines className="mr-2 inline h-5 w-5 text-purple-500" />
Whisper Whisper
</CardTitle> </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> </CardHeader>
<CardContent> <CardContent>
{loading && !status ? ( {loading && !status ? (
@@ -192,6 +196,14 @@ export default function Monitor() {
<span className="text-muted-foreground">:</span> <span className="text-muted-foreground">:</span>
<span className="font-mono">{status.whisper.transcriber_type}</span> <span className="font-mono">{status.whisper.transcriber_type}</span>
</div> </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> </div>
) : null} ) : null}
</CardContent> </CardContent>

View File

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

View File

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

View File

@@ -1,9 +1,29 @@
import request from '@/utils/request' 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') return await request.get('/sys_health')
} }
/** 保留旧 systemCheck 函数名App.tsx 启动时仍调用),返回值同 getSysHealth。 */
export const systemCheck = getSysHealth
export interface DeployStatus { export interface DeployStatus {
backend: { backend: {
status: string status: string
@@ -11,12 +31,16 @@ export interface DeployStatus {
} }
cuda: { cuda: {
available: boolean available: boolean
/** 新增torch 是否安装。轻量部署没装 torch 时为 false避免误判为 CUDA 故障 */
torch_installed?: boolean
version: string | null version: string | null
gpu_name: string | null gpu_name: string | null
} }
whisper: { whisper: {
model_size: string model_size: string
transcriber_type: string transcriber_type: string
/** 新增模型是否已完整下载fast-whisper 看 model.bin / mlx 看 config.json */
downloaded: boolean
} }
ffmpeg: { ffmpeg: {
available: boolean available: boolean

View File

@@ -169,10 +169,25 @@ export const useTaskStore = create<TaskStore>()(
if (!task) return if (!task) return
const newFormData = payload || task.formData const newFormData = payload || task.formData
await generateNote({ try {
...newFormData, await generateNote({
task_id: id, ...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 => ({ set(state => ({
tasks: state.tasks.map(t => tasks: state.tasks.map(t =>

View File

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