mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-06-12 03:00:09 +08:00
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:
@@ -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",
|
||||||
|
|||||||
22
BillNote_frontend/pnpm-lock.yaml
generated
22
BillNote_frontend/pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
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"]
|
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.10(commit 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]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭期统一杀 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>,
|
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 并 kill(kill 失败也继续,可能进程已经退了)
|
// 1. 拿出旧 child 并 kill(kill 失败也继续,可能进程已经退了)
|
||||||
{
|
{
|
||||||
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 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/* 经常炸;
|
// 安装路径诊断:PyInstaller 在含非 ASCII / 空格的路径下加载 _internal/* 经常炸;
|
||||||
// 父目录不可写时模型 / 配置 / 日志也无法落盘
|
// 父目录不可写时模型 / 配置 / 日志也无法落盘
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 —— 它们比 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 (
|
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
|
||||||
|
|||||||
@@ -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
|
<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}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 失败都会弹一个红色 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 () => {
|
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 用来强制重启 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 { 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)
|
||||||
|
|||||||
@@ -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_provider;type 必须是 '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>
|
||||||
|
|||||||
@@ -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'}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
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'
|
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
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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: '请求失败,请检查网络连接',
|
||||||
|
|||||||
Reference in New Issue
Block a user