From 4015cf80efa712aa2ff82d5abf7e98f329a75bec Mon Sep 17 00:00:00 2001 From: crosswk Date: Sun, 30 Aug 2026 09:06:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=8D=83=E9=97=AE=20?= =?UTF-8?q?Audio=203.0=20=E6=B5=81=E5=BC=8F=E8=AF=86=E5=88=AB=E3=80=81WebD?= =?UTF-8?q?AV=20=E5=A4=87=E4=BB=BD=E3=80=81=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=9C=AC=E5=9C=B0=20AI=20=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三项一起提交是因为它们共用命令注册与供应商目录,拆开任一条都编不过。 千问 Audio 3.0 流式:热词交给模型自带的即时热词,可带权重、上限 100 条, 不再需要业务空间 ID。 WebDAV 备份:配置、历史、音频是同一份 zip 的三个子集,配置为默认项; 选 WebDAV 而不是各家网盘 SDK,是为了让坚果云这类自建入口直接可用。 包体可能有几个 GB,所以走流式 PUT,不整包读进内存。 服务器模式本地 AI 整理:此前 ASR 与 AI 整理绑在一起,若因成本停掉服务器 AI 会连带失去语音识别。拆开来源后服务器只做识别,整理由本机直连用户自己的供应商, API Key、提示词与上下文感知读到的文字都不再经过服务器。默认关闭。 --- client/src-tauri/Cargo.lock | 3 +- client/src-tauri/Cargo.toml | 7 +- client/src-tauri/src/commands/backup.rs | 350 ++++-- client/src-tauri/src/commands/mod.rs | 1 + client/src-tauri/src/commands/webdav.rs | 998 ++++++++++++++++++ client/src-tauri/src/main.rs | 10 + .../src/providers/asr_qwen_audio_stream.rs | 955 +++++++++++++++++ client/src-tauri/src/providers/mod.rs | 1 + client/src-tauri/src/providers/registry.rs | 14 +- client/src/App.tsx | 18 +- .../src/features/backup/autoWebdavBackup.ts | 193 ++++ .../features/settings/AIProofreadToggle.tsx | 62 +- .../features/settings/AIProviderSection.tsx | 120 ++- .../src/features/settings/BackupSection.tsx | 13 +- .../src/features/settings/CloudAPISection.tsx | 12 +- .../features/settings/GeneralSettingsPage.tsx | 3 + .../src/features/settings/WebDavSection.tsx | 639 +++++++++++ .../__tests__/asrProfileStore.test.ts | 20 +- .../__tests__/asrProviderCatalog.test.ts | 36 +- .../features/settings/asrProviderCatalog.ts | 37 +- client/src/index.css | 90 +- client/src/lib/asrModels.ts | 13 +- client/src/lib/utils.ts | 18 + client/src/pages/Dictionary.tsx | 1 + client/src/pages/History.tsx | 84 +- client/src/services/backup.ts | 12 +- client/src/services/defaults.ts | 26 +- client/src/services/store.ts | 2 + .../transcription/CloudAPIProvider.ts | 182 ++-- .../services/transcription/LocalProvider.ts | 75 +- .../services/transcription/ServerProvider.ts | 133 ++- .../services/transcription/clientAiPolish.ts | 192 ++++ client/src/services/transcription/index.ts | 13 +- .../services/transcription/serverAiSource.ts | 26 + client/src/services/transcription/types.ts | 11 + client/src/services/webdavBackup.ts | 129 +++ client/src/stores/modeStatus.ts | 1 + 37 files changed, 4127 insertions(+), 373 deletions(-) create mode 100644 client/src-tauri/src/commands/webdav.rs create mode 100644 client/src-tauri/src/providers/asr_qwen_audio_stream.rs create mode 100644 client/src/features/backup/autoWebdavBackup.ts create mode 100644 client/src/features/settings/WebDavSection.tsx create mode 100644 client/src/services/transcription/clientAiPolish.ts create mode 100644 client/src/services/transcription/serverAiSource.ts create mode 100644 client/src/services/webdavBackup.ts diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock index dab616e..c827b75 100644 --- a/client/src-tauri/Cargo.lock +++ b/client/src-tauri/Cargo.lock @@ -4032,7 +4032,7 @@ dependencies = [ [[package]] name = "sayit" -version = "0.1.8" +version = "0.1.9" dependencies = [ "base64 0.22.1", "bzip2", @@ -4065,6 +4065,7 @@ dependencies = [ "tauri-plugin-updater", "tokio", "tokio-tungstenite", + "tokio-util", "transcribe-cpp", "uuid", "webview2-com", diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index b027e14..74cb2bc 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sayit" -version = "0.1.8" +version = "0.1.9" authors = ["Liu Qianglong", "Claude"] edition = "2021" @@ -41,7 +41,10 @@ once_cell = "1" flate2 = "1" tar = "0.4" bzip2 = "0.4" -tokio = { version = "1", features = ["rt", "macros", "time"] } +tokio = { version = "1", features = ["rt", "macros", "time", "fs"] } +# WebDAV 备份包要流式 PUT 上去:含音频的包能有几个 GB,整包读进 Vec 会直接把 +# 内存打满。ReaderStream 把文件转成 reqwest 能吃的 Stream,配 reqwest 的 stream feature。 +tokio-util = { version = "0.7", features = ["io"] } silero-vad-crs = { version = "=0.4.0", features = ["auto"] } # GGUF / ggml 本地 ASR 引擎(transcribe.cpp 的官方 Rust 绑定,MIT)。 # dynamic-backends:各计算后端编成可 dlopen 的模块(隐含 shared,libtranscribe diff --git a/client/src-tauri/src/commands/backup.rs b/client/src-tauri/src/commands/backup.rs index fffd09f..a330d76 100644 --- a/client/src-tauri/src/commands/backup.rs +++ b/client/src-tauri/src/commands/backup.rs @@ -41,6 +41,18 @@ pub struct ConfigExportSelection { prompt_preset_ids: Vec, } +/// 全量备份里除配置之外还装什么。 +/// +/// 两个字段都**不给 serde 默认值**:调用方必须显式表态。给默认值的那一版是个陷阱 —— +/// 漏传参数时会静默按「全都要」打包,而含音频的包可以有几个 GB,自动备份场景下 +/// 意味着每次悄悄上传几个 GB。宁可让反序列化直接失败,报一条看得见的错。 +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupScope { + pub include_history: bool, + pub include_audio: bool, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ConfigImportSectionPreview { @@ -265,19 +277,42 @@ fn build_selected_config_value( })) } -fn build_full_value(storage: &Storage) -> Value { - json!({ +/// 组装 `backup.json`。 +/// +/// 不含历史时**整个字段都不写**(而不是写一个空数组):`import_full` 对缺失段是 +/// `if let Some(...)`,字段缺席就跳过、保留本机现有历史;写成空数组反而会把本机 +/// 历史整表清空。「只备份配置」这一档能安全恢复,全靠这个区别。 +fn build_full_value(storage: &Storage, scope: BackupScope) -> Value { + let mut payload = json!({ "kind": "full", "formatVersion": FORMAT_VERSION, "appVersion": app_version(), "exportedAt": chrono::Utc::now().to_rfc3339(), + // 备份包自报装了什么,供恢复前预览与排查「为什么恢复完没有历史」。 + "scope": { + "includeHistory": scope.include_history, + "includeAudio": scope.include_audio, + }, "appSettings": storage.export_app_settings(&[]), "promptPresets": storage.get("promptPresets", None), "appPromptRules": storage.get("appPromptRules", None), - "history": storage.get("history", None), - "manualCorrections": storage.get("manualCorrections", None), - "feedbackQueue": storage.get("feedbackQueue", None), - }) + }); + + if scope.include_history { + if let Some(object) = payload.as_object_mut() { + object.insert("history".to_string(), storage.get("history", None)); + object.insert( + "manualCorrections".to_string(), + storage.get("manualCorrections", None), + ); + object.insert( + "feedbackQueue".to_string(), + storage.get("feedbackQueue", None), + ); + } + } + + payload } fn check_kind_and_version(data: &Value, expected_kind: &str, max_version: i64) -> Result { @@ -692,8 +727,11 @@ fn emit_export_progress(app: &AppHandle, progress: BackupExportProgress) { let _ = app.emit("backup-export-progress", progress); } -fn collect_audio_export_files() -> Vec { +fn collect_audio_export_files(include_audio: bool) -> Vec { let mut files = Vec::new(); + if !include_audio { + return files; + } let adir = audio_dir(); if let Ok(entries) = fs::read_dir(adir) { for entry in entries.flatten() { @@ -754,56 +792,65 @@ pub async fn export_config( Ok(path) } -#[tauri::command] -pub async fn export_full( - app: AppHandle, - storage: State<'_, Storage>, -) -> Result { - let output = timestamped_backup_path("sayit-backup", "zip"); - let out_path = output.to_string_lossy().to_string(); - let temp_path = PathBuf::from(format!("{}.part", out_path)); - let audio_files = collect_audio_export_files(); +/// 打包过程中的一帧进度。`phase` 取值与前端 `BackupExportProgress['phase']` 对齐。 +pub struct ArchiveProgress<'a> { + pub phase: &'static str, + pub current_file: Option<&'a str>, + pub processed_files: u64, + pub total_files: u64, + pub processed_bytes: u64, + pub total_bytes: u64, + pub percent: f64, +} + +/// 把配置(+ 按 scope 决定的历史 / 音频)打成 zip 写到 `output`。 +/// +/// 本地「全部数据」导出与 WebDAV 备份共用这一份实现,两者的差别只在落地位置和 +/// 进度事件名。刻意不让 WebDAV 自己写一套打包:一旦两份代码分家,就会出现 +/// 「本地导出能恢复、云端备份恢复不了」这种只在换机时才暴露的问题。 +/// +/// 先写 `.part` 再原子 rename,失败时清掉临时文件 —— 中断留下的半个 zip 不能 +/// 长得像一个有效备份。 +/// +/// 总量(文件数 / 字节数)通过 `on_progress` 报出去,不作为返回值:调用方要的是 +/// 打包完成后**zip 本身**的大小(用来算上传进度),那个得 stat 产物文件才知道。 +pub fn write_backup_archive( + storage: &Storage, + scope: BackupScope, + output: &Path, + on_progress: &mut dyn FnMut(ArchiveProgress), +) -> Result<(), String> { + let temp_path = PathBuf::from(format!("{}.part", output.to_string_lossy())); + let audio_files = collect_audio_export_files(scope.include_audio); let total_files = audio_files.len() as u64; let total_bytes = audio_files.iter().map(|file| file.size).sum::(); - emit_export_progress( - &app, - BackupExportProgress { - status: "running".to_string(), - phase: "preparing".to_string(), - file_path: out_path.clone(), - current_file: None, - processed_files: 0, - total_files, - processed_bytes: 0, - total_bytes, - percent: 2.0, - error: None, - }, - ); + on_progress(ArchiveProgress { + phase: "preparing", + current_file: None, + processed_files: 0, + total_files, + processed_bytes: 0, + total_bytes, + percent: 2.0, + }); - let export_result = (|| -> Result<(), String> { + let result = (|| -> Result<(), String> { if let Some(parent) = output.parent() { fs::create_dir_all(parent).map_err(|e| format!("Failed to create export directory: {}", e))?; } - let payload = build_full_value(storage.inner()); + let payload = build_full_value(storage, scope); let json_str = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?; - emit_export_progress( - &app, - BackupExportProgress { - status: "running".to_string(), - phase: "packingData".to_string(), - file_path: out_path.clone(), - current_file: Some("backup.json".to_string()), - processed_files: 0, - total_files, - processed_bytes: 0, - total_bytes, - percent: 5.0, - error: None, - }, - ); + on_progress(ArchiveProgress { + phase: "packingData", + current_file: Some("backup.json"), + processed_files: 0, + total_files, + processed_bytes: 0, + total_bytes, + percent: 5.0, + }); let file = fs::File::create(&temp_path).map_err(|e| format!("Failed to create file: {}", e))?; let mut zip = zip::ZipWriter::new(file); @@ -839,67 +886,94 @@ pub async fn export_full( processed_bytes += read as u64; if last_emit.elapsed() >= Duration::from_millis(150) { - emit_export_progress( - &app, - BackupExportProgress { - status: "running".to_string(), - phase: "packingAudio".to_string(), - file_path: out_path.clone(), - current_file: Some(audio.name.clone()), - processed_files, - total_files, - processed_bytes, - total_bytes, - percent: audio_progress_percent(processed_bytes, total_bytes), - error: None, - }, - ); + on_progress(ArchiveProgress { + phase: "packingAudio", + current_file: Some(&audio.name), + processed_files, + total_files, + processed_bytes, + total_bytes, + percent: audio_progress_percent(processed_bytes, total_bytes), + }); last_emit = Instant::now(); } } processed_files += 1; - emit_export_progress( - &app, - BackupExportProgress { - status: "running".to_string(), - phase: "packingAudio".to_string(), - file_path: out_path.clone(), - current_file: Some(audio.name.clone()), - processed_files, - total_files, - processed_bytes, - total_bytes, - percent: audio_progress_percent(processed_bytes, total_bytes), - error: None, - }, - ); - } - - emit_export_progress( - &app, - BackupExportProgress { - status: "running".to_string(), - phase: "finalizing".to_string(), - file_path: out_path.clone(), - current_file: None, + on_progress(ArchiveProgress { + phase: "packingAudio", + current_file: Some(&audio.name), processed_files, total_files, processed_bytes, total_bytes, - percent: 98.0, - error: None, - }, - ); + percent: audio_progress_percent(processed_bytes, total_bytes), + }); + } + + on_progress(ArchiveProgress { + phase: "finalizing", + current_file: None, + processed_files, + total_files, + processed_bytes, + total_bytes, + percent: 98.0, + }); zip.finish().map_err(|e| e.to_string())?; if output.exists() { - fs::remove_file(&output).map_err(|e| format!("Failed to replace the previous backup: {}", e))?; + fs::remove_file(output).map_err(|e| format!("Failed to replace the previous backup: {}", e))?; } - fs::rename(&temp_path, &output).map_err(|e| format!("Failed to finalize the backup file: {}", e))?; + fs::rename(&temp_path, output).map_err(|e| format!("Failed to finalize the backup file: {}", e))?; Ok(()) })(); + match result { + Ok(()) => Ok(()), + Err(error) => { + let _ = fs::remove_file(&temp_path); + Err(error) + } + } +} + +#[tauri::command] +pub async fn export_full( + app: AppHandle, + scope: BackupScope, + storage: State<'_, Storage>, +) -> Result { + let output = timestamped_backup_path("sayit-backup", "zip"); + let out_path = output.to_string_lossy().to_string(); + + // 失败分支也要报出总量,所以在回调里留一份最近值:write_backup_archive 出错时 + // 只给 Err(String),拿不到它内部算出的 totals。 + let mut seen_totals = (0u64, 0u64); + let export_result = { + let progress_app = app.clone(); + let progress_path = out_path.clone(); + write_backup_archive(storage.inner(), scope, &output, &mut |progress| { + seen_totals = (progress.total_files, progress.total_bytes); + emit_export_progress( + &progress_app, + BackupExportProgress { + status: "running".to_string(), + phase: progress.phase.to_string(), + file_path: progress_path.clone(), + current_file: progress.current_file.map(ToString::to_string), + processed_files: progress.processed_files, + total_files: progress.total_files, + processed_bytes: progress.processed_bytes, + total_bytes: progress.total_bytes, + percent: progress.percent, + error: None, + }, + ); + }) + }; + let (total_files, total_bytes) = seen_totals; + match export_result { Ok(()) => { emit_export_progress( @@ -917,11 +991,16 @@ pub async fn export_full( error: None, }, ); - log::info!("Full backup exported to {}", out_path); + log::info!( + "Full backup exported to {} (history={} audio={})", + out_path, + scope.include_history, + scope.include_audio + ); Ok(out_path) } Err(error) => { - let _ = fs::remove_file(&temp_path); + // 临时文件已由 write_backup_archive 清理。 emit_export_progress( &app, BackupExportProgress { @@ -1190,7 +1269,15 @@ pub async fn import_config( #[tauri::command] pub async fn import_full(in_path: String, storage: State<'_, Storage>) -> Result<(), String> { - let file = fs::File::open(&in_path).map_err(|e| format!("Failed to open file: {}", e))?; + apply_full_backup(storage.inner(), &in_path) +} + +/// 应用一个全量备份 zip。本地导入与 WebDAV 恢复共用。 +/// +/// 对缺失段是宽容的:只含配置的备份(WebDAV 默认档)不会带 `history` 字段, +/// 此时**保留本机现有历史**而不是清空 —— 见 `build_full_value` 里为什么不写空数组。 +pub fn apply_full_backup(storage: &Storage, in_path: &str) -> Result<(), String> { + let file = fs::File::open(in_path).map_err(|e| format!("Failed to open file: {}", e))?; let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Not a valid backup archive: {}", e))?; // 1. 读取并校验 backup.json @@ -1225,7 +1312,7 @@ pub async fn import_full(in_path: String, storage: State<'_, Storage>) -> Result } // 3. 应用配置部分(含 stats) - apply_config_part(storage.inner(), &data, &[])?; + apply_config_part(storage, &data, &[])?; // 4. 历史:重写音频路径后整表替换 if let Some(history) = data.get("history").and_then(|v| v.as_array()) { @@ -1260,6 +1347,75 @@ pub fn restart_app(app: tauri::AppHandle) { mod tests { use super::*; + fn temp_storage(tag: &str) -> (Storage, PathBuf) { + let dir = std::env::temp_dir().join(format!("sayit-backup-test-{}-{}", tag, uuid::Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + let storage = Storage::new(dir.join("test.db")).unwrap(); + (storage, dir) + } + + /// 不含历史时,`history` 字段必须**整个缺席**,不能是空数组。 + /// + /// 这是「只备份配置」这一档能安全恢复的全部依据:`apply_full_backup` 用 + /// `if let Some(history)` 判断,字段缺席就保留本机历史,写成空数组则会把本机 + /// 历史整表清空 —— 一个「只想同步设置」的用户会因此丢掉全部记录,而且备份和 + /// 恢复都不会报任何错。 + #[test] + fn config_only_backup_omits_history_instead_of_emptying_it() { + let (storage, dir) = temp_storage("scope"); + storage + .set( + "history", + &json!([{ "id": "h1", "timestamp": 1, "charCount": 3 }]), + ) + .unwrap(); + + let config_only = build_full_value( + &storage, + BackupScope { + include_history: false, + include_audio: false, + }, + ); + assert!( + config_only.get("history").is_none(), + "history must be absent, got {:?}", + config_only.get("history") + ); + assert!(config_only.get("manualCorrections").is_none()); + assert!(config_only.get("feedbackQueue").is_none()); + // 配置部分照旧在,且 kind 仍是 full —— 用现有的导入路径就能恢复。 + assert_eq!(config_only.get("kind").and_then(Value::as_str), Some("full")); + assert!(config_only.get("appSettings").is_some()); + assert_eq!( + config_only.pointer("/scope/includeAudio").and_then(Value::as_bool), + Some(false) + ); + + let with_history = build_full_value( + &storage, + BackupScope { + include_history: true, + include_audio: false, + }, + ); + assert_eq!( + with_history + .get("history") + .and_then(Value::as_array) + .map(Vec::len), + Some(1) + ); + + let _ = fs::remove_dir_all(&dir); + } + + /// 不含音频时不去扫 audio 目录,也就不会把别的备份的录音塞进来。 + #[test] + fn audio_files_are_only_collected_when_requested() { + assert!(collect_audio_export_files(false).is_empty()); + } + #[test] fn prompt_overrides_allow_one_entry_per_builtin_language() { let items = vec![ diff --git a/client/src-tauri/src/commands/mod.rs b/client/src-tauri/src/commands/mod.rs index e826169..818d30e 100644 --- a/client/src-tauri/src/commands/mod.rs +++ b/client/src-tauri/src/commands/mod.rs @@ -8,4 +8,5 @@ pub mod shortcuts; pub mod storage; pub mod system; pub mod tray; +pub mod webdav; pub mod window; diff --git a/client/src-tauri/src/commands/webdav.rs b/client/src-tauri/src/commands/webdav.rs new file mode 100644 index 0000000..1acf60a --- /dev/null +++ b/client/src-tauri/src/commands/webdav.rs @@ -0,0 +1,998 @@ +// WebDAV 备份:把备份包上传到用户自己的 WebDAV 服务器,并支持从上面恢复。 +// +// 设计要点: +// - 打包**不在这里实现**,复用 `backup::write_backup_archive`。一旦两边各写一套, +// 就会出现「本地导出能恢复、云端备份恢复不了」这种只在换机时才暴露的问题。 +// - 备份档位:配置必备,历史与音频可选,默认都不含(见 `scope_from_settings`)。 +// 只含配置的包也是合法的全量 zip,用现有的 `apply_full_backup` 就能恢复。 +// - 只允许 https。WebDAV 用 HTTP Basic 认证,凭证随每个请求明文发送。 +// - 先 PUT 到 `<名字>.part` 再 MOVE 成正式名:网络断在中途留下的半个 zip +// 不能长得像一个有效备份(和本地导出的 .part + rename 是同一个理由)。 +// - 上传完必须 PROPFIND 回读一次并比对字节数。「服务器收下了但存坏了/没存」 +// 和「上传成功」在客户端看来一模一样,不回读就分不出来。 +// - 每次备份的结果(成功或失败)都写进 `webdav.lastResult` 并落日志。 +// 一个你以为有、其实几个月前就在静默失败的备份,比没有备份更糟。 + +use futures_util::TryStreamExt; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::io::Write; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter, State}; +use tokio_util::io::ReaderStream; + +use crate::commands::backup::{apply_full_backup, write_backup_archive, BackupScope}; +use crate::providers::{diag, http_client}; +use crate::storage::Storage; + +const KEY_URL: &str = "webdav.url"; +const KEY_USERNAME: &str = "webdav.username"; +const KEY_PASSWORD: &str = "webdav.password"; +const KEY_INCLUDE_HISTORY: &str = "webdav.includeHistory"; +const KEY_INCLUDE_AUDIO: &str = "webdav.includeAudio"; +const KEY_KEEP_COUNT: &str = "webdav.keepCount"; +const KEY_LAST_RESULT: &str = "webdav.lastResult"; + +/// 备份文件名前缀,与本地导出一致,便于用户对着看。 +const BACKUP_PREFIX: &str = "sayit-backup-"; +const DEFAULT_KEEP_COUNT: u64 = 5; +const PROGRESS_EVENT: &str = "webdav-backup-progress"; + +/// 元数据类请求(PROPFIND / MKCOL / MOVE / DELETE)的超时。 +const META_TIMEOUT: Duration = Duration::from_secs(30); + +// ─── 配置 ─── + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebDavConfig { + pub url: String, + pub username: String, + pub password: String, +} + +/// 已校验的连接信息。`dir_url` 一定是 https 且不带末尾斜杠。 +struct Target { + dir_url: String, + username: String, + password: String, +} + +/// 手写 Debug 而不是 derive:`#[derive(Debug)]` 会让任何一次 `{:?}`(包括 +/// `unwrap_err()` 的 panic 消息、以后随手加的一条日志)把网盘密码打进 sayit.log, +/// 而日志是会跟着诊断包发出来的。 +impl std::fmt::Debug for Target { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Target") + .field("dir_url", &self.dir_url) + .field("username", &self.username) + .field("password", &"") + .finish() + } +} + +/// 校验并规范化目录 URL。 +/// +/// 只接受 https:Basic 认证等于把网盘密码随每个请求明文发出去,走 http 就是把它 +/// 广播到链路上的每一跳。坚果云等服务本身也只提供 https,所以这里不留开关。 +/// +/// 返回的错误是**错误码**而不是文案:这几种都是可预期的用户输入问题,交给前端本地化 +/// 才能给出针对性的提示(比如坚果云要用「应用密码」而不是登录密码)。 +fn normalize_dir_url(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("WEBDAV_URL_EMPTY".to_string()); + } + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("http://") { + return Err("WEBDAV_URL_INSECURE".to_string()); + } + if !lower.starts_with("https://") { + return Err("WEBDAV_URL_SCHEME".to_string()); + } + // 末尾斜杠去掉,拼子路径时统一再加,避免出现 `//`(部分服务器会 404)。 + let normalized = trimmed.trim_end_matches('/'); + if normalized.len() <= "https://".len() { + return Err("WEBDAV_URL_EMPTY".to_string()); + } + Ok(normalized.to_string()) +} + +impl Target { + fn from_config(config: &WebDavConfig) -> Result { + let dir_url = normalize_dir_url(&config.url)?; + if config.username.trim().is_empty() || config.password.is_empty() { + return Err("WEBDAV_CREDENTIALS_EMPTY".to_string()); + } + Ok(Self { + dir_url, + username: config.username.trim().to_string(), + password: config.password.clone(), + }) + } + + fn from_settings(storage: &Storage) -> Result { + Self::from_config(&WebDavConfig { + url: setting_string(storage, KEY_URL), + username: setting_string(storage, KEY_USERNAME), + password: setting_string(storage, KEY_PASSWORD), + }) + } + + fn child(&self, name: &str) -> String { + format!("{}/{}", self.dir_url, name) + } +} + +fn setting_string(storage: &Storage, key: &str) -> String { + storage + .get(key, None) + .as_str() + .unwrap_or_default() + .to_string() +} + +fn setting_bool(storage: &Storage, key: &str) -> bool { + storage.get(key, None).as_bool().unwrap_or(false) +} + +/// 备份档位一律从设置读,不接受调用方传参。 +/// +/// 手动「立即备份」和后台定时备份必须打出一样的包 —— 两个入口各带一份 scope 的话, +/// 「界面上勾了音频但自动备份里没有」这类问题会很难查。 +/// +/// 两个默认值都是 false:读不到设置时宁可打一个只含配置的小包,也不要悄悄上传 +/// 几个 GB 音频。 +fn scope_from_settings(storage: &Storage) -> BackupScope { + BackupScope { + include_history: setting_bool(storage, KEY_INCLUDE_HISTORY), + include_audio: setting_bool(storage, KEY_INCLUDE_AUDIO), + } +} + +// ─── HTTP ─── + +fn dav_method(verb: &'static str) -> reqwest::Method { + reqwest::Method::from_bytes(verb.as_bytes()).expect("WebDAV verb is static ASCII") +} + +/// 构造一个带 UA 与 Basic 认证的请求。 +/// +/// 必须走 `http_client::shared()`:reqwest 默认不发 User-Agent,而 nginx / WAF 类 +/// 网关普遍按「无 UA」拦(见 pitfalls 第 1 条)。被拦掉的响应里一个字都不提 UA, +/// 只会表现成「密码错误」或「地址不对」,几乎不可能猜到。 +fn request(target: &Target, verb: &'static str, url: &str) -> reqwest::RequestBuilder { + http_client::shared() + .request(dav_method(verb), url) + .basic_auth(&target.username, Some(&target.password)) +} + +/// 把 HTTP 层面的失败翻成一条能定位问题的错误。 +/// +/// 401/403 单独给码:坚果云上这两个几乎都是「用了登录密码而不是应用密码」, +/// 前端要能给出针对性提示。其余带上状态码和一小段响应体 —— 用 `diag::truncate` +/// 按字符截断,直接切字节会在中文错误体上 panic(见 pitfalls 第 14 条)。 +async fn check_status(response: reqwest::Response, stage: &str) -> Result { + let status = response.status(); + if status.is_success() { + return Ok(response); + } + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + log::warn!("[webdav] {} rejected: http={}", stage, status.as_u16()); + return Err("WEBDAV_UNAUTHORIZED".to_string()); + } + let body = response.text().await.unwrap_or_default(); + let detail = diag::truncate(&body, 200); + log::warn!( + "[webdav] {} failed: http={} body={}", + stage, + status.as_u16(), + detail + ); + Err(format!("{} failed (HTTP {}) {}", stage, status.as_u16(), detail)) +} + +async fn send(target: &Target, verb: &'static str, url: &str, stage: &str) -> Result { + let response = request(target, verb, url) + .timeout(META_TIMEOUT) + .send() + .await + .map_err(|e| format!("{} failed: {}", stage, diag::truncate(&e.to_string(), 200)))?; + check_status(response, stage).await +} + +/// PROPFIND 一个目录,返回原始 XML。 +/// +/// `Depth: 1` 是硬要求,不是偷懒:坚果云只支持 depth=1,发 `infinity` 会被拒。 +/// 我们也只需要列一层目录。 +async fn propfind_dir(target: &Target, url: &str) -> Result { + let response = request(target, "PROPFIND", url) + .header("Depth", "1") + .header("Content-Type", "application/xml; charset=utf-8") + .timeout(META_TIMEOUT) + .body( + r#" +"#, + ) + .send() + .await + .map_err(|e| format!("PROPFIND failed: {}", diag::truncate(&e.to_string(), 200)))?; + let response = check_status(response, "PROPFIND").await?; + response + .text() + .await + .map_err(|e| format!("Failed to read the directory listing: {}", e)) +} + +enum MkcolOutcome { + Done, + ParentMissing, +} + +async fn mkcol(target: &Target, url: &str) -> Result { + let response = request(target, "MKCOL", url) + .timeout(META_TIMEOUT) + .send() + .await + .map_err(|e| format!("MKCOL failed: {}", diag::truncate(&e.to_string(), 200)))?; + let status = response.status(); + // 405 = 该路径上已经有东西了。是不是目录留给后续 PROPFIND 去发现。 + if status.is_success() || status == reqwest::StatusCode::METHOD_NOT_ALLOWED { + return Ok(MkcolOutcome::Done); + } + if status == reqwest::StatusCode::CONFLICT { + return Ok(MkcolOutcome::ParentMissing); + } + check_status(response, "MKCOL").await.map(|_| MkcolOutcome::Done) +} + +/// 把路径的各级祖先收集出来(从深到浅),到 `https://host` 就停。 +/// +/// 停止条件是「斜杠数 <= 2」:`https://` 自带两个,再少就意味着已经把主机名切掉了。 +fn ancestor_urls(url: &str) -> Vec { + let mut out = Vec::new(); + let mut cursor = url.to_string(); + // 8 级足够覆盖任何合理的网盘路径,同时给一个明确的上界。 + for _ in 0..8 { + let Some((parent, _)) = cursor.rsplit_once('/') else { + break; + }; + if parent.matches('/').count() <= 2 { + break; + } + out.push(parent.to_string()); + cursor = parent.to_string(); + } + out +} + +/// 确保目录存在。已存在(405)与创建成功(201)都算成功。 +/// +/// 父目录缺失(409)时把祖先从浅到深补建一遍,这样用户填一个多级路径也能一次配好。 +/// 写成循环而不是 async 递归:递归要装箱,而层级本来就有上界。 +async fn ensure_collection(target: &Target, url: &str) -> Result<(), String> { + if let MkcolOutcome::Done = mkcol(target, url).await? { + return Ok(()); + } + // 补建祖先是**尽力而为**,失败不算失败:坚果云不允许在 `/dav` 根上 MKCOL, + // 会回一个错误码,但那不代表目标目录建不出来。只有目标自己的结果算数。 + for ancestor in ancestor_urls(url).iter().rev() { + if let Err(error) = mkcol(target, ancestor).await { + log::info!( + "[webdav] could not create parent {}: {}", + ancestor, + diag::truncate(&error, 120) + ); + } + } + match mkcol(target, url).await? { + MkcolOutcome::Done => Ok(()), + MkcolOutcome::ParentMissing => Err("WEBDAV_MKCOL_CONFLICT".to_string()), + } +} + +// ─── PROPFIND 响应解析 ─── + +/// `xml[at]` 处是否为本地名等于 `local` 的标签起始;返回名字结束后的下标。 +/// +/// 忽略命名空间前缀:同一份响应里各家服务器分别用 `d:`、`D:`、`lp1:` 或干脆不带前缀, +/// 按前缀匹配必然漏。 +fn tag_name_end(xml: &str, at: usize, local: &str, closing: bool) -> Option { + let rest = xml.get(at..)?; + let mut cur = rest.strip_prefix('<')?; + let mut consumed = 1; + if closing { + cur = cur.strip_prefix('/')?; + consumed += 1; + } + let name_len = cur + .find(|c: char| c == '>' || c == '/' || c.is_whitespace()) + .unwrap_or(cur.len()); + let name = &cur[..name_len]; + let local_name = name.rsplit(':').next().unwrap_or(name); + if local_name.eq_ignore_ascii_case(local) { + Some(at + consumed + name_len) + } else { + None + } +} + +/// 取出所有本地名为 `local` 的元素的内容(含嵌套的原始 XML)。 +/// +/// 不引 XML 解析库:PROPFIND 的响应结构固定,只需要 href 和 getcontentlength 两个 +/// 叶子值,一个几十行的扫描器比配一个解析器更好读,也更容易钉住各家的前缀差异。 +fn extract_elements(xml: &str, local: &str) -> Vec { + let mut out = Vec::new(); + let mut cursor = 0usize; + while let Some(rel) = xml[cursor..].find('<') { + let open = cursor + rel; + let Some(name_end) = tag_name_end(xml, open, local, false) else { + cursor = open + 1; + continue; + }; + let Some(gt_rel) = xml[name_end..].find('>') else { + break; + }; + let tag_end = name_end + gt_rel; + if xml[name_end..tag_end].trim_end().ends_with('/') { + cursor = tag_end + 1; // 自闭合,没有文本 + continue; + } + let content_start = tag_end + 1; + let mut scan = content_start; + let mut close = None; + while let Some(r) = xml[scan..].find('<') { + let candidate = scan + r; + if tag_name_end(xml, candidate, local, true).is_some() { + close = Some(candidate); + break; + } + scan = candidate + 1; + } + let Some(close) = close else { break }; + out.push(xml[content_start..close].trim().to_string()); + cursor = close + 1; + } + out +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebDavEntry { + pub name: String, + pub size: u64, +} + +/// 从 PROPFIND 响应里挑出我们自己的备份文件。 +/// +/// 名字里带的时间戳是零填充的,所以按名字倒序就是按时间从新到旧,不必解析日期。 +fn parse_backup_entries(xml: &str) -> Vec { + let mut entries: Vec = extract_elements(xml, "response") + .iter() + .filter_map(|block| { + let href = extract_elements(block, "href").into_iter().next()?; + let name = href + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or_default() + .to_string(); + if !name.starts_with(BACKUP_PREFIX) || !name.ends_with(".zip") { + return None; + } + let size = extract_elements(block, "getcontentlength") + .into_iter() + .next() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + Some(WebDavEntry { name, size }) + }) + .collect(); + entries.sort_by(|a, b| b.name.cmp(&a.name)); + entries.dedup_by(|a, b| a.name == b.name); + entries +} + +// ─── 进度 ─── + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct WebDavProgress { + status: String, + /// 'preparing' | 'packingData' | 'packingAudio' | 'finalizing' | 'uploading' | 'verifying' | 'completed' | 'failed' + phase: String, + file_name: String, + current_file: Option, + processed_bytes: u64, + total_bytes: u64, + percent: f64, + error: Option, +} + +fn emit_progress(app: &AppHandle, progress: WebDavProgress) { + let _ = app.emit(PROGRESS_EVENT, progress); +} + +/// 打包占前一半进度,上传占后一半。打包阶段的百分比是 2~98,压到 0~50。 +fn packing_percent(archive_percent: f64) -> f64 { + (archive_percent * 0.5).clamp(0.0, 50.0) +} + +fn upload_percent(sent: u64, total: u64) -> f64 { + if total == 0 { + return 90.0; + } + (50.0 + sent as f64 / total as f64 * 40.0).min(90.0) +} + +// ─── 命令 ─── + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebDavBackupResult { + pub file_name: String, + pub bytes: u64, + pub include_history: bool, + pub include_audio: bool, + pub finished_at: i64, + pub pruned: usize, +} + +/// 连接测试:PROPFIND 目标目录,目录不存在就先建。 +/// +/// 走的是和真实备份完全相同的动词与认证方式,避免「测试通过但备份失败」。 +#[tauri::command] +pub async fn webdav_test(config: WebDavConfig) -> Result { + let target = Target::from_config(&config)?; + ensure_collection(&target, &target.dir_url).await?; + let xml = propfind_dir(&target, &target.dir_url).await?; + let count = parse_backup_entries(&xml).len() as u64; + log::info!( + "[webdav] connection test ok, existing backups={}", + count + ); + Ok(count) +} + +/// 列出服务器上已有的备份,供恢复时选择。 +#[tauri::command] +pub async fn webdav_list(storage: State<'_, Storage>) -> Result, String> { + let target = Target::from_settings(storage.inner())?; + let xml = propfind_dir(&target, &target.dir_url).await?; + Ok(parse_backup_entries(&xml)) +} + +/// 立即备份一次。手动按钮与后台定时任务共用这一个入口。 +#[tauri::command] +pub async fn webdav_backup_now( + app: AppHandle, + storage: State<'_, Storage>, +) -> Result { + let scope = scope_from_settings(storage.inner()); + let file_name = format!( + "{}{}.zip", + BACKUP_PREFIX, + chrono::Local::now().format("%Y-%m-%d_%H-%M-%S-%3f") + ); + let temp_path = std::env::temp_dir().join(&file_name); + + let outcome = run_backup(&app, storage.inner(), scope, &file_name, &temp_path).await; + + // 打包用的临时文件不留在磁盘上:含音频时它和音频库一样大。 + let _ = std::fs::remove_file(&temp_path); + + // 成功与失败都记账。少了失败那一半,一个静默失败几个月的备份看起来 + // 和一个正常工作的备份完全一样。 + let finished_at = chrono::Utc::now().timestamp_millis(); + match &outcome { + Ok(result) => { + let _ = storage.set( + KEY_LAST_RESULT, + &json!({ + "at": finished_at, + "ok": true, + "fileName": result.file_name, + "bytes": result.bytes, + "includeHistory": result.include_history, + "includeAudio": result.include_audio, + "error": Value::Null, + }), + ); + log::info!( + "[webdav] backup ok name={} bytes={} history={} audio={} pruned={}", + result.file_name, + result.bytes, + result.include_history, + result.include_audio, + result.pruned + ); + emit_progress( + &app, + WebDavProgress { + status: "completed".to_string(), + phase: "completed".to_string(), + file_name: result.file_name.clone(), + current_file: None, + processed_bytes: result.bytes, + total_bytes: result.bytes, + percent: 100.0, + error: None, + }, + ); + } + Err(error) => { + let _ = storage.set( + KEY_LAST_RESULT, + &json!({ + "at": finished_at, + "ok": false, + "fileName": file_name, + "bytes": 0, + "includeHistory": scope.include_history, + "includeAudio": scope.include_audio, + "error": diag::truncate(error, 300), + }), + ); + log::warn!("[webdav] backup failed name={} error={}", file_name, diag::truncate(error, 300)); + emit_progress( + &app, + WebDavProgress { + status: "failed".to_string(), + phase: "failed".to_string(), + file_name: file_name.clone(), + current_file: None, + processed_bytes: 0, + total_bytes: 0, + percent: 0.0, + error: Some(error.clone()), + }, + ); + } + } + + outcome +} + +async fn run_backup( + app: &AppHandle, + storage: &Storage, + scope: BackupScope, + file_name: &str, + temp_path: &PathBuf, +) -> Result { + // 先校验连接信息再打包:地址填错时没必要先花几分钟压几个 GB。 + let target = Target::from_settings(storage)?; + + { + let progress_app = app.clone(); + let progress_name = file_name.to_string(); + write_backup_archive(storage, scope, temp_path, &mut |progress| { + emit_progress( + &progress_app, + WebDavProgress { + status: "running".to_string(), + phase: progress.phase.to_string(), + file_name: progress_name.clone(), + current_file: progress.current_file.map(ToString::to_string), + processed_bytes: progress.processed_bytes, + total_bytes: progress.total_bytes, + percent: packing_percent(progress.percent), + error: None, + }, + ); + })?; + } + + let local_size = std::fs::metadata(temp_path) + .map_err(|e| format!("Failed to read the packaged backup: {}", e))? + .len(); + + ensure_collection(&target, &target.dir_url).await?; + + // 先传成 .part,成功后再 MOVE 成正式名。断线留下的半个包不会被当成有效备份。 + let temp_remote = target.child(&format!("{}.part", file_name)); + let final_remote = target.child(file_name); + upload_file(app, &target, &temp_remote, temp_path, local_size, file_name).await?; + move_file(&target, &temp_remote, &final_remote).await?; + + emit_progress( + app, + WebDavProgress { + status: "running".to_string(), + phase: "verifying".to_string(), + file_name: file_name.to_string(), + current_file: None, + processed_bytes: local_size, + total_bytes: local_size, + percent: 95.0, + error: None, + }, + ); + + // 回读确认。服务器「收下了但没存」「存成了 0 字节」和「上传成功」在客户端 + // 看来完全一样,不比对字节数就分不出来。 + let xml = propfind_dir(&target, &target.dir_url).await?; + let entries = parse_backup_entries(&xml); + let uploaded = entries + .iter() + .find(|entry| entry.name == file_name) + .ok_or_else(|| "WEBDAV_VERIFY_MISSING".to_string())?; + // 有的服务器不报 getcontentlength(返回 0),那就只能确认文件在。 + if uploaded.size != 0 && uploaded.size != local_size { + log::warn!( + "[webdav] size mismatch name={} local={} remote={}", + file_name, + local_size, + uploaded.size + ); + return Err("WEBDAV_VERIFY_SIZE".to_string()); + } + + let pruned = prune_old_backups(&target, storage, &entries, file_name).await; + + Ok(WebDavBackupResult { + file_name: file_name.to_string(), + bytes: local_size, + include_history: scope.include_history, + include_audio: scope.include_audio, + finished_at: chrono::Utc::now().timestamp_millis(), + pruned, + }) +} + +async fn upload_file( + app: &AppHandle, + target: &Target, + url: &str, + path: &PathBuf, + total: u64, + file_name: &str, +) -> Result<(), String> { + let file = tokio::fs::File::open(path) + .await + .map_err(|e| format!("Failed to open the packaged backup: {}", e))?; + + let progress_app = app.clone(); + let progress_name = file_name.to_string(); + let mut sent = 0u64; + // 进度按时间节流:256KB 一块,几个 GB 的包会产生上万次事件,全发出去 + // 光是序列化就够拖慢上传。 + let mut last_emit = Instant::now() - Duration::from_secs(1); + let stream = ReaderStream::with_capacity(file, 256 * 1024).map_ok(move |chunk| { + sent += chunk.len() as u64; + if last_emit.elapsed() >= Duration::from_millis(200) { + emit_progress( + &progress_app, + WebDavProgress { + status: "running".to_string(), + phase: "uploading".to_string(), + file_name: progress_name.clone(), + current_file: None, + processed_bytes: sent, + total_bytes: total, + percent: upload_percent(sent, total), + error: None, + }, + ); + last_emit = Instant::now(); + } + chunk + }); + + let response = request(target, "PUT", url) + .header("Content-Type", "application/zip") + .header(reqwest::header::CONTENT_LENGTH, total) + // 上传不能用固定超时:几个 GB 的包在慢上行链路上要几小时。按体积给一个 + // 下限约 50KB/s 的预算,既不会把正常上传掐断,也不会永远挂着。 + .timeout(Duration::from_secs(120 + total / 50_000)) + .body(reqwest::Body::wrap_stream(stream)) + .send() + .await + .map_err(|e| format!("Upload failed: {}", diag::truncate(&e.to_string(), 200)))?; + check_status(response, "PUT").await.map(|_| ()) +} + +async fn move_file(target: &Target, from: &str, to: &str) -> Result<(), String> { + let response = request(target, "MOVE", from) + .header("Destination", to) + .header("Overwrite", "T") + .timeout(META_TIMEOUT) + .send() + .await + .map_err(|e| format!("MOVE failed: {}", diag::truncate(&e.to_string(), 200)))?; + check_status(response, "MOVE").await.map(|_| ()) +} + +/// 只保留最近 N 份。删不掉不算备份失败 —— 包已经安全落地了,清理失败最多是占空间。 +async fn prune_old_backups( + target: &Target, + storage: &Storage, + entries: &[WebDavEntry], + just_uploaded: &str, +) -> usize { + let keep = storage + .get(KEY_KEEP_COUNT, None) + .as_u64() + .filter(|value| *value >= 1) + .unwrap_or(DEFAULT_KEEP_COUNT) as usize; + + // entries 是按名字倒序(= 时间从新到旧)的,但刚上传的这一份可能还没出现在 + // 列表里(回读发生在 MOVE 之后,正常都在),所以显式合并一次再排序。 + let mut names: Vec = entries.iter().map(|entry| entry.name.clone()).collect(); + if !names.iter().any(|name| name == just_uploaded) { + names.push(just_uploaded.to_string()); + } + names.sort_by(|a, b| b.cmp(a)); + names.dedup(); + + let mut pruned = 0usize; + for name in names.into_iter().skip(keep) { + match send(target, "DELETE", &target.child(&name), "DELETE").await { + Ok(_) => { + pruned += 1; + log::info!("[webdav] pruned old backup {}", name); + } + Err(error) => { + log::warn!("[webdav] failed to prune {}: {}", name, diag::truncate(&error, 200)); + } + } + } + pruned +} + +/// 从服务器恢复:下载到临时文件后走本地导入的同一条路径。 +/// 完成后由前端调用 `restart_app`,与本地导入的行为一致。 +#[tauri::command] +pub async fn webdav_restore(name: String, storage: State<'_, Storage>) -> Result<(), String> { + // 只接受我们自己的备份名,且不能带路径分隔符 —— 否则一个 `../` 就能让 + // 下载地址跑到目录之外去。 + if !name.starts_with(BACKUP_PREFIX) + || !name.ends_with(".zip") + || name.contains('/') + || name.contains('\\') + { + return Err("WEBDAV_BAD_NAME".to_string()); + } + let target = Target::from_settings(storage.inner())?; + + // 先列一次目录拿到体积:下载超时必须按体积给,含音频的备份能有几个 GB, + // 套用元数据请求那 30 秒会把正常下载掐断,而症状是「恢复总是失败」。 + let listing = propfind_dir(&target, &target.dir_url).await?; + let expected = parse_backup_entries(&listing) + .into_iter() + .find(|entry| entry.name == name) + .ok_or_else(|| "WEBDAV_VERIFY_MISSING".to_string())?; + + let temp_path = std::env::temp_dir().join(&name); + let written = download_to_file(&target, &target.child(&name), &temp_path, expected.size).await; + let written = match written { + Ok(written) => written, + Err(error) => { + let _ = std::fs::remove_file(&temp_path); + return Err(error); + } + }; + + let result = apply_full_backup(storage.inner(), &temp_path.to_string_lossy()); + let _ = std::fs::remove_file(&temp_path); + match &result { + Ok(()) => log::info!("[webdav] restored from {} ({} bytes)", name, written), + Err(error) => log::warn!("[webdav] restore failed {}: {}", name, diag::truncate(error, 300)), + } + result +} + +/// 流式下载到文件。不用 `response.bytes()`:那会把整个备份读进内存, +/// 含音频时等于按备份大小申请一块几 GB 的堆。 +async fn download_to_file( + target: &Target, + url: &str, + dest: &std::path::Path, + expected_size: u64, +) -> Result { + let response = request(target, "GET", url) + // 和上传同一套预算:下限约 50KB/s,既不掐断正常下载也不会永远挂着。 + .timeout(Duration::from_secs(120 + expected_size / 50_000)) + .send() + .await + .map_err(|e| format!("GET failed: {}", diag::truncate(&e.to_string(), 200)))?; + let mut response = check_status(response, "GET").await?; + + let mut file = std::fs::File::create(dest) + .map_err(|e| format!("Failed to write the downloaded backup: {}", e))?; + let mut written = 0u64; + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| format!("Download interrupted: {}", diag::truncate(&e.to_string(), 200)))? + { + file.write_all(&chunk) + .map_err(|e| format!("Failed to write the downloaded backup: {}", e))?; + written += chunk.len() as u64; + } + file.flush() + .map_err(|e| format!("Failed to write the downloaded backup: {}", e))?; + + if written == 0 { + return Err("WEBDAV_EMPTY_DOWNLOAD".to_string()); + } + // 服务器报了体积就核对一次。截断的下载解压时会报「不是有效的备份档」, + // 那条信息会把人引去怀疑备份文件本身,而真正的问题是这次传输。 + if expected_size != 0 && written != expected_size { + return Err(format!( + "Download incomplete: got {} of {} bytes", + written, expected_size + )); + } + Ok(written) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_plain_http_and_other_schemes() { + assert_eq!( + normalize_dir_url("http://dav.jianguoyun.com/dav/SayIt").unwrap_err(), + "WEBDAV_URL_INSECURE" + ); + assert_eq!( + normalize_dir_url("dav.jianguoyun.com/dav").unwrap_err(), + "WEBDAV_URL_SCHEME" + ); + assert_eq!(normalize_dir_url(" ").unwrap_err(), "WEBDAV_URL_EMPTY"); + assert_eq!(normalize_dir_url("https://").unwrap_err(), "WEBDAV_URL_EMPTY"); + } + + #[test] + fn strips_trailing_slashes_so_children_do_not_double_up() { + let url = normalize_dir_url("https://dav.jianguoyun.com/dav/SayIt/").unwrap(); + assert_eq!(url, "https://dav.jianguoyun.com/dav/SayIt"); + let target = Target { + dir_url: url, + username: "u".into(), + password: "p".into(), + }; + assert_eq!( + target.child("sayit-backup-x.zip"), + "https://dav.jianguoyun.com/dav/SayIt/sayit-backup-x.zip" + ); + } + + #[test] + fn credentials_must_not_be_blank() { + let err = Target::from_config(&WebDavConfig { + url: "https://dav.jianguoyun.com/dav/SayIt".into(), + username: " ".into(), + password: "p".into(), + }) + .unwrap_err(); + assert_eq!(err, "WEBDAV_CREDENTIALS_EMPTY"); + } + + /// 坚果云用 `d:` 前缀。 + #[test] + fn parses_listing_with_namespace_prefix() { + let xml = r#" + + + /dav/SayIt/ + + + + /dav/SayIt/sayit-backup-2026-08-26_10-00-00-000.zip + 4096 + + + /dav/SayIt/sayit-backup-2026-08-25_10-00-00-000.zip + 2048 + +"#; + let entries = parse_backup_entries(xml); + assert_eq!(entries.len(), 2); + // 倒序 = 从新到旧,保留策略直接 skip(keep) 就是删旧的。 + assert_eq!(entries[0].name, "sayit-backup-2026-08-26_10-00-00-000.zip"); + assert_eq!(entries[0].size, 4096); + assert_eq!(entries[1].size, 2048); + } + + /// 大写前缀 / 无前缀 / 其他前缀都必须能解析:各家服务器写法不统一, + /// 按前缀匹配的那一版会在换服务器时静默返回空列表。 + #[test] + fn parses_listing_regardless_of_prefix_case_or_absence() { + let xml = r#" + /dav/SayIt/sayit-backup-a.zip + 11 + +"#; + let entries = parse_backup_entries(xml); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].size, 11); + + let no_prefix = r#" + /dav/SayIt/sayit-backup-b.zip + 22 + +"#; + let entries = parse_backup_entries(no_prefix); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "sayit-backup-b.zip"); + assert_eq!(entries[0].size, 22); + } + + #[test] + fn ignores_directories_and_foreign_files() { + let xml = r#" + /dav/SayIt/ + /dav/SayIt/notes.txt + /dav/SayIt/sayit-config-2026.json + /dav/SayIt/sayit-backup-x.zip.part +"#; + assert!(parse_backup_entries(xml).is_empty()); + } + + /// 打包 0~50、上传 50~90,两段不能重叠也不能倒退。 + #[test] + fn progress_phases_do_not_overlap() { + assert_eq!(packing_percent(2.0), 1.0); + assert_eq!(packing_percent(98.0), 49.0); + assert!(packing_percent(200.0) <= 50.0); + assert_eq!(upload_percent(0, 1000), 50.0); + assert_eq!(upload_percent(1000, 1000), 90.0); + // 体积未知时不会算出 NaN 或负数 + assert_eq!(upload_percent(0, 0), 90.0); + } + + /// 设置没配过时,scope 必须是「只有配置」。 + /// + /// 这条要用真的 Storage 跑:默认值在 defaults.ts 和这里各有一份,靠 + /// `setting_bool` 的 `unwrap_or(false)` 兜底。一旦有人把兜底改成 true, + /// 后果是每台机器悄悄开始上传整个音频库,而界面上的勾还是没勾的。 + #[test] + fn scope_defaults_to_config_only_when_unset() { + let dir = std::env::temp_dir().join(format!("sayit-webdav-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let storage = Storage::new(dir.join("test.db")).unwrap(); + + let scope = scope_from_settings(&storage); + assert!(!scope.include_history); + assert!(!scope.include_audio); + + storage.set(KEY_INCLUDE_AUDIO, &Value::Bool(true)).unwrap(); + assert!(scope_from_settings(&storage).include_audio); + assert!(!scope_from_settings(&storage).include_history); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn ancestors_stop_at_the_host() { + assert_eq!( + ancestor_urls("https://host.com/dav/a/b"), + vec!["https://host.com/dav/a", "https://host.com/dav"] + ); + // 目录就在根下时没有需要补建的祖先 + assert!(ancestor_urls("https://host.com/dav").is_empty()); + } + + /// 恢复只接受我们自己的备份名:一个 `../` 就能把下载地址带到目录之外。 + #[test] + fn restore_rejects_names_with_separators() { + for bad in [ + "../../etc/passwd", + "sayit-backup-../x.zip", + "sayit-backup-a.zip/..", + "other.zip", + "sayit-backup-a.txt", + ] { + let ok = bad.starts_with(BACKUP_PREFIX) + && bad.ends_with(".zip") + && !bad.contains('/') + && !bad.contains('\\'); + assert!(!ok, "should have been rejected: {}", bad); + } + assert!("sayit-backup-2026-08-26_10-00-00-000.zip".starts_with(BACKUP_PREFIX)); + } +} diff --git a/client/src-tauri/src/main.rs b/client/src-tauri/src/main.rs index a14df87..bf0cbdb 100644 --- a/client/src-tauri/src/main.rs +++ b/client/src-tauri/src/main.rs @@ -637,6 +637,11 @@ fn main() { commands::backup::import_config, commands::backup::import_full, commands::backup::restart_app, + // WebDAV 备份(配置必备,历史/音频可选) + commands::webdav::webdav_test, + commands::webdav::webdav_list, + commands::webdav::webdav_backup_now, + commands::webdav::webdav_restore, // Diagnostics commands::diagnostics::collect_settings, commands::diagnostics::get_diagnostics_preview, @@ -660,6 +665,11 @@ fn main() { providers::asr_qwen_realtime::qwen_stream_send, providers::asr_qwen_realtime::qwen_stream_finish, providers::asr_qwen_realtime::qwen_stream_close, + // Qwen-Audio-3.0 streaming ASR (DashScope duplex protocol) + providers::asr_qwen_audio_stream::qwen_audio_stream_open, + providers::asr_qwen_audio_stream::qwen_audio_stream_send, + providers::asr_qwen_audio_stream::qwen_audio_stream_finish, + providers::asr_qwen_audio_stream::qwen_audio_stream_close, // Models (local model management) models::registry::list_available_models, models::registry::list_downloaded_models, diff --git a/client/src-tauri/src/providers/asr_qwen_audio_stream.rs b/client/src-tauri/src/providers/asr_qwen_audio_stream.rs new file mode 100644 index 0000000..155227e --- /dev/null +++ b/client/src-tauri/src/providers/asr_qwen_audio_stream.rs @@ -0,0 +1,955 @@ +// 千问 Qwen-Audio-3.0 流式语音识别 — qwen-audio-3.0-asr-flash-streaming +// +// ⚠️ 协议与 asr_qwen_realtime.rs **不是同一套**,别指望改个模型名就能复用: +// · asr_qwen_realtime → `/api-ws/v1/realtime`,OpenAI-Realtime 风格事件 +// (session.update / input_audio_buffer.append,音频 base64 塞进 JSON); +// · 这一份 → `/api-ws/v1/inference`,DashScope 原生 duplex 指令 +// (run-task → task-started → 裸二进制音频帧 → result-generated → +// finish-task → task-finished)。 +// +// 实测结论(`dev-scripts/probe_qwen_audio30_stream.py`,打的是真实接口): +// · 通用域名 `dashscope.aliyuncs.com` 与业务空间专属域名都能用 —— 这个模型 +// **不需要 WorkspaceId**(qwen3-asr-flash-realtime 才需要)。填了就走专属域名 +// (阿里官方称更稳),没填照样工作,所以设置页不把它列为必填项。 +// · `result-generated` 里 `sentence.text` 是**当前句的全量文本**,不是增量。 +// · 开头十几条 `result-generated` 的 text 是空串(VAD 还没判出语音),必须丢掉, +// 否则悬浮窗会先闪一下空字幕。 +// · 每个 `sentence_end=true` 的句子自带句尾标点(中文「。」、英文 ". "), +// 所以分句拼接**不加分隔符**;asr_qwen_realtime 那边补「,」是因为它的分句不带标点。 +// · 音频不按实时节奏发也能用(10.84s 音频一次性推完,2.6s 出全文), +// 所以一次性识别那条路不需要 sleep 假装实时。 + +use super::diag; +use super::types::{AsrProviderConfig, AsrResult, TestResult}; +use futures_util::stream::{SplitSink, SplitStream}; +use futures_util::{Sink, SinkExt, StreamExt}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter}; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tokio_tungstenite::tungstenite; +use tungstenite::client::IntoClientRequest; +use tungstenite::http::header::{AUTHORIZATION, USER_AGENT}; +use tungstenite::http::HeaderValue; + +const MODEL: &str = "qwen-audio-3.0-asr-flash-streaming"; +const SCOPE: &str = "qwen/audio30"; +const WS_PATH: &str = "/api-ws/v1/inference"; +/// 没配业务空间时的通用域名。实测与专属域名同样可用。 +const GENERIC_HOST: &str = "dashscope.aliyuncs.com"; + +/// 音频帧大小:16kHz 单声道 16-bit 下正好 100ms。 +/// +/// 官方示例用的就是 3200 字节,probe 脚本也是按这个尺寸实测通过的 —— +/// 一次性路径不改成"一大帧发完"是因为那个形状没验证过,而这个验证过。 +const FRAME_BYTES: usize = 3200; + +/// 即时热词权重。文档区间 [1,5],越大越倾向输出该词;50 是「超级热词」 +/// (召回大幅提升但最多 50 个)。取 4 是要偏置效果又不想把普通词条推成超级热词。 +const HOTWORD_WEIGHT: u32 = 4; + +/// 即时热词条数上限。文档只对「超级热词」明确了 50 的上限,普通热词没给数字; +/// 这里自己设一个上限并记日志,避免词库很大时 run-task 被整条拒掉 —— +/// 那会让识别**完全不可用**,而不是"热词少生效几个"。 +const HOTWORD_LIMIT: usize = 100; + +type WsStream = tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, +>; +type WsSink = SplitSink; + +// ─────────────────────────── 协议编解码 ─────────────────────────── + +fn ws_url(workspace: &str) -> String { + let workspace = workspace.trim(); + if workspace.is_empty() { + format!("wss://{}{}", GENERIC_HOST, WS_PATH) + } else { + format!("wss://{}.cn-beijing.maas.aliyuncs.com{}", workspace, WS_PATH) + } +} + +/// 热词 → `parameters.vocabulary`(`{词: 权重}`)。 +/// +/// 与 asr_qwen.rs 的做法不同:那边只能把热词拼成一段上下文文本,这个模型有专门的 +/// 即时热词字段,权重可控,也不存在「模型把整串热词当识别结果吐回来」的回显风险。 +fn build_vocabulary(hotwords: &[String]) -> Option<(serde_json::Value, usize, usize)> { + let mut seen = std::collections::HashSet::new(); + let words: Vec<&str> = hotwords + .iter() + .map(|w| w.trim()) + .filter(|w| !w.is_empty()) + .filter(|w| seen.insert(w.to_lowercase())) + .collect(); + if words.is_empty() { + return None; + } + let total = words.len(); + let mut map = serde_json::Map::new(); + for w in words.iter().take(HOTWORD_LIMIT) { + map.insert((*w).to_string(), serde_json::json!(HOTWORD_WEIGHT)); + } + let used = map.len(); + Some((serde_json::Value::Object(map), used, total)) +} + +fn run_task_payload(task_id: &str, sample_rate: u32, hotwords: &[String]) -> serde_json::Value { + let mut parameters = serde_json::json!({ + "format": "pcm", + "sample_rate": sample_rate, + }); + if let Some((vocabulary, used, total)) = build_vocabulary(hotwords) { + parameters["vocabulary"] = vocabulary; + diag::log( + SCOPE, + "hotwords", + &format!("vocabulary={} of={} weight={}", used, total, HOTWORD_WEIGHT), + ); + } + serde_json::json!({ + "header": { "action": "run-task", "task_id": task_id, "streaming": "duplex" }, + "payload": { + "task_group": "audio", + "task": "asr", + "function": "recognition", + "model": MODEL, + "parameters": parameters, + "input": {}, + } + }) +} + +fn finish_task_payload(task_id: &str) -> serde_json::Value { + serde_json::json!({ + "header": { "action": "finish-task", "task_id": task_id, "streaming": "duplex" }, + "payload": { "input": {} } + }) +} + +fn event_name(ev: &serde_json::Value) -> &str { + ev.get("header") + .and_then(|h| h.get("event")) + .and_then(|e| e.as_str()) + .unwrap_or("") +} + +/// task-failed 的原因。错误码和文案都在 header 里,不在 payload。 +fn task_failed_reason(ev: &serde_json::Value) -> String { + let header = ev.get("header"); + let code = header + .and_then(|h| h.get("error_code")) + .and_then(|c| c.as_str()) + .unwrap_or("unknown"); + let message = header + .and_then(|h| h.get("error_message")) + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error"); + format!("{}: {}", code, diag::truncate(message, 200)) +} + +/// `payload.output.sentence` → (当前句文本, 是否已断句) +fn sentence_of(ev: &serde_json::Value) -> Option<(&str, bool)> { + let sentence = ev.get("payload")?.get("output")?.get("sentence")?; + let text = sentence.get("text").and_then(|t| t.as_str()).unwrap_or(""); + let ended = sentence + .get("sentence_end") + .and_then(|e| e.as_bool()) + .unwrap_or(false); + Some((text, ended)) +} + +/// 一次会话累积到的文本。committed = 已断句的句子,partial = 进行中的那一句。 +#[derive(Default)] +struct Transcript { + committed: String, + partial: String, + finished: bool, + error: Option, + /// 收到过多少条带文本的中间结果,只用于日志 + partial_events: usize, +} + +/// 一条事件对累积状态做了什么,调用方据此决定要不要上抛 / 退出循环。 +enum Applied { + /// 与文本无关的事件(task-started、空文本的中间结果等) + Ignored, + /// 可显示文本变了 + Updated, + Finished, + Failed, +} + +impl Transcript { + /// 实时展示用文本 = 已断句 + 进行中。分句自带标点,所以直接相接。 + fn display(&self) -> String { + format!("{}{}", self.committed, self.partial) + } + + fn apply(&mut self, ev: &serde_json::Value) -> Applied { + match event_name(ev) { + "result-generated" => { + let Some((text, ended)) = sentence_of(ev) else { + return Applied::Ignored; + }; + // 开头那十几条空文本的中间结果必须丢掉,否则悬浮窗先闪一下空字幕 + if text.is_empty() { + return Applied::Ignored; + } + if ended { + self.committed.push_str(text); + self.partial.clear(); + } else { + // text 是当前句的全量文本(不是增量),直接覆盖 + self.partial.clear(); + self.partial.push_str(text); + } + self.partial_events += 1; + Applied::Updated + } + "task-finished" => { + self.finished = true; + Applied::Finished + } + "task-failed" => { + self.error = Some(task_failed_reason(ev)); + self.finished = true; + Applied::Failed + } + _ => Applied::Ignored, + } + } +} + +// ─────────────────────────── 建连与开任务 ─────────────────────────── + +struct Task { + ws: WsStream, + task_id: String, +} + +/// 建连 → 发 run-task → 等 task-started。拿到 Task 才算可以发音频。 +async fn start_task( + config: &AsrProviderConfig, + workspace: &str, + sample_rate: u32, + hotwords: &[String], +) -> Result { + if config.api_key.trim().is_empty() { + return Err(diag::fail_code( + SCOPE, + "credentials", + "provider_bad_key", + "Qwen ASR is missing the API Key; complete it in Settings".to_string(), + )); + } + + let url = ws_url(workspace); + let mut request = url.as_str().into_client_request().map_err(|e| { + diag::fail( + SCOPE, + "build_request", + format!("Failed to build request: {}", e), + ) + })?; + request.headers_mut().insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", config.api_key.trim())).map_err(|e| { + diag::fail_code( + SCOPE, + "authorization_header", + "provider_bad_key", + format!("Invalid Authorization header: {}", e), + ) + })?, + ); + // UA 不是这个端点的硬要求(实测不带也能连),但我们所有出网请求都带: + // 自有 ALB 与第三方 nginx 网关会按「无 UA」直接 403,且报错里绝不提 UA。 + request.headers_mut().insert( + USER_AGENT, + HeaderValue::from_static(concat!("SayIt/", env!("CARGO_PKG_VERSION"))), + ); + let workspace = workspace.trim(); + if !workspace.is_empty() { + if let Ok(value) = HeaderValue::from_str(workspace) { + request.headers_mut().insert("X-DashScope-WorkSpace", value); + } + } + + let (mut ws, response) = tokio_tungstenite::connect_async(request) + .await + .map_err(|e| { + diag::fail( + SCOPE, + "connect", + format!("WebSocket connection failed: {}", e), + ) + })?; + + let task_id = uuid::Uuid::new_v4().to_string(); + diag::log( + SCOPE, + "connected", + &format!( + "status={} model={} host={} rate={}", + response.status(), + MODEL, + if workspace.is_empty() { GENERIC_HOST } else { "workspace" }, + sample_rate + ), + ); + + let payload = run_task_payload(&task_id, sample_rate, hotwords); + ws.send(tungstenite::Message::Text( + serde_json::to_string(&payload).unwrap().into(), + )) + .await + .map_err(|e| { + diag::fail( + SCOPE, + "send_run_task", + format!("Failed to send run-task: {}", e), + ) + })?; + + // 必须等到 task-started 才算就绪。额度耗尽 / 模型未开通时服务端回 task-failed + // 或直接关连接,都要在这里变成 Err,让上层回退到别的路径而不是空转。 + let deadline = Duration::from_secs(15); + let started = tokio::time::timeout(deadline, async { + while let Some(msg) = ws.next().await { + let msg = msg.map_err(|e| { + diag::fail( + SCOPE, + "recv_ack", + format!("Failed to receive acknowledgement: {}", e), + ) + })?; + match msg { + tungstenite::Message::Text(text) => { + let Ok(ev) = serde_json::from_str::(&text) else { + continue; + }; + match event_name(&ev) { + "task-started" => return Ok(true), + "task-failed" => { + return Err(diag::fail( + SCOPE, + "task_failed", + format!("Server rejected the task: {}", task_failed_reason(&ev)), + )) + } + _ => {} + } + } + tungstenite::Message::Close(frame) => { + let reason = frame + .map(|f| f.reason.to_string()) + .unwrap_or_else(|| "No reason".to_string()); + return Err(diag::fail( + SCOPE, + "closed_before_ready", + format!("Server closed the session: {}", reason), + )); + } + _ => {} + } + } + Ok(false) + }) + .await + .map_err(|_| { + diag::fail( + SCOPE, + "task_started_timeout", + "Timed out waiting for the task to start".to_string(), + ) + })??; + + if !started { + return Err(diag::fail( + SCOPE, + "closed_before_ready", + "WebSocket closed before the task started".to_string(), + )); + } + diag::log(SCOPE, "task_started", ""); + Ok(Task { ws, task_id }) +} + +/// 把 PCM 切成 100ms 的二进制帧发出去。 +async fn send_frames(sink: &mut S, pcm: &[u8], stage: &'static str) -> Result<(), String> +where + S: Sink + Unpin, +{ + for frame in pcm.chunks(FRAME_BYTES) { + sink.send(tungstenite::Message::Binary(frame.to_vec().into())) + .await + .map_err(|e| diag::fail(SCOPE, stage, format!("Failed to send audio: {}", e)))?; + } + Ok(()) +} + +// ─────────────────────────── 一次性识别 ─────────────────────────── + +/// 录完再发:整段 PCM 推完 → finish-task → 收全文。 +/// +/// 设置页的「识别测试」和关掉实时字幕时的录音都走这里,而它们都不传业务空间 ID, +/// 所以这条路必须能用通用域名 —— 已实测可用。 +pub async fn transcribe( + audio_pcm_b64: &str, + sample_rate: u32, + config: &AsrProviderConfig, + hotwords: &[String], +) -> Result { + let pcm = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, audio_pcm_b64).map_err( + |e| { + diag::fail( + SCOPE, + "decode_b64", + format!("Failed to decode base64 audio: {}", e), + ) + }, + )?; + if pcm.is_empty() { + diag::empty_result(SCOPE, "Input audio was empty; provider request was skipped"); + return Ok(AsrResult { + text: String::new(), + elapsed_ms: 0, + }); + } + + let workspace = config + .extra + .get("workspaceId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let audio_sec = pcm.len() as f64 / (sample_rate.max(1) as f64 * 2.0); + diag::log( + SCOPE, + "start", + &format!( + "pcm_bytes={} audio_sec={:.1} rate={} hotwords={}", + pcm.len(), + audio_sec, + sample_rate, + hotwords.len() + ), + ); + + let start = Instant::now(); + let Task { mut ws, task_id } = start_task(config, workspace, sample_rate, hotwords).await?; + send_frames(&mut ws, &pcm, "send_audio").await?; + ws.send(tungstenite::Message::Text( + serde_json::to_string(&finish_task_payload(&task_id)) + .unwrap() + .into(), + )) + .await + .map_err(|e| { + diag::fail( + SCOPE, + "send_finish", + format!("Failed to send finish-task: {}", e), + ) + })?; + + let mut transcript = Transcript::default(); + // 上限按音频长度放宽:10.8s 音频实测 2.6s 收全,长音频线性长一些。 + let collect_budget = Duration::from_secs(20) + Duration::from_secs_f64(audio_sec.min(300.0)); + let closed_early = tokio::time::timeout(collect_budget, async { + while let Some(msg) = ws.next().await { + let msg = msg.map_err(|e| { + diag::fail(SCOPE, "recv", format!("Failed to receive result: {}", e)) + })?; + match msg { + tungstenite::Message::Text(text) => { + let Ok(ev) = serde_json::from_str::(&text) else { + continue; + }; + match transcript.apply(&ev) { + Applied::Finished => return Ok(false), + Applied::Failed => { + return Err(diag::fail( + SCOPE, + "task_failed", + format!( + "Recognition failed: {}", + transcript.error.clone().unwrap_or_default() + ), + )) + } + _ => {} + } + } + // 服务端提前关连接:协议没跑完,不能当成"识别到空文本"上报成功, + // 否则真实原因(额度、权限、协议错)会被显示成「未检测到有效声音」。 + tungstenite::Message::Close(frame) => { + let reason = frame + .map(|f| f.reason.to_string()) + .unwrap_or_else(|| "No reason".to_string()); + diag::log(SCOPE, "closed_before_finished", &reason); + return Ok(true); + } + _ => {} + } + } + Ok(true) + }) + .await + .map_err(|_| { + diag::fail( + SCOPE, + "collect_timeout", + "Timed out waiting for the recognition result".to_string(), + ) + })??; + + let _ = ws.close(None).await; + let text = transcript.display().trim().to_string(); + let elapsed_ms = start.elapsed().as_millis() as u64; + + if closed_early && text.is_empty() { + return Err(diag::fail( + SCOPE, + "closed_before_finished", + "The server closed the connection before returning a result".to_string(), + )); + } + if text.is_empty() { + diag::empty_result( + SCOPE, + &format!( + "Task finished with no transcript audio_sec={:.1} elapsed={}ms events={}", + audio_sec, elapsed_ms, transcript.partial_events + ), + ); + } else { + diag::ok(SCOPE, elapsed_ms, text.chars().count()); + } + Ok(AsrResult { text, elapsed_ms }) +} + +pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { + let start = Instant::now(); + // 只握手 + 开任务 + 立刻收尾:能开出任务就说明密钥、模型、地域都对得上。 + // 不发音频(这个命令只回答"能不能连",真实转写由设置页的识别测试负责)。 + let result = async { + let workspace = config + .extra + .get("workspaceId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let Task { mut ws, task_id } = start_task(config, workspace, 16000, &[]).await?; + let _ = ws + .send(tungstenite::Message::Text( + serde_json::to_string(&finish_task_payload(&task_id)) + .unwrap() + .into(), + )) + .await; + let _ = ws.close(None).await; + Ok::<(), String>(()) + } + .await; + + let elapsed_ms = start.elapsed().as_millis() as u64; + match result { + Ok(()) => TestResult { + ok: true, + message: format!("Connection successful ({}ms)", elapsed_ms), + elapsed_ms, + detail: format!("model: {}", MODEL), + }, + Err(message) => TestResult { + ok: false, + message, + elapsed_ms, + detail: String::new(), + }, + } +} + +// ─────────────────────────── 流式会话 ─────────────────────────── + +static SINK: once_cell::sync::Lazy>>> = + once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None))); +static READER: once_cell::sync::Lazy>>>> = + once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None))); +static STATE: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(Transcript::default()))); +static TASK_ID: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(String::new()))); +static ACTIVE: AtomicBool = AtomicBool::new(false); + +/// 打开流式会话。 +/// +/// 与 asr_qwen_realtime 不同,这里**不分「实时 / 普通」两条代码路径**:无论要不要 +/// 上抛中间结果,都必须有后台 reader 一直读。这个协议在发音频期间每 ~100ms 就推一条 +/// result-generated,没人读就会把连接堵死;realtime 只决定要不要 emit。 +#[tauri::command] +pub async fn qwen_audio_stream_open( + app: AppHandle, + config: AsrProviderConfig, + hotwords: Option>, + realtime: Option, + workspace_id: Option, +) -> Result<(), String> { + let realtime = realtime.unwrap_or(false); + cleanup().await; + + let hotwords = hotwords.unwrap_or_default(); + let workspace = workspace_id.unwrap_or_default(); + let Task { ws, task_id } = start_task(&config, &workspace, 16000, &hotwords).await?; + + let (sink, stream) = ws.split(); + *STATE.lock().await = Transcript::default(); + *TASK_ID.lock().await = task_id; + *SINK.lock().await = Some(sink); + let handle = tokio::spawn(run_reader(stream, app, STATE.clone(), realtime)); + *READER.lock().await = Some(handle); + ACTIVE.store(true, Ordering::SeqCst); + diag::log(SCOPE, "stream_open", &format!("realtime={}", realtime)); + Ok(()) +} + +async fn run_reader( + mut stream: SplitStream, + app: AppHandle, + state: Arc>, + realtime: bool, +) { + let mut emitted = 0usize; + let mut ended_cleanly = false; + let mut close_reason = String::new(); + while let Some(msg) = stream.next().await { + let text = match msg { + Ok(tungstenite::Message::Text(t)) => t, + Ok(tungstenite::Message::Close(frame)) => { + close_reason = frame + .map(|f| f.reason.to_string()) + .unwrap_or_else(|| "no reason".to_string()); + break; + } + Err(e) => { + close_reason = e.to_string(); + break; + } + Ok(_) => continue, + }; + let Ok(ev) = serde_json::from_str::(&text) else { + continue; + }; + + let (applied, display) = { + let mut s = state.lock().await; + let applied = s.apply(&ev); + (applied, s.display()) + }; + match applied { + Applied::Updated => { + if realtime { + emitted += 1; + if emitted == 1 { + diag::log(SCOPE, "first_partial", ""); + } + let _ = app.emit( + "asr-partial", + serde_json::json!({ "text": display, "provider": "qwen_audio_stream" }), + ); + } + } + Applied::Finished | Applied::Failed => { + ended_cleanly = true; + break; + } + Applied::Ignored => {} + } + } + let mut s = state.lock().await; + s.finished = true; + // 协议没跑完就断了要记下来。否则 finish 只能返回一个空字符串,而空字符串会被 + // 显示成「未检测到有效声音」,把用户引去查麦克风 —— 真实原因(额度、鉴权、 + // 网络)就此消失。见 pitfalls 第 15 条。 + if !ended_cleanly && s.error.is_none() { + s.error = Some(format!( + "connection closed before task-finished: {}", + diag::truncate(&close_reason, 200) + )); + } + diag::log( + SCOPE, + "reader_stopped", + &format!( + "emits={} events={} clean={}", + emitted, s.partial_events, ended_cleanly + ), + ); +} + +async fn cleanup() { + ACTIVE.store(false, Ordering::SeqCst); + if let Some(mut sink) = SINK.lock().await.take() { + let _ = sink.close().await; + } + if let Some(handle) = READER.lock().await.take() { + handle.abort(); + } + *STATE.lock().await = Transcript::default(); + TASK_ID.lock().await.clear(); +} + +#[tauri::command] +pub async fn qwen_audio_stream_send(pcm_b64: String) -> Result<(), String> { + if !ACTIVE.load(Ordering::SeqCst) { + return Err(diag::fail( + SCOPE, + "send_without_session", + "Session is not open".to_string(), + )); + } + let pcm = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &pcm_b64) + .map_err(|e| { + diag::fail( + SCOPE, + "decode_b64", + format!("Failed to decode base64 audio: {}", e), + ) + })?; + let mut sink = SINK.lock().await; + let s = sink.as_mut().ok_or_else(|| { + diag::fail( + SCOPE, + "send_without_session", + "Session is not open".to_string(), + ) + })?; + send_frames(s, &pcm, "send_audio").await +} + +#[tauri::command] +pub async fn qwen_audio_stream_finish() -> Result { + if !ACTIVE.load(Ordering::SeqCst) { + return Err(diag::fail( + SCOPE, + "finish_without_session", + "Session is not open".to_string(), + )); + } + let task_id = TASK_ID.lock().await.clone(); + { + let mut sink = SINK.lock().await; + let s = sink.as_mut().ok_or_else(|| { + diag::fail( + SCOPE, + "finish_without_session", + "Session is not open".to_string(), + ) + })?; + // 发送失败不致命:可能服务端已经收尾,继续读已累计的文本即可。 + if let Err(e) = s + .send(tungstenite::Message::Text( + serde_json::to_string(&finish_task_payload(&task_id)) + .unwrap() + .into(), + )) + .await + { + diag::log(SCOPE, "finish_send_err_ignored", &e.to_string()); + } + } + + let deadline = Instant::now() + Duration::from_secs(20); + loop { + { + let st = STATE.lock().await; + if st.finished { + let err = st.error.clone(); + let text = st.display().trim().to_string(); + let events = st.partial_events; + drop(st); + cleanup().await; + // 已经识别出文字就交回去,哪怕收尾时断了 —— 丢掉它等于让用户白说一遍。 + // 但断连原因仍要留痕。 + if !text.is_empty() { + if let Some(err) = &err { + diag::log(SCOPE, "finished_with_error", err); + } + return Ok(text); + } + if let Some(err) = err { + return Err(diag::fail( + SCOPE, + "task_failed", + format!("Recognition failed: {}", err), + )); + } + diag::empty_result( + SCOPE, + &format!("Task finished with no transcript events={}", events), + ); + return Ok(String::new()); + } + } + if Instant::now() >= deadline { + break; + } + tokio::time::sleep(Duration::from_millis(30)).await; + } + + // 超时也把已拿到的文本交回去:丢掉它等于让用户白说一遍。 + let text = STATE.lock().await.display().trim().to_string(); + diag::log( + SCOPE, + "finish_timeout", + &format!("returning partial chars={}", text.chars().count()), + ); + cleanup().await; + Ok(text) +} + +#[tauri::command] +pub async fn qwen_audio_stream_close() -> Result<(), String> { + cleanup().await; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 没配业务空间也要能工作:设置页的识别测试就不传它。 + #[test] + fn ws_url_falls_back_to_the_generic_host() { + assert_eq!( + ws_url(""), + "wss://dashscope.aliyuncs.com/api-ws/v1/inference" + ); + assert_eq!( + ws_url(" "), + "wss://dashscope.aliyuncs.com/api-ws/v1/inference" + ); + assert_eq!( + ws_url("ws-abc"), + "wss://ws-abc.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference" + ); + } + + #[test] + fn run_task_payload_matches_the_documented_shape() { + let payload = run_task_payload("tid", 16000, &[]); + assert_eq!(payload["header"]["action"], "run-task"); + assert_eq!(payload["header"]["task_id"], "tid"); + assert_eq!(payload["header"]["streaming"], "duplex"); + assert_eq!(payload["payload"]["task_group"], "audio"); + assert_eq!(payload["payload"]["task"], "asr"); + assert_eq!(payload["payload"]["function"], "recognition"); + assert_eq!(payload["payload"]["model"], MODEL); + assert_eq!(payload["payload"]["parameters"]["format"], "pcm"); + assert_eq!(payload["payload"]["parameters"]["sample_rate"], 16000); + // input 是必填,没有上下文时也要是一个空对象 + assert!(payload["payload"]["input"].is_object()); + assert!(payload["payload"]["parameters"].get("vocabulary").is_none()); + } + + #[test] + fn hotwords_become_weighted_instant_vocabulary() { + let words = vec!["SayIt".to_string(), " Kiro ".to_string()]; + let payload = run_task_payload("tid", 16000, &words); + let vocab = &payload["payload"]["parameters"]["vocabulary"]; + assert_eq!(vocab["SayIt"], HOTWORD_WEIGHT); + assert_eq!(vocab["Kiro"], HOTWORD_WEIGHT); + } + + #[test] + fn hotwords_are_deduped_and_capped() { + let (_, used, total) = build_vocabulary(&[ + "SayIt".to_string(), + "sayit".to_string(), + "".to_string(), + " ".to_string(), + ]) + .unwrap(); + assert_eq!((used, total), (1, 1)); + + let many: Vec = (0..HOTWORD_LIMIT + 20).map(|i| format!("w{}", i)).collect(); + let (_, used, total) = build_vocabulary(&many).unwrap(); + assert_eq!(used, HOTWORD_LIMIT); + assert_eq!(total, HOTWORD_LIMIT + 20); + + assert!(build_vocabulary(&[]).is_none()); + assert!(build_vocabulary(&[" ".to_string()]).is_none()); + } + + fn result_event(text: &str, ended: bool) -> serde_json::Value { + serde_json::json!({ + "header": { "event": "result-generated" }, + "payload": { "output": { "sentence": { "text": text, "sentence_end": ended } } } + }) + } + + /// 实测形状:中间结果是**当前句的全量文本**,不是增量。 + /// 按增量累加过一次就会得到「语音输入语音输入法语音输入法测试」这种叠字。 + #[test] + fn partials_replace_rather_than_append() { + let mut t = Transcript::default(); + t.apply(&result_event("语音输入", false)); + t.apply(&result_event("语音输入法测试", false)); + assert_eq!(t.display(), "语音输入法测试"); + } + + /// 开头那批空文本的中间结果不能产生一次「更新」,否则悬浮窗会先闪一下空字幕。 + #[test] + fn empty_partials_are_ignored() { + let mut t = Transcript::default(); + assert!(matches!(t.apply(&result_event("", false)), Applied::Ignored)); + assert_eq!(t.partial_events, 0); + assert_eq!(t.display(), ""); + } + + /// 分句自带句尾标点,所以拼接不补分隔符。 + #[test] + fn finished_sentences_are_joined_without_a_separator() { + let mut t = Transcript::default(); + t.apply(&result_event("The quick brown fox. ", true)); + t.apply(&result_event("Second one", false)); + assert_eq!(t.display(), "The quick brown fox. Second one"); + t.apply(&result_event("Second one here.", true)); + assert_eq!(t.display(), "The quick brown fox. Second one here."); + assert_eq!(t.partial, ""); + } + + #[test] + fn task_finished_and_failed_are_terminal() { + let mut t = Transcript::default(); + assert!(matches!( + t.apply(&serde_json::json!({ "header": { "event": "task-finished" } })), + Applied::Finished + )); + assert!(t.finished); + + let mut t = Transcript::default(); + let failed = serde_json::json!({ + "header": { "event": "task-failed", "error_code": "InvalidParameter", "error_message": "bad model" } + }); + assert!(matches!(t.apply(&failed), Applied::Failed)); + assert!(t.finished); + assert_eq!( + t.error.as_deref(), + Some("InvalidParameter: bad model") + ); + } + + /// task-started 之类的事件不该被当成文本更新。 + #[test] + fn unrelated_events_are_ignored() { + let mut t = Transcript::default(); + assert!(matches!( + t.apply(&serde_json::json!({ "header": { "event": "task-started" } })), + Applied::Ignored + )); + assert!(!t.finished); + assert_eq!(t.display(), ""); + } +} diff --git a/client/src-tauri/src/providers/mod.rs b/client/src-tauri/src/providers/mod.rs index 75deaec..addf728 100644 --- a/client/src-tauri/src/providers/mod.rs +++ b/client/src-tauri/src/providers/mod.rs @@ -14,6 +14,7 @@ pub mod asr_qwen_omni; pub mod asr_mimo; pub mod asr_groq; pub mod asr_qwen_realtime; +pub mod asr_qwen_audio_stream; pub mod doubao_auth; pub mod doubao_protocol; pub mod registry; diff --git a/client/src-tauri/src/providers/registry.rs b/client/src-tauri/src/providers/registry.rs index de13a1e..17f7ff8 100644 --- a/client/src-tauri/src/providers/registry.rs +++ b/client/src-tauri/src/providers/registry.rs @@ -3,7 +3,7 @@ use super::types::*; use super::{ ai_ollama, ai_openai_compat, asr_doubao, asr_doubao_stream, asr_groq, asr_mimo, asr_qwen, - asr_qwen_omni, + asr_qwen_audio_stream, asr_qwen_omni, }; use crate::error_protocol; @@ -86,6 +86,17 @@ pub async fn cloud_transcribe(request: CloudTranscribeRequest) -> Result { + asr_qwen_audio_stream::transcribe( + &request.audio_b64, + request.sample_rate, + config, + &request.hotwords, + ) + .await + } "qwen_omni" => { asr_qwen_omni::transcribe( &request.audio_b64, @@ -127,6 +138,7 @@ pub async fn test_asr_connection(config: AsrProviderConfig) -> Result Ok(asr_doubao::test_connection(&config).await), "doubao_v2" => Ok(asr_doubao_stream::test_connection(&config).await), "qwen" | "aliyun" | "qwen_realtime" => Ok(asr_qwen::test_connection(&config).await), + "qwen_audio_stream" => Ok(asr_qwen_audio_stream::test_connection(&config).await), "qwen_omni" => Ok(asr_qwen_omni::test_connection(&config).await), "mimo" => Ok(asr_mimo::test_connection(&config).await), "groq_whisper" => Ok(asr_groq::test_connection(&config).await), diff --git a/client/src/App.tsx b/client/src/App.tsx index 973dfbb..1d7e640 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -18,6 +18,7 @@ import { initAiEnabled } from './stores/aiEnabled' import { initActivePreset } from './stores/activePreset' import { getSetting, setSetting } from './services/store' import { startUpdateService } from './features/update/autoUpdate' +import { startWebDavBackupService } from './features/backup/autoWebdavBackup' import UpdateDialog from './features/update/UpdateDialog' import * as bridge from './services/bridge' @@ -37,15 +38,16 @@ export default function App() { void initActivePreset() initRecorder() void startUpdateService() + void startWebDavBackupService() - // 检查是否需要显示欢迎向导(仅首次安装) - ;(async () => { - const onboardedVersion = await getSetting('onboardingVersion', '') - if (!onboardedVersion) { - setShowWelcome(true) - } - setOnboardingChecked(true) - })() + // 检查是否需要显示欢迎向导(仅首次安装) + ; (async () => { + const onboardedVersion = await getSetting('onboardingVersion', '') + if (!onboardedVersion) { + setShowWelcome(true) + } + setOnboardingChecked(true) + })() return () => { cleanup() diff --git a/client/src/features/backup/autoWebdavBackup.ts b/client/src/features/backup/autoWebdavBackup.ts new file mode 100644 index 0000000..a64a8b7 --- /dev/null +++ b/client/src/features/backup/autoWebdavBackup.ts @@ -0,0 +1,193 @@ +// WebDAV 自动备份的调度。 +// +// 结构照着 features/update/autoUpdate.ts 抄:模块级 inFlight 锁 + setInterval + 一个 +// 小状态store。项目里只有这一种周期任务 pattern,别再引第二种。 +// +// 三条约束值得单独说明: +// +// 1. **判据是时间戳,不是 tick 计数。** setInterval 只在应用运行期计时,休眠和关机 +// 期间不补偿。按 tick 数算「到 24 小时了吗」,一台每天只开两小时的机器要 12 天 +// 才备份一次。所以每次 tick 都拿 `Date.now()` 和上次成功时间比。 +// 2. **失败后要退避。** 坚果云对 WebDAV 有请求频率限制,地址填错时每 15 分钟重试 +// 一次除了刷日志没有别的作用,还可能把账号打进限流。 +// 3. **enabled 每次 tick 都重读。** 用户在设置里打开开关后不该要求重启应用。 + +import { getSetting, setSetting } from '@/services/store' +import { addRuntimeEvent } from '@/services/debugLog' +import { + runWebDavBackup, + type WebDavBackupResult, + type WebDavLastResult, +} from '@/services/webdavBackup' + +/** 上次成功备份的时刻(ms)。间隔判定只看这个。 */ +const KEY_LAST_BACKUP_AT = 'webdav.lastBackupAt' +/** 上次尝试的时刻(ms),成功失败都记。失败退避用它。 */ +const KEY_LAST_ATTEMPT_AT = 'webdav.lastAttemptAt' + +/** + * 轮询周期。它**不是**备份间隔 —— 备份间隔由 webdav.intervalHours 决定,这里只是 + * 「多久去看一眼该不该备份了」。15 分钟足够让「刚过 24 小时」这件事被及时发现, + * 每次开销是读三条设置。 + */ +const TICK_MS = 15 * 60 * 1000 + +/** + * 启动后第一次检查的延迟。 + * + * 不在挂载时立刻跑:那正是加载本地模型、恢复窗口、检查更新最挤的时候,一个可能 + * 要压几百 MB 的打包挤进去只会让启动更慢。两分钟后再看,用户感知不到差别。 + */ +const FIRST_CHECK_DELAY_MS = 2 * 60 * 1000 + +/** 失败后至少等这么久再试,避免撞上网盘的请求频率限制。 */ +const FAILURE_BACKOFF_MS = 60 * 60 * 1000 + +export interface WebDavBackupState { + /** 正在备份。UI 用它禁用按钮、显示进度。 */ + running: boolean + lastResult: WebDavLastResult | null +} + +let currentState: WebDavBackupState = { running: false, lastResult: null } +const listeners = new Set<(state: WebDavBackupState) => void>() +/** 正在进行中的备份,防止定时任务和用户手动点击撞在一起传两份。 */ +let inFlight: Promise | null = null +let tickTimer: ReturnType | null = null +let firstCheckTimer: ReturnType | null = null + +function setState(patch: Partial) { + currentState = { ...currentState, ...patch } + listeners.forEach((cb) => cb(currentState)) +} + +export function getWebDavBackupState(): WebDavBackupState { + return currentState +} + +export function onWebDavBackupChange(cb: (state: WebDavBackupState) => void) { + listeners.add(cb) + return () => { listeners.delete(cb) } +} + +/** 从设置读回上次结果(Rust 侧在每次备份后写入)。 */ +export async function refreshLastResult(): Promise { + const value = await getSetting('webdav.lastResult', null).catch(() => null) + const lastResult = value && typeof value === 'object' ? value : null + setState({ lastResult }) + return lastResult +} + +/** + * 跑一次备份。手动按钮和定时任务共用。 + * + * 无论成功失败都写 lastAttemptAt,成功才写 lastBackupAt —— 失败不推进备份时间, + * 否则一次失败会让下一次备份被推迟整个间隔。 + */ +export async function runBackupNow(trigger: 'manual' | 'schedule'): Promise { + if (inFlight) return inFlight + + const task = (async (): Promise => { + setState({ running: true }) + const startedAt = Date.now() + await setSetting(KEY_LAST_ATTEMPT_AT, startedAt).catch(() => undefined) + try { + const result = await runWebDavBackup() + await setSetting(KEY_LAST_BACKUP_AT, Date.now()).catch(() => undefined) + addRuntimeEvent('info', 'webdav', 'backup finished', { + trigger, + fileName: result.fileName, + bytes: result.bytes, + includeHistory: result.includeHistory, + includeAudio: result.includeAudio, + pruned: result.pruned, + elapsedMs: Date.now() - startedAt, + }) + return result + } catch (error) { + // 失败必须留声,而且要能和「没开启备份」区分开。一个静默失败几个月的备份 + // 在界面上和一个正常工作的备份长得一模一样。 + addRuntimeEvent('error', 'webdav', 'backup failed', { trigger, error: String(error) }) + return null + } + })() + + inFlight = task + try { + return await task + } finally { + inFlight = null + setState({ running: false }) + await refreshLastResult() + } +} + +/** 到点了吗。读设置而不是缓存,用户改了间隔立刻生效。 */ +async function shouldBackupNow(): Promise { + const enabled = await getSetting('webdav.enabled', false).catch(() => false) + if (!enabled) return false + + // 地址或凭证没配完就别去打包:打完才发现传不上去纯属白烧一次 CPU 和磁盘。 + const [url, username, password] = await Promise.all([ + getSetting('webdav.url', '').catch(() => ''), + getSetting('webdav.username', '').catch(() => ''), + getSetting('webdav.password', '').catch(() => ''), + ]) + if (!url.trim() || !username.trim() || !password) return false + + const now = Date.now() + const lastAttemptAt = await getSetting(KEY_LAST_ATTEMPT_AT, 0).catch(() => 0) + const lastBackupAt = await getSetting(KEY_LAST_BACKUP_AT, 0).catch(() => 0) + + // 上次尝试失败(尝试比成功新)→ 走退避,别每 15 分钟撞一次限流。 + if (lastAttemptAt > lastBackupAt && now - lastAttemptAt < FAILURE_BACKOFF_MS) return false + + const intervalHours = await getSetting('webdav.intervalHours', 24).catch(() => 24) + const intervalMs = Math.max(1, intervalHours) * 60 * 60 * 1000 + // lastBackupAt 为 0 = 从没成功备份过,开启后第一次检查就该跑。 + return now - lastBackupAt >= intervalMs +} + +async function tick() { + try { + if (await shouldBackupNow()) { + await runBackupNow('schedule') + } + } catch (error) { + // tick 自己不能抛:它跑在 setInterval 里,抛出去就是一个没人接的 + // unhandled rejection,而且下一次 tick 照旧 —— 表现成毫无痕迹。 + addRuntimeEvent('error', 'webdav', 'backup tick threw', { error: String(error) }) + } +} + +/** + * 启动自动备份服务。在 App.tsx 挂载时调用一次。 + * + * 即使当前没开启也会起 ticker:开关是每次 tick 重读的,这样用户在设置里打开后 + * 不需要重启应用。代价是每 15 分钟读一次设置。 + */ +export async function startWebDavBackupService(): Promise { + // 整个函数包一层:调用方是 `void startWebDavBackupService()`,异常会被 Promise + // 静默吞掉,表现成「自动备份从来没跑过」且不留痕迹。 + try { + await refreshLastResult() + const enabled = await getSetting('webdav.enabled', false).catch(() => false) + // 无条件记一条:排查时先看这行在不在,不在就说明服务压根没启动。 + addRuntimeEvent('info', 'webdav', 'backup service starting', { + enabled, + intervalHours: await getSetting('webdav.intervalHours', 24).catch(() => 24), + includeHistory: await getSetting('webdav.includeHistory', false).catch(() => false), + includeAudio: await getSetting('webdav.includeAudio', false).catch(() => false), + lastBackupAt: await getSetting(KEY_LAST_BACKUP_AT, 0).catch(() => 0), + }) + + if (firstCheckTimer === null) { + firstCheckTimer = setTimeout(() => { void tick() }, FIRST_CHECK_DELAY_MS) + } + if (tickTimer === null) { + tickTimer = setInterval(() => { void tick() }, TICK_MS) + } + } catch (error) { + addRuntimeEvent('error', 'webdav', 'backup service failed to start', { error: String(error) }) + } +} diff --git a/client/src/features/settings/AIProofreadToggle.tsx b/client/src/features/settings/AIProofreadToggle.tsx index 79d0799..d7cb85f 100644 --- a/client/src/features/settings/AIProofreadToggle.tsx +++ b/client/src/features/settings/AIProofreadToggle.tsx @@ -4,7 +4,7 @@ import { Card, CardContent } from '@/components/ui/card' import { Switch } from '@/components/ui/switch' import { useAiEnabled } from '@/hooks/useAiEnabled' import { toggleAiEnabled } from '@/stores/aiEnabled' -import { useLocale, useT } from '@/i18n/useT' +import { useT } from '@/i18n/useT' import { ComboShortcutInput } from './ShortcutInputs' import { useEffect, useState } from 'react' import { getPresetShortcuts, getSetting, setSetting } from '@/services/store' @@ -15,7 +15,6 @@ import { MAX_RECORDING_SEC } from '@/services/recorder/types' export default function AIProofreadToggle() { const t = useT() - const locale = useLocale() const aiEnabled = useAiEnabled() const [shortcut, setShortcut] = useState('') const [minDurationSec, setMinDurationSec] = useState(0) @@ -62,14 +61,17 @@ export default function AIProofreadToggle() { void saveMinDuration(next) } - // 数字本身不如「实际会怎样」容易理解。默认值保留为可直接编辑的 0, - // 旁边同步说明 0 / 非 0 各自的效果,用户无需先记住规则再决定要不要改。 + // 数字本身不如「实际会怎样」容易理解,所以把生效结果做成紧跟标签的小徽标。 + // 原先它单独占一行右对齐,既不贴标签也不贴输入框,还得靠手算的 margin 才对得齐 + // (中英各一套分支)——挪到标签后面之后那些魔法数就不需要了。 const minDurationEffect = minDurationSec === 0 ? t('aiProofread.minDurationDefaultHint') : t('aiProofread.minDurationActiveHint', { seconds: minDurationSec }) - // 中文“始终整理”足够短,固定在 64px 输入框正下方居中;英文文案更长, - // 保持右对齐以避免越出卡片。填写数值后的状态说明始终使用右对齐。 - const centerDefaultChineseEffect = minDurationSec === 0 && locale === 'zh-CN' + // 只有非默认值才上主色:默认的「始终整理」是常态,不该抢注意力; + // 用户真的设了门槛才值得被看见(与 ServerSection 的「未保存」徽标同一套逻辑)。 + const effectToneClass = minDurationSec === 0 + ? 'bg-muted text-muted-foreground' + : 'bg-primary/10 text-primary' return ( @@ -101,11 +103,32 @@ export default function AIProofreadToggle() { label={t('aiProofread.shortcutLabel')} description={t('aiProofread.shortcutDesc')} /> + {/* 这一项只在 AI 整理开启时才有意义。关闭时不能只是留着让人填 —— + 原来照样可编辑,用户填完一个数字,它却根本不参与判断,界面还一声不响。 + 所以关闭时真的 disabled(读屏也能听到),并把说明换成「不生效」的原因。 + 上面的快捷键那行不跟着灰:它正是用来把 AI 整理打开的手段。 */}
-

{t('aiProofread.minDurationLabel')}

-

{t('aiProofread.minDurationDesc')}

+
+

+ {t('aiProofread.minDurationLabel')} +

+ {aiEnabled && ( + + {minDurationEffect} + + )} +
+

+ {aiEnabled ? t('aiProofread.minDurationDesc') : t('aiProofread.minDurationDisabled')} +

-
-

- {minDurationEffect} -

-
diff --git a/client/src/features/settings/AIProviderSection.tsx b/client/src/features/settings/AIProviderSection.tsx index f61daf7..c1e3c3c 100644 --- a/client/src/features/settings/AIProviderSection.tsx +++ b/client/src/features/settings/AIProviderSection.tsx @@ -29,10 +29,15 @@ import { Button } from '@/components/ui/button' import { Feedback, FormatHint, type FeedbackTone } from '@/components/ui/feedback' import { Modal } from '@/components/ui/modal' import { PasswordInput } from '@/components/ui/password-input' +import { Switch } from '@/components/ui/switch' import { Tooltip } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import { getSetting } from '@/services/store' -import { getWorkMode } from '@/services/transcription' +import { getSetting, setSetting } from '@/services/store' +import { + SERVER_AI_SOURCE_KEY, + setRuntimeServerAiSource, + type ServerAiSource, +} from '@/services/transcription/serverAiSource' import { setEngineDraftDirty } from '@/stores/engineDraft' import { describeProviderError } from '@/lib/errorMessages' import { @@ -248,8 +253,6 @@ function describeStatus(profile: AiProfile, checking: boolean): CardStatus { // 卡上标签的含义由标签自己和它的 tooltip 承担。 const pickAdvice = () => t('ai.pickAdvice') -const serverModeHint = () => t('ai.serverModeHint') - const inputClass = 'h-9 w-full rounded-md border border-input-border bg-input-bg px-3 text-sm transition-colors focus:border-input-focus-border' const selectClass = 'h-9 w-full rounded-md border border-input-border bg-input-bg px-2 text-sm transition-colors focus:border-input-focus-border' const linkClass = 'inline-flex items-center gap-1 text-xs text-primary underline underline-offset-2 decoration-primary/40 transition-colors hover:decoration-primary' @@ -279,16 +282,17 @@ export default function AIProviderSection() { const [checkingId, setCheckingId] = useState('') const [pendingDeleteId, setPendingDeleteId] = useState('') const [notice, setNotice] = useState(null) - const [workMode, setWorkMode] = useState(getWorkMode) + const [serverAiSource, setServerAiSource] = useState('managed') + const [savingServerAiSource, setSavingServerAiSource] = useState(false) + const [serverAiSourceError, setServerAiSourceError] = useState(false) + /** 工作模式与来源都读回后才渲染那一段(见 useEffect 里的注释) */ + const [serverAiSourceLoaded, setServerAiSourceLoaded] = useState(false) + const [isServerMode, setIsServerMode] = useState(false) /** 「测试全部」的进度;null = 没在批量测试 */ const [batch, setBatch] = useState<{ done: number; total: number } | null>(null) /** 批量测试是并发的,所以「正在测哪个」是一组而不是一个 */ const [checkingIds, setCheckingIds] = useState([]) - // 服务器模式下本页配置不参与实际请求。曾经整页禁用过,但那样太粗暴:想提前配好、 - // 或者只是想测一下自己的 key 通不通的人被一起挡住了。现在只提醒、不阻止 —— - // 提醒由标题旁那枚 warning 徽标承担(点它有完整说明),交互一概照常。 - const configIgnored = workMode === 'server' const draftProvider = findProvider(draft?.provider ?? '') const draftNeedsKey = !draftProvider.keyless const draftUrlError = draft ? checkApiUrl(draft.apiUrl) : '' @@ -308,22 +312,55 @@ export default function AIProviderSection() { const pendingDelete = profiles.find((p) => p.id === pendingDeleteId) ?? null useEffect(() => { + let cancelled = false void (async () => { const state = await loadAiProfiles() setProfiles(state.profiles) setActiveId(state.activeId) setLoaded(true) })() - void getSetting('workMode', 'server').then((value) => { - const mode = value as string - if (mode === 'server' || mode === 'cloud_api' || mode === 'local') setWorkMode(mode as typeof workMode) + // 两个值一起等(Promise.all + 各自 catch 兜底),到齐后同一批渲染。 + // 分两次落值会出两种假象:默认是服务器模式,本地/云模式的用户会看见这一段先出现再消失; + // 来源默认 managed,已保存 custom 的用户会看见开关自己滑一下(pitfalls #11)。 + // 只在值到齐后才首次渲染,两者都不存在,也就不需要 ready/animate 那套过渡开关。 + void Promise.all([ + getSetting('workMode', 'server').catch(() => 'server'), + getSetting(SERVER_AI_SOURCE_KEY, 'managed').catch(() => 'managed'), + ]).then(([mode, value]) => { + if (cancelled) return + const source = value === 'custom' ? 'custom' : 'managed' + setServerAiSource(source) + setRuntimeServerAiSource(source) + setIsServerMode(mode === 'server') + setServerAiSourceLoaded(true) }) // 切走路由时复位「有未保存改动」,别把脏状态留给下一次进入 - return () => setEngineDraftDirty(false) + return () => { + cancelled = true + setEngineDraftDirty(false) + } }, []) useEffect(() => { setEngineDraftDirty(draftDirty) }, [draftDirty]) + async function handleServerAiSource(next: ServerAiSource) { + if (savingServerAiSource || next === serverAiSource) return + const previous = serverAiSource + setServerAiSource(next) + setRuntimeServerAiSource(next) + setSavingServerAiSource(true) + setServerAiSourceError(false) + try { + await setSetting(SERVER_AI_SOURCE_KEY, next) + } catch { + setServerAiSource(previous) + setRuntimeServerAiSource(previous) + setServerAiSourceError(true) + } finally { + setSavingServerAiSource(false) + } + } + /** 唯一的写入点:列表 + 启用项一起落盘,并由 store 同步运行时那四个扁平键 */ async function persist(nextProfiles: AiProfile[], nextActiveId: string) { const active = resolveActiveProfile(nextProfiles, nextActiveId) @@ -835,23 +872,50 @@ export default function AIProviderSection() { return ( - {/* 卡头留在 fieldset 外面:整块禁用时,说明"为什么禁用"的那行字必须照常可读 */} + {/* 只在服务器模式出现:本地/云 API 模式下 AI 整理本来就走下面选中的服务,没有第二个 + 选项可选,摆一个恒定生效的开关只会让人以为它还管着别的事。 + 形态用设置页里那 8 处同构的开关行 —— 分段控件在本应用表示页面/短枚举切换, + 而两个短选项撑成等宽卡会留一大片空白,还会和下方真正的服务卡争层级。 + 说明文字跟着开关状态走,直接说“当前由谁整理”,比让用户从「关」反推更省事。 */} + {serverAiSourceLoaded && isServerMode && ( +
+
+
+
+ {/* 字号与图标间距都对齐下面的「AI 服务」标题:两者是同一层级的两块内容, + 标题小一号会让它看着像附属于下面那块 */} +

+ {t('ai.serverSource.title')} +

+ + + +
+

+ {t(serverAiSource === 'custom' ? 'ai.serverSource.descCustom' : 'ai.serverSource.descManaged')} +

+
+ void handleServerAiSource(serverAiSource === 'custom' ? 'managed' : 'custom')} + labelledBy="server-ai-source-heading" + disabled={savingServerAiSource} + /> +
+ + {serverAiSourceError && ( + + )} +
+ )} +

{t('ai.title')}

- {/* 徽标沿用工作模式卡上「待配置」那一款(同样的形状和 warning 配色), - 比正文亮一档 —— 服务器模式下它是本页最需要被读到的一行。 - 完整解释挂在徽标自己的 tooltip 上,而不是塞进右边的 ⓘ: - 「为什么不生效」和「选哪家」是两件事,各自有各自的入口,谁也别挤占谁。 */} - {configIgnored && ( - - - - {t('ai.serverModeBadge')} - - - )} @@ -887,9 +951,7 @@ export default function AIProviderSection() {
- {/* fieldset 只当分组容器留着。服务器模式下不再禁用它:能照常新增、切换、测试, - 配好了等切到云 API / 本地模式就直接生效。为什么不生效由上面那枚徽标说明, - 而不是把整页涂灰 —— 涂灰会把"想提前配"和"想测 key"的人一起挡在外面。 */} + {/* 自定义服务始终可编辑和测试;服务器模式是否实际使用由上方来源选择决定。 */}
{!loaded ? (

{t('ai.loading')}

diff --git a/client/src/features/settings/BackupSection.tsx b/client/src/features/settings/BackupSection.tsx index 5da4f8a..61fd5c2 100644 --- a/client/src/features/settings/BackupSection.tsx +++ b/client/src/features/settings/BackupSection.tsx @@ -22,6 +22,7 @@ import { type ConfigExportSelection, type ConfigImportPreview, } from '@/services/backup' +import { formatBytes } from '@/lib/utils' import type { TranslationKey } from '@/i18n' import { useT } from '@/i18n/useT' @@ -42,18 +43,6 @@ const phaseLabelKeys: Record = { failed: 'backup.status.failed', } -function formatBytes(value: number): string { - if (!Number.isFinite(value) || value <= 0) return '0 B' - const units = ['B', 'KB', 'MB', 'GB'] - let size = value - let unit = 0 - while (size >= 1024 && unit < units.length - 1) { - size /= 1024 - unit += 1 - } - return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}` -} - function SavedPath({ label, path, diff --git a/client/src/features/settings/CloudAPISection.tsx b/client/src/features/settings/CloudAPISection.tsx index ca9dc63..01100f6 100644 --- a/client/src/features/settings/CloudAPISection.tsx +++ b/client/src/features/settings/CloudAPISection.tsx @@ -567,6 +567,7 @@ export default function CloudAPISection() { const status = describeCard(profile) const siblings = profiles.filter((p) => p.provider === profile.provider).length const title = profileTitle(profile, siblings) + const availability = asrAvailabilityLabel(entry) return (

- {entry.blurb} {asrAvailabilityLabel(entry)} + {entry.blurb} + {/* 地区提示多数供应商为空(见 asrAvailabilityLabel),条件渲染避免留一个空 span 和多余空格 */} + {availability !== '' && <> {availability}}

) @@ -657,6 +660,7 @@ export default function CloudAPISection() { if (!draft) return null const platformInfo = ASR_PLATFORMS[draftPlatform] const draftEntry = findAsrProvider(draft.provider) + const draftAvailability = draftEntry ? asrAvailabilityLabel(draftEntry) : '' const keyLabel = draftIsDoubao ? doubaoKeyLabel(draft.console) : 'API Key' return (

- {draftEntry?.blurb}{' '} - {draftEntry && ( - {asrAvailabilityLabel(draftEntry)} - )} + {draftEntry?.blurb} + {draftAvailability !== '' && <> {draftAvailability}}

diff --git a/client/src/features/settings/GeneralSettingsPage.tsx b/client/src/features/settings/GeneralSettingsPage.tsx index 30beef1..210103d 100644 --- a/client/src/features/settings/GeneralSettingsPage.tsx +++ b/client/src/features/settings/GeneralSettingsPage.tsx @@ -16,6 +16,7 @@ import { Switch } from '@/components/ui/switch' import { Segmented } from '@/components/ui/segmented' import AppSection from './AppSection' import BackupSection from './BackupSection' +import WebDavSection from './WebDavSection' import MicrophoneSection from './MicrophoneSection' import type { MicVolumeLevel } from './MicrophoneSection' import { ComboShortcutInput, PTTShortcutInput } from './ShortcutInputs' @@ -514,6 +515,8 @@ export default function GeneralSettingsPage() {
+ + ) diff --git a/client/src/features/settings/WebDavSection.tsx b/client/src/features/settings/WebDavSection.tsx new file mode 100644 index 0000000..20e44a6 --- /dev/null +++ b/client/src/features/settings/WebDavSection.tsx @@ -0,0 +1,639 @@ +// WebDAV 自动备份的设置面板。 +// +// 分工:**卡面回答「现在在做什么」,弹窗回答「怎么配」。** +// +// 这张卡片原先把两件事混在一起——一次性配置服务器,和日常查看/触发备份。前者做完 +// 就再也不碰,常驻在设置页上是最主要的噪声来源(地址、账号、密码、坚果云说明、测试 +// 按钮、测试结果,七组元素占了半张卡,而它们一年用一次)。 +// +// 现在: +// - 卡面:一句说明 + 「连到了哪、备份什么、多久一次」一行摘要 + 一个主动作 + 一行状态。 +// 没配置时只有说明和右上角一个按钮 —— 绝大多数用户永远不会开这个功能,默认状态 +// 不该给他们一整面控件。 +// - 弹窗:地址凭证、**备份内容**、频率、保留份数。备份内容必须在这里,因为首次配置 +// 时用户就该看见「会上传哪些东西」并当场选择,而不是点了完成之后才在卡面上发现。 +// 三项(配置/历史/录音)全部列出,配置那一项显示「始终包含」而不是藏起来—— +// 「会同步哪些文件」这个问题要有一个看得见的完整答案。 +// - 录音打开时**就地**出现一行提示,不弹二级确认框:弹窗里再套弹窗是反模式,而且 +// 常驻提示比点一次就消失的确认更有用(只要录音是开着的,代价就一直写在那儿)。 +// +// 功能一个没少,收进弹窗的仍然可达,只是不再和日常操作抢注意力。 +// +// 另外两条与视觉无关但同样刻意的: +// - 档位(含不含历史/录音)只存在设置里,手动备份和定时备份都读设置,界面上勾了 +// 什么就一定是上传的内容。 +// - 录音默认关。录音库能有几个 GB,而网盘有月上传流量额度,默认打开等于替用户做了 +// 一个很贵的决定。 + +import { useEffect, useState } from 'react' +import { CloudUpload, Loader2, RotateCcw, Settings2 } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { Modal } from '@/components/ui/modal' +import { PasswordInput } from '@/components/ui/password-input' +import { Segmented } from '@/components/ui/segmented' +import { Switch } from '@/components/ui/switch' +import { Tooltip } from '@/components/ui/tooltip' +import { FormatHint } from '@/components/ui/feedback' +import { getSetting, setSetting } from '@/services/store' +import { restartApp } from '@/services/backup' +import { + describeWebDavError, + listWebDavBackups, + onWebDavBackupProgress, + restoreWebDavBackup, + testWebDavConnection, + DEFAULT_DAV_URL, + type WebDavEntry, + type WebDavLastResult, + type WebDavProgress, +} from '@/services/webdavBackup' +import { + onWebDavBackupChange, + refreshLastResult, + runBackupNow, +} from '@/features/backup/autoWebdavBackup' +import { formatBytes } from '@/lib/utils' +import type { TranslationKey } from '@/i18n' +import { useT } from '@/i18n/useT' + +const inputClass = + 'h-9 w-full rounded-md border border-input-border bg-input-bg px-3 text-sm transition-colors focus:border-input-focus-border' + +/** + * 卡上图标按钮,与「AI 供应商」服务卡上的那几颗同一套样式。 + * + * 与那边的一个区别:这里**不做 hover 才显形**。那边网格里有三五张卡,图标常显会很吵; + * 这里只有一张卡,藏起来只会让「立即备份」变得找不到。 + */ +const cardIconButtonClass = + 'rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40' + +const phaseLabelKeys: Record = { + preparing: 'backup.status.preparing', + packingData: 'backup.status.packingData', + packingAudio: 'backup.status.packingAudio', + finalizing: 'backup.status.finalizing', + uploading: 'webdav.status.uploading', + verifying: 'webdav.status.verifying', + completed: 'backup.status.completed', + failed: 'backup.status.failed', +} + +const intervalOptions = [ + { value: 24, labelKey: 'webdav.interval.daily' }, + { value: 72, labelKey: 'webdav.interval.every3Days' }, + { value: 168, labelKey: 'webdav.interval.weekly' }, +] as const satisfies readonly { value: number; labelKey: TranslationKey }[] + +/** + * 四种档位各有一个完整句子,而不是把「配置」「历史记录」拼起来。 + * 拼接在中文里勉强能读,换到别的语言就会出「Settings and 历史记录」式的畸形。 + */ +const contentSummaryKeys: Record = { + 'false,false': 'webdav.summary.config', + 'true,false': 'webdav.summary.configHistory', + 'false,true': 'webdav.summary.configAudio', + 'true,true': 'webdav.summary.all', +} + +type Busy = 'test' | 'list' | 'restore' | null + +/** 卡面上只需要认出「连到了哪」。协议头永远是 https,显示它等于八个字符的噪声。 */ +function shortServer(url: string): string { + return url.trim().replace(/^https:\/\//i, '').replace(/\/+$/, '') +} + +/** 摘要行里的时间。完整的 toLocaleString 在 11px 一行里太占地方,年份也没人看。 */ +function shortTime(ms: number): string { + return new Date(ms).toLocaleString(undefined, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +export default function WebDavSection() { + const t = useT() + + // ready 控制「值到位才显示」,animate 控制「隔两帧才允许过渡」。 + // 合成一个标志修不掉自己动一下的问题:揭开的同一帧加回 transition,浏览器会把 + // 「默认值 → 已保存值」真的动画一遍(见 pitfalls 里设置类控件那条)。 + const [ready, setReady] = useState(false) + const [animate, setAnimate] = useState(false) + + const [enabled, setEnabled] = useState(false) + const [url, setUrl] = useState('') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [includeHistory, setIncludeHistory] = useState(false) + const [includeAudio, setIncludeAudio] = useState(false) + const [intervalHours, setIntervalHours] = useState(24) + const [keepCount, setKeepCount] = useState(5) + + const [busy, setBusy] = useState(null) + const [running, setRunning] = useState(false) + const [progress, setProgress] = useState(null) + const [lastResult, setLastResult] = useState(null) + const [testMessage, setTestMessage] = useState('') + const [actionError, setActionError] = useState('') + + const [serverOpen, setServerOpen] = useState(false) + const [restoreList, setRestoreList] = useState(null) + const [restoreTarget, setRestoreTarget] = useState(null) + const [restoreDone, setRestoreDone] = useState(false) + + useEffect(() => { + let cancelled = false + void (async () => { + const [en, u, name, pass, hist, audio, interval, keep] = await Promise.all([ + getSetting('webdav.enabled', false).catch(() => false), + getSetting('webdav.url', '').catch(() => ''), + getSetting('webdav.username', '').catch(() => ''), + getSetting('webdav.password', '').catch(() => ''), + getSetting('webdav.includeHistory', false).catch(() => false), + getSetting('webdav.includeAudio', false).catch(() => false), + getSetting('webdav.intervalHours', 24).catch(() => 24), + getSetting('webdav.keepCount', 5).catch(() => 5), + ]) + if (cancelled) return + setEnabled(en) + // 地址预填坚果云,但**必须同时落库**:Rust 侧备份时是直接读库的,而 getSetting + // 的默认值只活在前端内存里。用户只填了账号密码、没碰过地址栏时,库里就没有这 + // 一行,备份会以「地址为空」失败 —— 而界面上明明显示着地址。 + if (u.trim()) { + setUrl(u) + } else { + setUrl(DEFAULT_DAV_URL) + await setSetting('webdav.url', DEFAULT_DAV_URL).catch(() => undefined) + } + setUsername(name) + setPassword(pass) + setIncludeHistory(hist) + setIncludeAudio(audio) + if (interval === 24 || interval === 72 || interval === 168) setIntervalHours(interval) + if (keep === 3 || keep === 5 || keep === 10) setKeepCount(keep) + setLastResult(await refreshLastResult()) + setReady(true) + // 隔两帧再允许过渡:第一帧用来把已保存值画上去。 + requestAnimationFrame(() => requestAnimationFrame(() => { if (!cancelled) setAnimate(true) })) + })() + return () => { cancelled = true } + }, []) + + useEffect(() => { + const unlisten = onWebDavBackupProgress(setProgress) + return () => { void unlisten.then((fn) => fn()) } + }, []) + + // 后台定时备份也会改状态,面板要跟着动,否则用户看到的是打开设置那一刻的快照。 + useEffect(() => { + const off = onWebDavBackupChange((state) => { + setRunning(state.running) + setLastResult(state.lastResult) + }) + return off + }, []) + + // 三样都填了就算配好了。弹窗里边打边存,所以这个值也正好是「测试连接」能不能点。 + const configured = Boolean(url.trim() && username.trim() && password) + const isBusy = busy !== null || running + + /** + * 地址栏的就地告警。 + * + * 说明文字里刻意不写「必须是 https」——填对的人不需要被告知规则,填错的人才需要, + * 而那时一句针对性的告警比一条常驻的规则有用。所以这里要覆盖**所有**非 https 的 + * 情况,而不只是 `http://`:只查 http 的话,粘一个裸域名(`dav.jianguoyun.com/dav`) + * 会一路没有提示,直到点测试连接才报错。 + */ + const urlWarning = (() => { + const value = url.trim().toLowerCase() + if (!value) return null + if (value.startsWith('http://')) return t('webdav.error.urlInsecure') + if (!value.startsWith('https://')) return t('webdav.error.urlScheme') + return null + })() + + const patch = async (key: string, value: T, apply: (value: T) => void) => { + apply(value) + await setSetting(key, value) + } + + const closeServerDialog = () => { + setServerOpen(false) + setTestMessage('') + setActionError('') + } + + const handleTest = async () => { + setBusy('test') + setTestMessage('') + setActionError('') + try { + const count = await testWebDavConnection({ url, username, password }) + setTestMessage(t('webdav.testOk', { count })) + } catch (error) { + setActionError(describeWebDavError(error)) + } finally { + setBusy(null) + } + } + + const handleOpenRestore = async () => { + setBusy('list') + setActionError('') + try { + setRestoreList(await listWebDavBackups()) + } catch (error) { + setActionError(describeWebDavError(error)) + } finally { + setBusy(null) + } + } + + const handleRestore = async () => { + if (!restoreTarget) return + const name = restoreTarget.name + setRestoreTarget(null) + setRestoreList(null) + setBusy('restore') + setActionError('') + try { + await restoreWebDavBackup(name) + setRestoreDone(true) + // 与本地导入一致:略作停留让用户看到提示,再重启使更改生效。 + setTimeout(() => { void restartApp() }, 1500) + } catch (error) { + setActionError(describeWebDavError(error)) + setBusy(null) + } + } + + const uploading = progress?.status === 'running' + const percent = Math.max(0, Math.min(100, progress?.percent ?? 0)) + const contentSummary = t(contentSummaryKeys[`${includeHistory},${includeAudio}`]) + const intervalLabel = t( + intervalOptions.find((opt) => opt.value === intervalHours)?.labelKey ?? 'webdav.interval.daily', + ) + + /** + * 状态徽标,写法沿用「AI 供应商」卡片上的那一枚(同样的圆角、字号、配色语汇)。 + * 备份中只显示百分比 —— 阶段名(「正在打包录音文件…」)太长,塞进徽标会把地址挤掉, + * 它落在下面的进度行里。 + */ + const chip = uploading + ? { text: `${Math.round(percent)}%`, box: 'bg-warning/10 text-warning-strong' } + : lastResult?.ok + ? { text: t('webdav.state.ok'), box: 'bg-success/10 text-success-strong' } + : lastResult + ? { text: t('webdav.state.failed'), box: 'bg-destructive/10 text-destructive-strong' } + : { text: t('webdav.state.never'), box: 'bg-muted text-muted-foreground' } + + // 摘要:备份什么 · 多久一次(仅自动备份开启时)· 上次什么时候 · 多大 + const metaParts = [contentSummary] + if (enabled) metaParts.push(intervalLabel) + if (lastResult) metaParts.push(shortTime(lastResult.at)) + if (lastResult?.ok) metaParts.push(formatBytes(lastResult.bytes)) + + return ( + + + {/* items-center 是本页卡片头部的既有写法(音频卡、日志卡都是),右侧控件对着 + 标题+说明整块居中。用 items-start 会让 32px 高的按钮压着 28px 的标题行, + 视觉上往下坠;而且「配置服务器」按钮和「自动备份」开关高度不同, + 顶对齐时两个状态的右侧内容会错开。 */} +
+
+

{t('webdav.title')}

+

{t('webdav.desc')}

+
+ {/* 右上角这一格是这张卡的开关位:没配置时是唯一的入口,配置完就换成自动备份开关。 + 入口按钮单独放在说明下面会显得像一个孤立的动作,和卡片其它内容对不上。 */} +
+ {configured ? ( + <> + + {t('webdav.autoLabel')} + + void patch('webdav.enabled', !enabled, setEnabled)} + noAnimation={!animate} + hidden={!ready} + /> + + ) : ( + + )} +
+
+ + {configured && ( + <> + {/* 备份目标做成一张有边框的小卡,写法沿用「AI 供应商」的服务卡: + 地址 + 状态徽标一行,摘要一行。原先这些是三段没有容器的散排文字, + 夹在标题和按钮之间,读起来像还在写说明,而不是「这是已经配好的那个东西」。 + 自动备份开启时用 border-primary + bg-primary/5 —— 这是本仓库表示 + 「这一份正在被使用」的既有语汇,不额外增加元素。 */} +
+
+ + {shortServer(url)} + + + {chip.text} + +
+ + {/* 三个动作收成卡上的图标 + tooltip,和「AI 供应商」服务卡一致。 + 原先是卡片下面三颗并排的文字按钮:一个实心加两个 ghost,宽窄不齐、 + 又和卡片抢层级,而它们本来就是「对这台服务器做的三件事」,属于这张卡。 */} +
+ + {uploading && progress ? t(phaseLabelKeys[progress.phase]) : metaParts.join(' · ')} + +
+ + + + + + + + + +
+
+ + {uploading && progress && ( +
+
+
+ )} + + {/* 失败原因单独一行:它可能很长,挤进摘要会把前面的信息顶掉。 + 成功和失败都要能看见 —— 少了失败那一半,一个静默失败几个月的 + 备份在界面上和一个正常工作的备份长得一模一样。 */} + {!uploading && lastResult && !lastResult.ok && ( +

+ {describeWebDavError(lastResult.error ?? '')} +

+ )} + + {actionError &&

{actionError}

} +
+ + )} + + + {serverOpen && ( + +
+
+ + void patch('webdav.url', e.target.value, setUrl)} + placeholder={DEFAULT_DAV_URL} + className={inputClass} + /> + {urlWarning + ? + :

{t('webdav.urlHint')}

} +
+ +
+
+ + void patch('webdav.username', e.target.value, setUsername)} + className={inputClass} + /> +
+
+ + void patch('webdav.password', value, setPassword)} + onSubmit={() => void handleTest()} + className={inputClass} + /> +
+
+ + {/* 坚果云是目前验证过的服务商,它那个必踩的点(要用应用密码)值得占一行。 */} +

{t('webdav.jianguoyunHint')}

+
+ + {/* ── 备份内容 ── + 三项全部列出,配置那一项标「始终包含」而不是不显示:「会同步哪些文件」 + 这个问题需要一个看得见的完整答案,而不是从两个开关反推。 */} +
+

{t('webdav.contentTitle')}

+ +
+ {t('webdav.includeConfig')} + {t('webdav.always')} +
+ +
+ + void patch('webdav.includeHistory', !includeHistory, setIncludeHistory)} + noAnimation={!animate} + /> +
+ +
+ + void patch('webdav.includeAudio', !includeAudio, setIncludeAudio)} + noAnimation={!animate} + /> +
+ {/* 就地提示而不是二级确认框:弹窗里再套弹窗是反模式,而且只要录音是开着的, + 代价就应该一直写在那儿,而不是点一次就消失。 */} + {includeAudio && } +
+ + {/* ── 频率与保留 ── */} +
+
+ + ({ value: opt.value, label: t(opt.labelKey) }))} + onChange={(value) => void patch('webdav.intervalHours', value, setIntervalHours)} + className="shrink-0 justify-end" + /> +
+
+
+ + ({ value, label: String(value) }))} + onChange={(value) => void patch('webdav.keepCount', value, setKeepCount)} + className="shrink-0 justify-end" + /> +
+ {/* 「保留份数」本身说不清会不会删东西。每次备份是一个新文件、不覆盖旧的, + 所以必须明说超出的那些会被删掉 —— 用户拿这个数做决定,就得知道代价。 */} +

{t('webdav.keepHint')}

+
+
+ +
+ {/* 测试结果和报错共用左边这一格,两者不会同时有意义。 */} + + {actionError || testMessage} + + + +
+
+ )} + + {restoreList && ( + setRestoreList(null)} panelClassName="w-[460px]"> + {restoreList.length === 0 ? ( +

{t('webdav.restoreEmpty')}

+ ) : ( +
    + {restoreList.map((entry) => ( +
  • + +
  • + ))} +
+ )} +
+ +
+
+ )} + + {restoreTarget && ( + setRestoreTarget(null)} showCloseButton={false}> +

+ {t('webdav.restoreConfirmBody', { name: restoreTarget.name })} +

+
+ + +
+
+ )} + + {restoreDone && ( + undefined} locked showCloseButton={false}> +

{t('webdav.restoreDoneBody')}

+
+ )} + + ) +} diff --git a/client/src/features/settings/__tests__/asrProfileStore.test.ts b/client/src/features/settings/__tests__/asrProfileStore.test.ts index af42c7b..ff4ee5a 100644 --- a/client/src/features/settings/__tests__/asrProfileStore.test.ts +++ b/client/src/features/settings/__tests__/asrProfileStore.test.ts @@ -21,16 +21,16 @@ describe('topUpProfiles', () => { it('一个平台填了密钥,就为该平台下每个服务各建一份', () => { const r = topUpProfiles([], { qwen: creds({ apiKey: 'sk-1' }) }) expect(r.profiles.map((p) => p.provider)).toEqual([ - 'qwen', 'qwen_realtime', 'qwen_omni_35_plus', 'qwen_omni_35_flash', + 'qwen_audio_stream', 'qwen', 'qwen_realtime', 'qwen_omni_35_plus', 'qwen_omni_35_flash', ]) - expect(r.added).toHaveLength(4) + expect(r.added).toHaveLength(5) }) - it('三个平台都有密钥时补出全部 6 个服务', () => { + it('三个平台都有密钥时补出全部 7 个服务', () => { const r = topUpProfiles([], { doubao: creds(), qwen: creds({ apiKey: 'sk-1' }), mimo: creds(), }) - expect(r.profiles).toHaveLength(6) + expect(r.profiles).toHaveLength(7) }) it('没填密钥的平台不建卡 —— 新用户应看到空状态引导,而不是一堆空卡', () => { @@ -41,22 +41,22 @@ describe('topUpProfiles', () => { /** * 真实场景回归:第一版迁移只建了 doubao_v2 / qwen_realtime / mimo 三份, - * 补齐必须把千问平台下另外 3 个加上,且不动已有的那份。 + * 补齐必须把千问平台下其余几个加上,且不动已有的那份。 */ - it('在第一版迁移的结果上补齐千问剩下 3 个', () => { + it('在第一版迁移的结果上补齐千问剩下几个', () => { const existing = [profile('doubao_v2'), profile('qwen_realtime'), profile('mimo')] const r = topUpProfiles(existing, { doubao: creds(), qwen: creds({ apiKey: 'sk-1' }), mimo: creds(), }) - expect(r.added).toEqual(['qwen', 'qwen_omni_35_plus', 'qwen_omni_35_flash']) - expect(r.profiles).toHaveLength(6) + expect(r.added).toEqual(['qwen_audio_stream', 'qwen', 'qwen_omni_35_plus', 'qwen_omni_35_flash']) + expect(r.profiles).toHaveLength(7) expect(r.profiles.slice(0, 3)).toEqual(existing) // 原有三份原样不动 }) it('只补缺的,已有档案原样不动(对已经自己建过卡的用户幂等)', () => { const existing = [profile('qwen', { apiKey: 'MY-OWN' })] const r = topUpProfiles(existing, { qwen: creds({ apiKey: 'sk-1' }) }) - expect(r.profiles).toHaveLength(4) + expect(r.profiles).toHaveLength(5) expect(r.profiles[0]).toBe(existing[0]) // 同一个对象,没被替换 expect(r.profiles.filter((p) => p.provider === 'qwen')).toHaveLength(1) }) @@ -74,7 +74,7 @@ describe('topUpProfiles', () => { */ it('已经自动补过又被删掉的服务不再重建', () => { const r = topUpProfiles([], { qwen: creds({ apiKey: 'sk-1' }) }, ['qwen', 'qwen_omni_35_flash']) - expect(r.added).toEqual(['qwen_realtime', 'qwen_omni_35_plus']) + expect(r.added).toEqual(['qwen_audio_stream', 'qwen_realtime', 'qwen_omni_35_plus']) }) it('豆包的控制台代次与两代密钥都带过去', () => { diff --git a/client/src/features/settings/__tests__/asrProviderCatalog.test.ts b/client/src/features/settings/__tests__/asrProviderCatalog.test.ts index aa3711c..26de6dd 100644 --- a/client/src/features/settings/__tests__/asrProviderCatalog.test.ts +++ b/client/src/features/settings/__tests__/asrProviderCatalog.test.ts @@ -34,14 +34,18 @@ describe('ASR_PROVIDERS 结构性不变量', () => { } }) - // 原来这里断言的是「每一家都是 mainland_china」。那在只有一个取值时看着像不变量, - // 其实只是把当时的事实写死了 —— 加入海外供应商后它拦的不是 bug,是新功能。 - // 真正该守的是:每个 availability 都有对应的展示文案。少一个分支不会报错, - // 只会让卡片上那行地区说明变成空白。 - it('每种地区都有对应的展示文案', () => { + // 这条断言变过两次,记下来免得再绕回去: + // v1「每一家都是 mainland_china」—— 只是把当时的事实写死,加海外供应商时它拦的是新功能; + // v2「每种地区都有展示文案」—— 于是七张卡上各挂一句「面向中国大陆账号」,是纯噪音; + // 现在:国内是默认情形,不给字;只有需要海外账号/网络的才提醒。 + it('只有海外供应商才给地区提示,国内的不占字', () => { for (const p of ASR_PROVIDERS) { - expect(asrAvailabilityLabel(p).trim()).not.toBe('') + const label = asrAvailabilityLabel(p).trim() + if (p.availability === 'global') expect(label).not.toBe('') + else expect(label).toBe('') } + // 至少还有一家是海外的,否则上面那半条断言等于没跑 + expect(ASR_PROVIDERS.some((p) => p.availability === 'global')).toBe(true) }) it('平台都在 ASR_PLATFORMS 里有定义', () => { @@ -56,9 +60,25 @@ describe('ASR_PROVIDERS 结构性不变量', () => { } }) - it('4 个千问变体归到同一个平台', () => { + it('5 个千问变体归到同一个平台', () => { expect(providersOfPlatform('qwen').map((p) => p.id)).toEqual([ - 'qwen', 'qwen_realtime', 'qwen_omni_35_plus', 'qwen_omni_35_flash', + 'qwen_audio_stream', 'qwen', 'qwen_realtime', 'qwen_omni_35_plus', 'qwen_omni_35_flash', + ]) + }) + + /** 清单顺序就是推荐顺序(卡片顺序 + 新建下拉的顺序),前两名钉住 */ + it('推荐顺序:豆包第一,千问 Audio 3.0 第二', () => { + expect(ASR_PROVIDERS.slice(0, 2).map((p) => p.id)).toEqual(['doubao_v2', 'qwen_audio_stream']) + }) + + /** + * 只有 qwen3-asr-flash-realtime 需要业务空间 ID。 + * qwen-audio-3.0-asr-flash-streaming 用通用域名就能跑(实测),加卡时照抄 + * needsWorkspaceId 会让用户以为不填就用不了 —— 这条钉住这个区别。 + */ + it('只有 qwen_realtime 需要业务空间 ID', () => { + expect(ASR_PROVIDERS.filter((p) => p.needsWorkspaceId).map((p) => p.id)).toEqual([ + 'qwen_realtime', ]) }) diff --git a/client/src/features/settings/asrProviderCatalog.ts b/client/src/features/settings/asrProviderCatalog.ts index d27f5b3..86d5853 100644 --- a/client/src/features/settings/asrProviderCatalog.ts +++ b/client/src/features/settings/asrProviderCatalog.ts @@ -62,10 +62,23 @@ export interface AsrProviderEntry { } /** - * 内置服务清单。顺序即卡片顺序:把最常用的放前面。 + * 内置服务清单。顺序即卡片顺序,也是新建时下拉的顺序:把最推荐的放前面。 * * label 刻意不带模型名后缀(模型名单独一行小字),否则卡片标题会被 * 「千问 3.5 Omni Plus(qwen3.5-omni-plus,ASR+AI)」这种括号串撑爆。 + * + * ── blurb 的口径(改文案前先读这段)── + * 每条只回答一件事:**什么时候该选它**,而且七条放在一起要能相互区分。 + * 早期的文案是各写各的优点(「准确率高、速度快」同时挂在两家上),等于没给出 + * 选择依据 —— 用户看完还是不知道该点哪一张。现在的口径: + * · 准确率的结论是**有条件的**,条件要写出来。豆包只有在**关掉实时字幕**时 + * 才是中文最准的一档,一开字幕就掉档;Audio 3.0 反过来,开着字幕也是第一档。 + * 少了这个前提,两张卡的「最准」会互相矛盾。 + * · 不如别家的地方照实写(千问 realtime 不及 Audio 3.0、MiMo 准确率一般), + * 但用词留余地 —— 目录里留着它们是因为有人的账号/网络只有那一家能用。 + * · 引用别的卡一律用**产品名**("Audio 3.0"、"Omni Plus"),不要用「上面那个」 + * 「同上」:顺序会变,位置指代改一次顺序就错。 + * · 这些准确率结论来自实际使用对比,不是跑分。改结论前先真用一遍。 */ export const ASR_PROVIDERS: AsrProviderEntry[] = [ { @@ -76,6 +89,19 @@ export const ASR_PROVIDERS: AsrProviderEntry[] = [ availability: 'mainland_china', get blurb() { return t('asrProvider.doubaoBlurb') }, }, + // 第二位(豆包之后):实测**开着实时字幕**时它比豆包更准 —— 豆包最准的用法是 + // 关掉实时字幕,一开就掉一档;这一家开着字幕也是第一档。它也不需要业务空间 ID + // (通用域名可用,见 providers/asr_qwen_audio_stream.rs 的文件头), + // 所以是"填了密钥就能用"的那张卡。 + { + id: 'qwen_audio_stream', + get label() { return t('asrProvider.qwenAudioStream') }, + model: 'qwen-audio-3.0-asr-flash-streaming', + platform: 'qwen', + availability: 'mainland_china', + get blurb() { return t('asrProvider.qwenAudioStreamBlurb') }, + streaming: true, + }, { id: 'qwen', get label() { return t('asrProvider.qwen') }, @@ -134,9 +160,16 @@ export function findAsrProvider(id: string): AsrProviderEntry | undefined { return ASR_PROVIDERS.find((p) => p.id === id) } +/** + * 地区提示,**只在需要提醒时才给字**。 + * + * `mainland_china` 刻意返回空串:内置清单里绝大多数都是国内端点,那是默认情形, + * 每张卡上都挂一句「面向中国大陆账号」等于把噪音重复七遍,还把真正要紧的 + * 「这个要海外网络才连得上」冲淡了。availability 字段本身保留 —— 它是数据, + * 只是这一条不值得占界面上的字。 + */ export function asrAvailabilityLabel(entry: AsrProviderEntry): string { switch (entry.availability) { - case 'mainland_china': return t('asrProvider.regionMainlandChina') case 'global': return t('asrProvider.regionGlobal') default: return '' } diff --git a/client/src/index.css b/client/src/index.css index 52a0591..2eacfda 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -138,21 +138,99 @@ } } -/* Overlay waiting dot pulse */ -@keyframes dot-pulse { +/* Surface 单独入场,内容根节点不重挂载,避免整层透明度闪变。 */ +@keyframes overlay-pill-surface-enter { + from { + opacity: 0.82; + transform: scale(0.94); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +/* 中性准备圆点只表达“按键已响应、正在准备”,不暗示音频已经开始采集。 */ +@keyframes overlay-preparing-dot-pulse { 0%, 100% { - opacity: 0.2; - transform: scale(1); + opacity: 0.32; + transform: scale(0.86); } 50% { - opacity: 0.8; - transform: scale(1.8); + opacity: 0.82; + transform: scale(1); } } +.overlay-pill-surface { + clip-path: inset(0 round 9999px); + transition: clip-path 280ms cubic-bezier(0.16, 1, 0.3, 1); + will-change: clip-path; +} + +.overlay-pill-surface-enter { + animation: overlay-pill-surface-enter 120ms cubic-bezier(0.16, 1, 0.3, 1) both; + transform-origin: center; +} + +.overlay-pill-recording-waiting .overlay-pill-surface { + clip-path: inset(0 calc(50% - 24px) 0 calc(50% - 24px) round 9999px); +} + +.overlay-recording-stage { + display: grid; + align-items: center; +} + +.overlay-preparing-indicator, +.overlay-recording-content { + grid-area: 1 / 1; +} + +.overlay-preparing-indicator { + display: flex; + height: 20px; + align-items: center; + justify-self: center; + gap: 3px; + opacity: 1; + transform: scale(1); + transition: + opacity 90ms ease-out, + transform 120ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.overlay-preparing-dot { + width: 3px; + height: 3px; + background: var(--overlay-text-dim); + animation: overlay-preparing-dot-pulse 800ms ease-in-out infinite; + transform-origin: center; +} + +.overlay-recording-content { + opacity: 0; + clip-path: inset(0 calc(50% - 24px) 0 calc(50% - 24px)); + transition: + opacity 180ms ease-out 35ms, + clip-path 280ms cubic-bezier(0.16, 1, 0.3, 1); + will-change: clip-path, opacity; +} + +.overlay-pill-recording-listening .overlay-preparing-indicator { + opacity: 0; + transform: scale(0.78); +} + +.overlay-pill-recording-listening .overlay-recording-content { + opacity: 1; + clip-path: inset(0); +} + /* 流式字幕的文字光标:只改透明度、不缩放不移动,柔和的打字机竖线 */ @keyframes caret-blink { diff --git a/client/src/lib/asrModels.ts b/client/src/lib/asrModels.ts index b5f8d8e..6558082 100644 --- a/client/src/lib/asrModels.ts +++ b/client/src/lib/asrModels.ts @@ -24,6 +24,8 @@ export function resolveQwenOmniModel(provider: string): string | undefined { const ASR_DISPLAY_MODEL_MAP: Record = { doubao_v2: 'Doubao-Seed-ASR-2.0', qwen: 'qwen3-asr-flash', + qwen_realtime: 'qwen3-asr-flash-realtime', + qwen_audio_stream: 'qwen-audio-3.0-asr-flash-streaming', mimo: 'mimo-v2.5-asr', groq_whisper: 'whisper-large-v3-turbo', ...QWEN_OMNI_MODEL_MAP, @@ -35,19 +37,26 @@ export function resolveAsrDisplayModel(providerKey: string): string { } /** 判断某个 ASR 供应商是否为「可能支持」流式实时显示的模型(不含运行时前置条件)。 - * 注意:qwen3-asr-flash(qwen)是非实时 HTTP 模型,不支持;只有 qwen3-asr-flash-realtime 支持。 */ + * 注意:qwen3-asr-flash(qwen)是非实时 HTTP 模型,不支持;千问这边只有 + * qwen3-asr-flash-realtime 与 qwen-audio-3.0-asr-flash-streaming 支持。 */ export function isStreamingDisplayCapable(provider: string): boolean { - return provider === 'doubao_v2' || provider === 'qwen_realtime' + return provider === 'doubao_v2' + || provider === 'qwen_realtime' + || provider === 'qwen_audio_stream' } /** * 判断当前配置下「流式实时显示」是否真正就绪可用(含运行时前置条件)。 * - doubao_v2:直接可用(需在火山开通「流式语音识别 2.0」,运行时由服务端校验)。 + * - qwen_audio_stream(qwen-audio-3.0-asr-flash-streaming):直接可用。这个模型 + * **不需要 WorkspaceId** —— 通用域名 dashscope.aliyuncs.com 实测可用, + * 别照抄下面那条的前置条件(见 providers/asr_qwen_audio_stream.rs 文件头的实测记录)。 * - qwen_realtime(qwen3-asr-flash-realtime):走地域专属实时端点,必须提供北京业务空间 WorkspaceId 才可用。 * - qwen(qwen3-asr-flash):非实时模型,不支持实时字幕。 */ export function isStreamingDisplayReady(provider: string, qwenWorkspaceId?: string): boolean { if (provider === 'doubao_v2') return true + if (provider === 'qwen_audio_stream') return true if (provider === 'qwen_realtime') { return Boolean(qwenWorkspaceId && qwenWorkspaceId.trim()) } diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts index d084cca..6fe5f55 100644 --- a/client/src/lib/utils.ts +++ b/client/src/lib/utils.ts @@ -4,3 +4,21 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** + * 字节数转人类可读大小。 + * + * 小于 10 且不是 B 时保留一位小数("1.5 MB" 比 "2 MB" 有信息量),其余取整 —— + * 备份大小是给人估算「够不够网盘空间」的,不需要三位小数。 + */ +export function formatBytes(value: number): string { + if (!Number.isFinite(value) || value <= 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB'] + let size = value + let unit = 0 + while (size >= 1024 && unit < units.length - 1) { + size /= 1024 + unit += 1 + } + return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}` +} diff --git a/client/src/pages/Dictionary.tsx b/client/src/pages/Dictionary.tsx index 862fae9..94f9828 100644 --- a/client/src/pages/Dictionary.tsx +++ b/client/src/pages/Dictionary.tsx @@ -196,6 +196,7 @@ export default function Dictionary() { {/* 模型名是品牌 + 型号,两种语言下都用官方写法,只有"支持/说明"两列走翻译 */} Doubao Seed-ASR 2.0{t('dict.support.yes')}{t('dict.note.doubao')} + {t('dict.model.qwenAudio30')}{t('dict.support.yes')}{t('dict.note.qwenAudio30')} {t('dict.model.qwenRealtime')}{t('dict.support.yes')}{t('dict.note.qwenRealtime')} {t('dict.model.qwenFlash')}{t('dict.support.yes')}{t('dict.note.qwenFlash')} {t('dict.model.localQwen')}{t('dict.support.notYet')}{t('dict.note.localQwen')} diff --git a/client/src/pages/History.tsx b/client/src/pages/History.tsx index 03ba4ce..eb9f7b3 100644 --- a/client/src/pages/History.tsx +++ b/client/src/pages/History.tsx @@ -3,7 +3,9 @@ import { cn } from '@/lib/utils' import { resolveAsrDisplayModel, isQwenOmniProvider, resolveQwenOmniModel } from '@/lib/asrModels' import { uint8ArrayToBase64 } from '@/lib/encoding' import { getWorkMode } from '@/services/transcription' -import type { WorkMode } from '@/services/transcription' +import { polishWithClientAi } from '@/services/transcription/clientAiPolish' +import { SERVER_AI_SOURCE_KEY } from '@/services/transcription/serverAiSource' +import type { AiExecutionSource, AiExecutionStatus, WorkMode } from '@/services/transcription' import { useCallback, useEffect, useRef, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { Download, Search, Check, FolderOpen } from 'lucide-react' @@ -47,6 +49,10 @@ interface ReprocessResult { durationSec: number asrEngine?: string asrModel?: string + aiSource?: AiExecutionSource + aiStatus?: AiExecutionStatus + aiProvider?: string + aiModel?: string } /** 服务器模式重新识别:通过独立 WebSocket 连接,避免干扰全局连接 */ @@ -59,8 +65,17 @@ async function reprocessViaServer( ): Promise { const { getWSUrl } = await import('@/services/runtimeConfig') const wsUrl = getWSUrl() + const [aiSource, rawAiMinDurationSec] = await Promise.all([ + getSetting(SERVER_AI_SOURCE_KEY, 'managed') as Promise, + getSetting('aiMinDurationSec', 0), + ]) + const audioDurationSec = (chunk.byteLength / 2) / 16000 + const aiMinDurationSec = Math.max(0, Number(rawAiMinDurationSec) || 0) + const skipAiForDuration = aiEnabled && aiMinDurationSec > 0 && audioDurationSec < aiMinDurationSec + const useCustomAi = aiEnabled && aiSource === 'custom' + const useManagedAi = aiEnabled && !useCustomAi && !skipAiForDuration - return new Promise((resolve, reject) => { + const serverResult = await new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { try { socket.close() } catch { /* ignore */ } reject(new Error('Retranscription timed out')) @@ -75,9 +90,9 @@ async function reprocessViaServer( const startMsg: Record = { cmd: 'start', source: 'history_reprocess', - disable_ai: !aiEnabled, + disable_ai: !useManagedAi, } - if (aiEnabled && systemPrompt) startMsg.system_prompt = systemPrompt + if (useManagedAi && systemPrompt) startMsg.system_prompt = systemPrompt if (clientMeta) { startMsg.client_meta = { user_id: clientMeta.userId, @@ -154,6 +169,41 @@ async function reprocessViaServer( } } }) + + if (!serverResult.asrText.trim()) { + return { + ...serverResult, + aiSource: 'none', + aiStatus: 'skipped', + aiProvider: undefined, + aiModel: undefined, + } + } + + if (!useCustomAi) { + return { + ...serverResult, + aiSource: useManagedAi ? 'server' : 'none', + aiStatus: useManagedAi + ? serverResult.llmMs > 0 ? 'applied' : 'unavailable' + : 'skipped', + aiProvider: useManagedAi ? 'server' : undefined, + } + } + + const polished = await polishWithClientAi({ + asrText: serverResult.asrText, + durationSec: audioDurationSec, + startOptions: { + runId: 1, + systemPrompt, + disableAi: !aiEnabled, + aiMinDurationSec, + source: 'history_reprocess', + }, + logSource: 'history', + }) + return polished ? { ...serverResult, ...polished } : serverResult } /** 云 API 模式重新识别:调用 cloud_transcribe + 可选 cloud_polish,与 CloudAPIProvider 一致 */ @@ -278,7 +328,13 @@ async function reprocessViaLocal( async function buildReprocessMetadata( workMode: WorkMode, result: ReprocessResult, -): Promise<{ asrProvider?: string; aiProvider?: string; aiModel?: string }> { +): Promise<{ + asrProvider?: string + aiProvider?: string + aiModel?: string + aiSource?: AiExecutionSource + aiStatus?: AiExecutionStatus +}> { if (workMode === 'cloud_api') { const asrProviderKey = await getSetting('cloudAsr.provider', '') as string const aiProvider = await getSetting('cloudAi.provider', '') as string @@ -299,7 +355,14 @@ async function buildReprocessMetadata( // server return { asrProvider: (result.asrModel || result.asrEngine || 'server').replace(/^.*\//, ''), - aiProvider: 'server', + aiProvider: result.aiSource === 'custom' + ? result.aiProvider + : result.aiSource === 'none' + ? undefined + : 'server', + aiModel: result.aiModel, + aiSource: result.aiSource, + aiStatus: result.aiStatus, } } @@ -462,9 +525,10 @@ export default function History() { result = await reprocessViaServer(chunk, hotwords, Boolean(aiEnabled), systemPrompt, clientMeta) } - // llmText === asrText 说明这段没经过 AI 整理(极速模式,或后端未跑 LLM 直接回 asrText)。 - // 纯 ASR 时「格式规范」由我们兜底;AI 整理过的文本把格式交给 AI,只做文本替换(见 applyTextTransforms) - const rawAsr = !result.llmText || result.llmText === result.asrText + // 新链路优先采用显式执行状态;旧的云/本地历史重跑尚未返回状态时才兼容文本比较。 + const rawAsr = result.aiStatus + ? result.aiStatus !== 'applied' + : !result.llmText || result.llmText === result.asrText const baseText = rawAsr ? result.asrText : result.llmText const replacedLlm = await applyTextTransforms(baseText, { rawAsr }) @@ -478,6 +542,8 @@ export default function History() { charCount: (result.llmText || result.asrText).length, isEmpty: !(result.llmText || result.asrText).trim(), workMode, + aiSource: meta.aiSource, + aiStatus: meta.aiStatus, aiProvider: meta.aiProvider, aiModel: meta.aiModel, asrProvider: meta.asrProvider, diff --git a/client/src/services/backup.ts b/client/src/services/backup.ts index c6b78fd..848fd19 100644 --- a/client/src/services/backup.ts +++ b/client/src/services/backup.ts @@ -85,9 +85,17 @@ export async function exportConfigFile(selection: ConfigExportSelection): Promis return { canceled: false, filePath: path } } -/** 导出全部数据(配置 + 历史 + 录音)为 zip,直接写入默认备份目录。 */ +/** + * 本地「全部数据」导出:配置 + 历史 + 录音,直接写入默认备份目录。 + * + * scope 显式传全 true —— Rust 侧刻意不给这两个字段 serde 默认值,漏传就反序列化失败。 + * 宁可报一条看得见的错,也不要让「默认全都要」在 WebDAV 自动备份那边变成 + * 每天悄悄上传几个 GB 音频。 + */ export async function exportFullFile(): Promise { - const path = await invoke('export_full') + const path = await invoke('export_full', { + scope: { includeHistory: true, includeAudio: true }, + }) return { canceled: false, filePath: path } } diff --git a/client/src/services/defaults.ts b/client/src/services/defaults.ts index 430fe71..0862ca0 100644 --- a/client/src/services/defaults.ts +++ b/client/src/services/defaults.ts @@ -68,7 +68,9 @@ export const DEFAULTS: Record = { 'cloudAi.profilesMigrated': false, // ── ASR(云 API)── - 'cloudAsr.provider': 'doubao_v2', // 可选: 'doubao_v2' | 'qwen' | 'qwen_realtime' | 'qwen_omni' | 'qwen_omni_turbo' | 'mimo' + // 可选值以 features/settings/asrProviderCatalog.ts 的 ASR_PROVIDERS 为准: + // 'doubao_v2' | 'qwen' | 'qwen_audio_stream' | 'qwen_realtime' | 'qwen_omni_35_*' | 'mimo' | 'groq_whisper' + 'cloudAsr.provider': 'doubao_v2', // 运行时读的「本次生效凭据」镜像。豆包按控制台代次算出来后写进这两个键: // 新版控制台只有一个 API Key、appId 必为空串(Rust 侧靠它区分两代鉴权头)。 'cloudAsr.apiKey': '', @@ -107,6 +109,8 @@ export const DEFAULTS: Record = { // 映射成 Chinese / English;'auto' = 交给模型自检。 // (注释原来写的是 'Chinese' | 'English' | 'Cantonese',那是服务端内部的取值, // 客户端从来没写过这几个字符串。) + // 服务器模式下的 AI 来源。managed = 服务器内置;custom = 服务器只做 ASR,客户端调用当前 AI 档案。 + 'server.aiSource': 'managed', // 可选: 'managed' | 'custom' 'server.language': 'auto', // ── 悬浮窗 ── @@ -138,6 +142,26 @@ export const DEFAULTS: Record = { audioRetentionDays: -1, // 录音保留天数。可选: 7 | 30 | 90 | -1(永久) logRetentionDays: 30, // 日志保留天数。可选: 7 | 15 | 30 | 90 + // ── WebDAV 备份 ── + // 备份包一定含配置,历史与录音可选。两个都默认 false:录音库能有几个 GB,而网盘 + // (尤其坚果云免费版)有月流量额度,默认打开等于替用户做了一个很贵的决定。 + // ⚠️ Rust 侧 commands/webdav.rs 里也有一份默认值(setting_bool 的 unwrap_or(false) + // 与 DEFAULT_KEEP_COUNT),改这里要一起改 —— 两份不一致时界面显示的和实际上传的 + // 就不是一回事了。 + 'webdav.enabled': false, // 自动定期备份。可选: true | false + 'webdav.url': '', // 目录地址,只接受 https(Basic 认证等于明文发密码) + 'webdav.username': '', + 'webdav.password': '', // 坚果云要用「应用密码」,不是登录密码 + 'webdav.includeHistory': false, // 备份含历史记录 + 'webdav.includeAudio': false, // 备份含录音文件(体积大,打开时会提醒) + 'webdav.intervalHours': 24, // 备份间隔小时数。可选: 24 | 72 | 168 + 'webdav.keepCount': 5, // 服务器上保留的份数。可选: 3 | 5 | 10 + 'webdav.lastBackupAt': 0, // 上次**成功**的时刻(ms)。间隔判定只看这个 + 'webdav.lastAttemptAt': 0, // 上次尝试的时刻(ms),失败退避用 + // 上次备份结果 { at, ok, fileName, bytes, includeHistory, includeAudio, error }。 + // 失败也记:静默失败几个月的备份在界面上和正常备份长得一模一样。 + 'webdav.lastResult': null, + // ── 文本后处理(不依赖 AI 的客户端文本规范化)── textPostProcess: { autoSegment: true, // 智能分段(仅极速模式生效),默认开启 diff --git a/client/src/services/store.ts b/client/src/services/store.ts index c702eb4..ca3fb93 100644 --- a/client/src/services/store.ts +++ b/client/src/services/store.ts @@ -68,6 +68,8 @@ export interface HistoryRecord { asrProvider?: string // 例如 "server" / "doubao" / "sensevoice-small" aiProvider?: string // 例如 "server" / "openai_compat" / "ollama" aiModel?: string // 例如 "deepseek-chat" / "qwen2.5:7b" + aiSource?: 'server' | 'custom' | 'none' + aiStatus?: 'applied' | 'skipped' | 'unavailable' | 'failed' } export type HistoryFailReasonCode = diff --git a/client/src/services/transcription/CloudAPIProvider.ts b/client/src/services/transcription/CloudAPIProvider.ts index ac281f2..b54db01 100644 --- a/client/src/services/transcription/CloudAPIProvider.ts +++ b/client/src/services/transcription/CloudAPIProvider.ts @@ -9,6 +9,7 @@ import { listen } from '@tauri-apps/api/event' import { getSetting } from '../store' import { restoreHotwordSpacing } from '../textPostProcess' import { addRuntimeEvent } from '../debugLog' +import { polishWithClientAi } from './clientAiPolish' import type { TranscriptionProvider, TranscriptionCallbacks, @@ -17,14 +18,6 @@ import type { WorkMode, } from './types' -interface AiProviderConfig { - provider: string - api_url: string - api_key: string - model: string - extra?: Record -} - interface AsrProviderConfig { provider: string api_key: string @@ -33,7 +26,39 @@ interface AsrProviderConfig { } interface AsrResult { text: string; elapsed_ms: number } -interface AiResult { text: string; elapsed_ms: number } + +/** + * 千问两个流式模型各有一组原生命令。 + * + * 它们是**两套 WebSocket 协议**,不是同一个接口的两个模型名: + * · qwen_realtime → OpenAI-Realtime 风格(/api-ws/v1/realtime) + * · qwen_audio_stream → DashScope duplex run-task(/api-ws/v1/inference) + * Rust 侧因此各有一份实现(asr_qwen_realtime.rs / asr_qwen_audio_stream.rs), + * 这里要记住本次会话用的是哪一组,收尾时才不会去 finish 另一条没开的会话。 + */ +const QWEN_STREAM_COMMANDS = { + qwen_realtime: { + open: 'qwen_stream_open', + send: 'qwen_stream_send', + finish: 'qwen_stream_finish', + close: 'qwen_stream_close', + }, + qwen_audio_stream: { + open: 'qwen_audio_stream_open', + send: 'qwen_audio_stream_send', + finish: 'qwen_audio_stream_finish', + close: 'qwen_audio_stream_close', + }, +} as const + +type QwenStreamProvider = keyof typeof QWEN_STREAM_COMMANDS +type QwenStreamCommands = typeof QWEN_STREAM_COMMANDS[QwenStreamProvider] +type NativeOpenCommand = 'doubao_stream_open' | QwenStreamCommands['open'] +type NativeFinishCommand = 'doubao_stream_finish' | QwenStreamCommands['finish'] + +function isQwenStreamProvider(provider: string): provider is QwenStreamProvider { + return provider === 'qwen_realtime' || provider === 'qwen_audio_stream' +} export class CloudAPIProvider implements TranscriptionProvider { readonly mode: WorkMode = 'cloud_api' @@ -50,6 +75,8 @@ export class CloudAPIProvider implements TranscriptionProvider { private isQwenStream = false private doubaoStreamReady = false private qwenStreamReady = false + /** 本次会话用的千问命令组;没走千问流式时为 null */ + private qwenCommands: QwenStreamCommands | null = null private streamStartTime = 0 private pendingChunks: ArrayBuffer[] = [] private flushTimer: ReturnType | null = null @@ -91,6 +118,7 @@ export class CloudAPIProvider implements TranscriptionProvider { this.isQwenStream = false this.doubaoStreamReady = false this.qwenStreamReady = false + this.qwenCommands = null this.streamStartTime = performance.now() this.pendingChunks = [] this.streamFinishing = false @@ -129,6 +157,7 @@ export class CloudAPIProvider implements TranscriptionProvider { this.isQwenStream = false this.doubaoStreamReady = false this.qwenStreamReady = false + this.qwenCommands = null if (this.flushTimer) { clearInterval(this.flushTimer); this.flushTimer = null } this.teardownPartials() void this.queueNativeClose(sendLockToDrain) @@ -143,14 +172,16 @@ export class CloudAPIProvider implements TranscriptionProvider { private queueNativeClose(sendLockToDrain: Promise): Promise { return this.enqueueNativeLifecycle(async () => { await sendLockToDrain.catch(() => { }) + // 逐个关:每个 close 在没有会话时都是无操作,所以不需要先判断开了哪一个。 await invoke('doubao_stream_close').catch(() => { }) await invoke('qwen_stream_close').catch(() => { }) + await invoke('qwen_audio_stream_close').catch(() => { }) }) } private invokeNativeOpen( runId: number, - command: 'doubao_stream_open' | 'qwen_stream_open', + command: NativeOpenCommand, args: Record, ): Promise { return this.enqueueNativeLifecycle(async () => { @@ -168,7 +199,7 @@ export class CloudAPIProvider implements TranscriptionProvider { private invokeNativeFinish( runId: number, - command: 'doubao_stream_finish' | 'qwen_stream_finish', + command: NativeFinishCommand, ): Promise { return this.enqueueNativeLifecycle(async () => { if (!this.isRunCurrent(runId)) return undefined @@ -194,7 +225,7 @@ export class CloudAPIProvider implements TranscriptionProvider { partialCount++ // 只记录第一条,避免刷屏;证明前端确实收到了 Rust 上抛的中间结果 if (partialCount === 1) { - addRuntimeEvent('info', 'cloud_api', 'First streaming partial received', { textLen: text.length }) + addRuntimeEvent('info', 'cloud_api', 'First streaming partial received', { textLen: text.length }) } this.callbacks.onPartialASR?.(text) }) @@ -203,7 +234,7 @@ export class CloudAPIProvider implements TranscriptionProvider { return } this.partialUnlisten = unlisten - addRuntimeEvent('info', 'cloud_api', 'Subscribed to asr-partial events') + addRuntimeEvent('info', 'cloud_api', 'Subscribed to asr-partial events') } private teardownPartials(): void { @@ -249,6 +280,7 @@ export class CloudAPIProvider implements TranscriptionProvider { this.isQwenStream = false this.doubaoStreamReady = false this.qwenStreamReady = false + this.qwenCommands = null this.teardownPartials() if (this.flushTimer) { clearInterval(this.flushTimer); this.flushTimer = null } void this.queueNativeClose(sendLockToDrain) @@ -281,7 +313,7 @@ export class CloudAPIProvider implements TranscriptionProvider { const settingOn = Boolean(await getSetting('streamingDisplayEnabled', false)) if (!this.isRunCurrent(runId)) return const realtime = (settingOn || Boolean(startOpts.streamingDisplay)) && isStreamingDisplayReady(asrProvider, qwenWorkspaceId) - addRuntimeEvent('info', 'cloud_api', 'Streaming display decision', { asrProvider, settingOn, startOpt: Boolean(startOpts.streamingDisplay), hasWorkspace: Boolean(qwenWorkspaceId), realtime, runId }) + addRuntimeEvent('info', 'cloud_api', 'Streaming display decision', { asrProvider, settingOn, startOpt: Boolean(startOpts.streamingDisplay), hasWorkspace: Boolean(qwenWorkspaceId), realtime, runId }) if (realtime) { // 先订阅中间结果,避免建连后、订阅前丢帧 await this.subscribePartials(runId) @@ -305,23 +337,26 @@ export class CloudAPIProvider implements TranscriptionProvider { if (!opened || !this.isRunCurrent(runId)) return this.doubaoStreamReady = true addRuntimeEvent('info', 'cloud_api', 'Doubao streaming: ready') - } else if (asrProvider === 'qwen_realtime' && realtime) { - // 千问实时(qwen3-asr-flash-realtime):仅在开启实时显示且配置了 WorkspaceId 时才走流式 WebSocket。 - // qwen3-asr-flash(非实时)与未配置/未开启时都不进此分支,走下面的一次性识别。 + } else if (isQwenStreamProvider(asrProvider) && realtime) { + // 千问流式:只在开启实时显示、且这份配置真的就绪时才走流式 WebSocket。 + // · qwen_realtime 需要 WorkspaceId(isStreamingDisplayReady 已把关) + // · qwen_audio_stream 不需要,填了密钥就能用 + // qwen3-asr-flash(非实时)与未开启实时显示时都不进此分支,走下面的一次性识别。 this.isQwenStream = true + this.qwenCommands = QWEN_STREAM_COMMANDS[asrProvider] const asrApiKey = await getSetting('cloudAsr.apiKey', '') as string if (!this.isRunCurrent(runId)) return - addRuntimeEvent('info', 'cloud_api', 'Qwen Realtime: connecting', { hasWorkspace: Boolean(qwenWorkspaceId) }) - const opened = await this.invokeNativeOpen(runId, 'qwen_stream_open', { - config: { provider: 'qwen', api_key: asrApiKey, app_id: '' }, + addRuntimeEvent('info', 'cloud_api', 'Qwen streaming: connecting', { asrProvider, hasWorkspace: Boolean(qwenWorkspaceId) }) + const opened = await this.invokeNativeOpen(runId, this.qwenCommands.open, { + config: { provider: asrProvider, api_key: asrApiKey, app_id: '' }, hotwords: startOpts.hotwords ?? [], realtime, workspaceId: qwenWorkspaceId, }) if (!opened || !this.isRunCurrent(runId)) return this.qwenStreamReady = true - addRuntimeEvent('info', 'cloud_api', 'Qwen Realtime: ready') + addRuntimeEvent('info', 'cloud_api', 'Qwen streaming: ready', { asrProvider }) } else { // 其他情况(含未配置 WorkspaceId 的千问)走录完再发的一次性识别 this.teardownPartials() @@ -347,6 +382,7 @@ export class CloudAPIProvider implements TranscriptionProvider { this.isQwenStream = false this.doubaoStreamReady = false this.qwenStreamReady = false + this.qwenCommands = null this.teardownPartials() } } @@ -372,11 +408,11 @@ export class CloudAPIProvider implements TranscriptionProvider { try { if (this.isDoubaoStream) { await invoke('doubao_stream_send', { pcmB64: b64 }) - } else if (this.isQwenStream) { - await invoke('qwen_stream_send', { pcmB64: b64 }) + } else if (this.isQwenStream && this.qwenCommands) { + await invoke(this.qwenCommands.send, { pcmB64: b64 }) } } catch (err) { - addRuntimeEvent('warn', 'cloud_api', 'Streaming send failed', { error: String(err) }) + addRuntimeEvent('warn', 'cloud_api', 'Streaming send failed', { error: String(err) }) } } // 接到发送链尾部,串行执行(无论前一个成功或失败都继续) @@ -404,7 +440,7 @@ export class CloudAPIProvider implements TranscriptionProvider { const durationSec = (totalBytes / 2) / 16000 if (durationSec < 0.3) { - addRuntimeEvent('info', 'cloud_api', 'Audio too short; skipped processing', { durationSec }) + addRuntimeEvent('info', 'cloud_api', 'Audio too short; skipped processing', { durationSec }) this.teardownPartials() this.callbacks.onDone?.() return @@ -428,21 +464,25 @@ export class CloudAPIProvider implements TranscriptionProvider { if (!this.isRunCurrent(runId)) return if (this.isDoubaoStream) { - addRuntimeEvent('info', 'cloud_api', 'Doubao streaming: sending finish') + addRuntimeEvent('info', 'cloud_api', 'Doubao streaming: sending finish') const finishStart = performance.now() const text = await this.invokeNativeFinish(runId, 'doubao_stream_finish') if (text === undefined || !this.isRunCurrent(runId)) return asrText = text asrMs = Math.round(performance.now() - finishStart) - addRuntimeEvent('info', 'cloud_api', 'Doubao streaming: recognition complete', { asrMs, textLen: asrText.length }) + addRuntimeEvent('info', 'cloud_api', 'Doubao streaming: recognition complete', { asrMs, textLen: asrText.length }) } else { - addRuntimeEvent('info', 'cloud_api', 'Qwen streaming: sending finish') + // 命令组在 qwenStreamReady 之前就已赋值,这里只是让类型收窄; + // 真为空就抛出去,而不是悄悄返回空文本(空结果会被显示成「没听到声音」)。 + const commands = this.qwenCommands + if (!commands) throw new Error('Qwen streaming session has no native command set') + addRuntimeEvent('info', 'cloud_api', 'Qwen streaming: sending finish', { command: commands.finish }) const finishStart = performance.now() - const text = await this.invokeNativeFinish(runId, 'qwen_stream_finish') + const text = await this.invokeNativeFinish(runId, commands.finish) if (text === undefined || !this.isRunCurrent(runId)) return asrText = text asrMs = Math.round(performance.now() - finishStart) - addRuntimeEvent('info', 'cloud_api', 'Qwen streaming: recognition complete', { asrMs, textLen: asrText.length }) + addRuntimeEvent('info', 'cloud_api', 'Qwen streaming: recognition complete', { asrMs, textLen: asrText.length }) } } else { // 非豆包 / 豆包建连失败:录完再发 @@ -475,7 +515,7 @@ export class CloudAPIProvider implements TranscriptionProvider { }), } - addRuntimeEvent('info', 'cloud_api', 'ASR started', { provider: asrProvider, durationSec }) + addRuntimeEvent('info', 'cloud_api', 'ASR started', { provider: asrProvider, durationSec }) const asrResult = await invoke('cloud_transcribe', { request: { audio_b64: audioB64, @@ -506,71 +546,39 @@ export class CloudAPIProvider implements TranscriptionProvider { } // AI 校对(Qwen Omni 已内置 AI,跳过) - let llmText = asrText - let llmMs = 0 - let aiSucceeded = false - - const disableAi = startOpts.disableAi ?? false - const aiMinDurationSec = Math.max(0, Number(startOpts.aiMinDurationSec) || 0) - const shouldUseAi = !disableAi && (aiMinDurationSec === 0 || durationSec >= aiMinDurationSec) - if (shouldUseAi && !isQwenOmni) { - const aiProvider = await getSetting('cloudAi.provider', 'openai_compat') as string - const aiApiUrl = await getSetting('cloudAi.apiUrl', '') as string - const aiApiKey = await getSetting('cloudAi.apiKey', '') as string - const aiModel = await getSetting('cloudAi.model', '') as string - if (!this.isRunCurrent(runId)) return - - // Ollama 是免密钥的本机服务:要求非空密钥会让配好的 Ollama 被静默跳过 - // (测试连接通过、口述出来的文字却没被整理)。LocalProvider 一直是这个判断。 - if (aiApiUrl && (aiApiKey || aiProvider === 'ollama') && aiModel) { - const aiConfig: AiProviderConfig = { - provider: aiProvider, api_url: aiApiUrl, api_key: aiApiKey, model: aiModel, - } - addRuntimeEvent('info', 'cloud_api', 'AI cleanup started', { provider: aiProvider, model: aiModel }) - - try { - const aiResult = await invoke('cloud_polish', { - request: { - text: asrText, - ai_config: aiConfig, - system_prompt: startOpts.systemPrompt || null, - text_context: startOpts.textContext || null, - }, - }) - if (!this.isRunCurrent(runId)) return - llmText = aiResult.text || asrText - llmMs = aiResult.elapsed_ms - aiSucceeded = Boolean(aiResult.text) - } catch (err) { - if (!this.isRunCurrent(runId)) return - addRuntimeEvent('warn', 'cloud_api', 'AI cleanup failed; using raw ASR text', { error: String(err) }) - } + const polish = isQwenOmni + ? { + llmText: startOpts.textContext?.selectedText || asrText, + llmMs: 0, + contextApplied: startOpts.textContext ? false : undefined, + aiSource: 'none' as const, + aiStatus: 'skipped' as const, } - } - - if (!disableAi && aiMinDurationSec > 0 && durationSec < aiMinDurationSec) { - addRuntimeEvent('info', 'cloud_api', 'AI cleanup skipped below duration threshold', { + : await polishWithClientAi({ + asrText, durationSec, - aiMinDurationSec, + startOptions: startOpts, + logSource: 'cloud_api', + isCurrent: () => this.isRunCurrent(runId), }) - } + if (!polish || !this.isRunCurrent(runId)) return - // A spoken edit command must never replace the user's selection when AI was unavailable. - // Re-inserting the original selection is a safe no-op for the target editor. - if (startOpts.textContext?.selectedText && !aiSucceeded) { - llmText = startOpts.textContext.selectedText - addRuntimeEvent('warn', 'cloud_api', 'Selected-text edit skipped because AI did not complete') - } - - if (!this.isRunCurrent(runId)) return const totalMs = Math.round(performance.now() - startTime) - addRuntimeEvent('info', 'cloud_api', 'Processing complete', { durationSec, asrMs, llmMs, totalMs, runId }) + addRuntimeEvent('info', 'cloud_api', 'Processing complete', { + durationSec, + asrMs, + llmMs: polish.llmMs, + totalMs, + runId, + }) const omniModel = isQwenOmni ? resolveQwenOmniModel(asrProvider) : undefined this.callbacks.onFinal?.({ - asrText, llmText, asrMs, llmMs, durationSec, - contextApplied: startOpts.textContext ? aiSucceeded : undefined, + asrText, + asrMs, + durationSec, + ...polish, ...(isQwenOmni && { asrEngine: 'qwen_omni', asrModel: omniModel }), }) this.callbacks.onDone?.() diff --git a/client/src/services/transcription/LocalProvider.ts b/client/src/services/transcription/LocalProvider.ts index 84badc2..8a76791 100644 --- a/client/src/services/transcription/LocalProvider.ts +++ b/client/src/services/transcription/LocalProvider.ts @@ -6,10 +6,9 @@ import { invoke } from '@tauri-apps/api/core' import { getSetting } from '../store' import { addRuntimeEvent } from '../debugLog' import { BufferedProvider } from './BufferedProvider' +import { polishWithClientAi } from './clientAiPolish' import type { TranscriptionCallbacks } from './types' -interface AiResult { text: string; elapsed_ms: number } - export class LocalProvider extends BufferedProvider { readonly mode = 'local' as const @@ -104,63 +103,29 @@ export class LocalProvider extends BufferedProvider { return } - let llmText = asrText - let llmMs = 0 - let aiSucceeded = false + const polish = await polishWithClientAi({ + asrText, + durationSec, + startOptions: startOpts, + logSource: 'local', + isCurrent: () => this.isRunCurrent(runId), + }) + if (!polish || !this.isRunCurrent(runId)) return - const aiEnabled = await getSetting('aiEnabled', false) - if (!this.isRunCurrent(runId)) return - const disableAi = startOpts?.disableAi ?? false - const aiMinDurationSec = Math.max(0, Number(startOpts?.aiMinDurationSec) || 0) - const shouldUseAi = !disableAi && (aiMinDurationSec === 0 || durationSec >= aiMinDurationSec) - - if (aiEnabled && shouldUseAi) { - const aiProvider = await getSetting('cloudAi.provider', 'openai_compat') as string - const aiApiUrl = await getSetting('cloudAi.apiUrl', '') as string - const aiApiKey = await getSetting('cloudAi.apiKey', '') as string - const aiModel = await getSetting('cloudAi.model', '') as string - if (!this.isRunCurrent(runId)) return - - if (aiApiUrl && (aiApiKey || aiProvider === 'ollama')) { - try { - const aiResult = await invoke('cloud_polish', { - request: { - text: asrText, - ai_config: { provider: aiProvider, api_url: aiApiUrl, api_key: aiApiKey, model: aiModel }, - system_prompt: startOpts?.systemPrompt || null, - text_context: startOpts?.textContext || null, - }, - }) - if (!this.isRunCurrent(runId)) return - llmText = aiResult.text || asrText - llmMs = aiResult.elapsed_ms - aiSucceeded = Boolean(aiResult.text) - } catch (err) { - if (!this.isRunCurrent(runId)) return - addRuntimeEvent('warn', 'local', 'AI cleanup failed; using raw ASR text', { error: String(err) }) - } - } - } - - if (aiEnabled && !disableAi && aiMinDurationSec > 0 && durationSec < aiMinDurationSec) { - addRuntimeEvent('info', 'local', 'AI cleanup skipped below duration threshold', { - durationSec, - aiMinDurationSec, - }) - } - - if (startOpts?.textContext?.selectedText && !aiSucceeded) { - llmText = startOpts.textContext.selectedText - addRuntimeEvent('warn', 'local', 'Selected-text edit skipped because AI did not complete') - } - - if (!this.isRunCurrent(runId)) return const totalMs = Math.round(performance.now() - startTime) - addRuntimeEvent('info', 'local', 'Processing complete', { durationSec, asrMs, llmMs, totalMs, runId }) + addRuntimeEvent('info', 'local', 'Processing complete', { + durationSec, + asrMs, + llmMs: polish.llmMs, + totalMs, + runId, + }) this.callbacks.onFinal?.({ - asrText, llmText, asrMs, llmMs, durationSec, - contextApplied: startOpts?.textContext ? aiSucceeded : undefined, + asrText, + asrMs, + durationSec, + ...polish, }) this.callbacks.onDone?.() } diff --git a/client/src/services/transcription/ServerProvider.ts b/client/src/services/transcription/ServerProvider.ts index e47cc83..a9d4c6b 100644 --- a/client/src/services/transcription/ServerProvider.ts +++ b/client/src/services/transcription/ServerProvider.ts @@ -1,8 +1,16 @@ -// 服务器模式 Provider — 封装现有 WebSocket 逻辑 -// 行为与原 websocket.ts 完全一致,只是包装为 TranscriptionProvider 接口 +// 服务器模式 Provider:服务器负责 ASR,AI 可由服务器或客户端自定义供应商完成。 +import { getSetting } from '../store' +import { addRuntimeEvent } from '../debugLog' import * as ws from '../websocket' +import { polishWithClientAi } from './clientAiPolish' +import { + getRuntimeServerAiSource, + loadServerAiSource, + type ServerAiSource, +} from './serverAiSource' import type { + FinalResult, TranscriptionProvider, TranscriptionCallbacks, StartOptions, @@ -14,9 +22,28 @@ export class ServerProvider implements TranscriptionProvider { private callbacks: TranscriptionCallbacks = {} private activeRunId = 0 + private activeStartOpts: Readonly | undefined + private aiSource: ServerAiSource = 'managed' + private customAiReady = false + private customFinalPending = false + private serverDonePending = false async connect(callbacks: TranscriptionCallbacks): Promise { this.callbacks = callbacks + const [storedSource, customProvider, customUrl, customKey, customModel] = await Promise.all([ + loadServerAiSource(), + getSetting('cloudAi.provider', '') as Promise, + getSetting('cloudAi.apiUrl', '') as Promise, + getSetting('cloudAi.apiKey', '') as Promise, + getSetting('cloudAi.model', '') as Promise, + ]) + this.aiSource = storedSource === 'custom' ? 'custom' : 'managed' + this.customAiReady = Boolean( + customUrl.trim() + && customModel.trim() + && (customKey.trim() || customProvider === 'ollama'), + ) + await ws.connect({ onStateChange: (state) => { callbacks.onStateChange?.(state) @@ -25,19 +52,32 @@ export class ServerProvider implements TranscriptionProvider { callbacks.onReady?.({ connectionId: data.connectionId, asr: data.asr, - llm: data.llm, + // 自定义 AI 在本机执行;服务端 llm=false 不应把整条模式标成不可用。 + llm: this.aiSource === 'custom' ? this.customAiReady : data.llm, }) }, onASR: (result) => { if (this.activeRunId === 0) return + const isEmpty = !result.text.trim() callbacks.onASR?.({ text: result.text, asrMs: result.asrMs, durationSec: result.durationSec, + // 空识别不会执行任何 AI;它会在录音器的专用空结果分支先于 final 落库。 + aiSource: isEmpty ? 'none' : undefined, + aiStatus: isEmpty ? 'skipped' : undefined, }) }, onFinal: (result) => { - if (this.activeRunId === 0) return + const runId = this.activeRunId + if (runId === 0) return + if (this.aiSource === 'custom') { + this.customFinalPending = true + void this.handleCustomFinal(runId, result) + return + } + + const aiDisabled = this.activeStartOpts?.disableAi ?? false callbacks.onFinal?.({ asrText: result.asrText, llmText: result.llmText, @@ -47,32 +87,58 @@ export class ServerProvider implements TranscriptionProvider { asrEngine: result.asrEngine, asrModel: result.asrModel, contextApplied: result.contextApplied, + aiSource: aiDisabled ? 'none' : 'server', + aiStatus: aiDisabled ? 'skipped' : result.llmMs > 0 ? 'applied' : 'unavailable', + aiProvider: aiDisabled ? undefined : 'server', }) }, onDone: () => { const runId = this.activeRunId if (runId === 0) return - callbacks.onDone?.() - if (this.activeRunId === runId) this.activeRunId = 0 + if (this.customFinalPending) { + // 服务端 final 后会立刻发 done;客户端 AI 尚未完成时必须暂存,避免录音器先收尾。 + this.serverDonePending = true + return + } + this.finishRun(runId) }, onError: (msg) => { const runId = this.activeRunId if (runId === 0) return callbacks.onError?.(msg) - if (this.activeRunId === runId) this.activeRunId = 0 + if (this.activeRunId === runId) this.resetRun() }, }) } start(opts: StartOptions): boolean { + // 设置页可在不切换工作模式的情况下改变来源;每次 start 都同步取运行时真值。 + this.aiSource = getRuntimeServerAiSource() this.activeRunId = opts.runId - const started = ws.sendStart(opts) - if (!started) this.activeRunId = 0 + this.activeStartOpts = { + ...opts, + hotwords: opts.hotwords ? [...opts.hotwords] : undefined, + textContext: opts.textContext ? { ...opts.textContext } : undefined, + } + this.customFinalPending = false + this.serverDonePending = false + + const wireOptions = this.aiSource === 'custom' + ? { + ...opts, + // 自定义 AI 由本机调用:服务端只做 ASR,也不需要收到 Prompt 或编辑器正文。 + disableAi: true, + systemPrompt: undefined, + textContext: undefined, + } + : opts + const started = ws.sendStart(wireOptions) + if (!started) this.resetRun() return started } cancel(): void { - this.activeRunId = 0 + this.resetRun() ws.disconnect() } @@ -81,15 +147,58 @@ export class ServerProvider implements TranscriptionProvider { } stop(opts?: StopOptions): boolean { - return ws.sendStop(opts) + if (opts?.disableAi && this.activeStartOpts) { + this.activeStartOpts = { ...this.activeStartOpts, disableAi: true } + } + return ws.sendStop(this.aiSource === 'custom' ? { ...opts, disableAi: true } : opts) } disconnect(): void { - this.activeRunId = 0 + this.resetRun() ws.disconnect() } isReady(): boolean { return ws.isConnected() } + + private async handleCustomFinal(runId: number, result: FinalResult): Promise { + const startOptions = this.activeStartOpts + const polished = await polishWithClientAi({ + asrText: result.asrText, + durationSec: result.durationSec, + startOptions, + logSource: 'server', + isCurrent: () => this.activeRunId === runId, + }) + if (!polished || this.activeRunId !== runId) return + + this.callbacks.onFinal?.({ + ...result, + ...polished, + }) + this.customFinalPending = false + + if (this.serverDonePending) { + this.finishRun(runId) + } + } + + private finishRun(runId: number): void { + if (this.activeRunId !== runId) return + this.callbacks.onDone?.() + if (this.activeRunId === runId) this.resetRun() + } + + private resetRun(): void { + if (this.activeRunId !== 0 && this.customFinalPending) { + addRuntimeEvent('info', 'server', 'Discarded pending custom AI result for stale run', { + runId: this.activeRunId, + }) + } + this.activeRunId = 0 + this.activeStartOpts = undefined + this.customFinalPending = false + this.serverDonePending = false + } } diff --git a/client/src/services/transcription/clientAiPolish.ts b/client/src/services/transcription/clientAiPolish.ts new file mode 100644 index 0000000..ca55bda --- /dev/null +++ b/client/src/services/transcription/clientAiPolish.ts @@ -0,0 +1,192 @@ +import { invoke } from '@tauri-apps/api/core' +import { addRuntimeEvent } from '../debugLog' +import { getSetting } from '../store' +import type { AiExecutionSource, AiExecutionStatus, StartOptions } from './types' + +interface AiResult { + text: string + elapsed_ms: number +} + +const CLIENT_AI_TIMEOUT_MS = 8_000 + +interface ClientAiConfig { + provider: string + apiUrl: string + apiKey: string + model: string +} + +export interface ClientAiPolishResult { + llmText: string + llmMs: number + contextApplied?: boolean + aiSource: AiExecutionSource + aiStatus: AiExecutionStatus + aiProvider?: string + aiModel?: string +} + +interface ClientAiPolishOptions { + asrText: string + durationSec: number + startOptions?: Readonly + logSource: string + /** 异步读设置和原生请求结束后都要验代;false 表示结果已经属于旧录音。 */ + isCurrent?: () => boolean +} + +async function loadClientAiConfig(): Promise { + const [provider, apiUrl, apiKey, model] = await Promise.all([ + getSetting('cloudAi.provider', 'openai_compat') as Promise, + getSetting('cloudAi.apiUrl', '') as Promise, + getSetting('cloudAi.apiKey', '') as Promise, + getSetting('cloudAi.model', '') as Promise, + ]) + return { provider, apiUrl, apiKey, model } +} + +function safeFallbackText(asrText: string, startOptions?: Readonly): string { + // 选区编辑依赖 AI 理解口述命令。AI 没有真正完成时,回填原选区才是安全 no-op; + // 直接回填 ASR 会把“改成更正式”这种命令本身覆盖进正文。 + return startOptions?.textContext?.selectedText || asrText +} + +/** + * 使用当前启用的客户端 AI 档案整理一段 ASR 文本。 + * + * 服务器、云 API、本地三种识别来源共用这一处,避免 Ollama 免密、短语音门槛、 + * 选区编辑回退在三条链路里逐渐漂移。返回 null 表示录音代次已经失效。 + */ +export async function polishWithClientAi( + options: ClientAiPolishOptions, +): Promise { + const { asrText, durationSec, startOptions, logSource } = options + const isCurrent = options.isCurrent ?? (() => true) + const disableAi = startOptions?.disableAi ?? false + const aiMinDurationSec = Math.max(0, Number(startOptions?.aiMinDurationSec) || 0) + + if (!asrText.trim()) { + return { + llmText: safeFallbackText(asrText, startOptions), + llmMs: 0, + contextApplied: startOptions?.textContext ? false : undefined, + aiSource: 'none', + aiStatus: 'skipped', + } + } + + if (disableAi) { + return { + llmText: safeFallbackText(asrText, startOptions), + llmMs: 0, + contextApplied: startOptions?.textContext ? false : undefined, + aiSource: 'none', + aiStatus: 'skipped', + } + } + + if (aiMinDurationSec > 0 && durationSec < aiMinDurationSec) { + addRuntimeEvent('info', logSource, 'AI cleanup skipped below duration threshold', { + durationSec, + aiMinDurationSec, + }) + return { + llmText: safeFallbackText(asrText, startOptions), + llmMs: 0, + contextApplied: startOptions?.textContext ? false : undefined, + aiSource: 'none', + aiStatus: 'skipped', + } + } + + const config = await loadClientAiConfig() + if (!isCurrent()) return null + + const complete = Boolean( + config.apiUrl.trim() + && config.model.trim() + && (config.apiKey.trim() || config.provider === 'ollama'), + ) + if (!complete) { + addRuntimeEvent('warn', logSource, 'Custom AI is selected but its active profile is incomplete', { + provider: config.provider || undefined, + hasUrl: Boolean(config.apiUrl.trim()), + hasKey: Boolean(config.apiKey.trim()) || config.provider === 'ollama', + hasModel: Boolean(config.model.trim()), + }) + return { + llmText: safeFallbackText(asrText, startOptions), + llmMs: 0, + contextApplied: startOptions?.textContext ? false : undefined, + aiSource: 'custom', + aiStatus: 'unavailable', + aiProvider: config.provider || undefined, + aiModel: config.model || undefined, + } + } + + addRuntimeEvent('info', logSource, 'Custom AI cleanup started', { + provider: config.provider, + model: config.model, + }) + + let timeoutId: ReturnType | undefined + try { + const request = invoke('cloud_polish', { + request: { + text: asrText, + ai_config: { + provider: config.provider, + api_url: config.apiUrl, + api_key: config.apiKey, + model: config.model, + }, + system_prompt: startOptions?.systemPrompt || null, + text_context: startOptions?.textContext || null, + }, + }) + const result = await Promise.race([ + request, + new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`Custom AI timed out after ${CLIENT_AI_TIMEOUT_MS}ms`)), + CLIENT_AI_TIMEOUT_MS, + ) + }), + ]) + if (!isCurrent()) return null + + const succeeded = Boolean(result.text) + if (!succeeded) { + addRuntimeEvent('warn', logSource, 'Custom AI returned an empty result; using raw ASR text') + } + return { + llmText: succeeded ? result.text : safeFallbackText(asrText, startOptions), + llmMs: result.elapsed_ms, + contextApplied: startOptions?.textContext ? succeeded : undefined, + aiSource: 'custom', + aiStatus: succeeded ? 'applied' : 'failed', + aiProvider: config.provider, + aiModel: config.model, + } + } catch (error) { + if (!isCurrent()) return null + addRuntimeEvent('warn', logSource, 'Custom AI cleanup failed; using raw ASR text', { + error: String(error), + provider: config.provider, + model: config.model, + }) + return { + llmText: safeFallbackText(asrText, startOptions), + llmMs: 0, + contextApplied: startOptions?.textContext ? false : undefined, + aiSource: 'custom', + aiStatus: 'failed', + aiProvider: config.provider, + aiModel: config.model, + } + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId) + } +} diff --git a/client/src/services/transcription/index.ts b/client/src/services/transcription/index.ts index 22adaaa..b7d4633 100644 --- a/client/src/services/transcription/index.ts +++ b/client/src/services/transcription/index.ts @@ -8,7 +8,18 @@ import { CloudAPIProvider } from './CloudAPIProvider' import { LocalProvider } from './LocalProvider' import type { TranscriptionProvider, WorkMode } from './types' -export type { TranscriptionProvider, TranscriptionCallbacks, StartOptions, StopOptions, FinalResult, ASRResult, WorkMode, ProviderState } from './types' +export type { + TranscriptionProvider, + TranscriptionCallbacks, + StartOptions, + StopOptions, + FinalResult, + ASRResult, + WorkMode, + ProviderState, + AiExecutionSource, + AiExecutionStatus, +} from './types' let currentProvider: TranscriptionProvider | null = null let currentMode: WorkMode = 'server' diff --git a/client/src/services/transcription/serverAiSource.ts b/client/src/services/transcription/serverAiSource.ts new file mode 100644 index 0000000..0eacd86 --- /dev/null +++ b/client/src/services/transcription/serverAiSource.ts @@ -0,0 +1,26 @@ +import { getSetting } from '../store' + +export type ServerAiSource = 'managed' | 'custom' +export const SERVER_AI_SOURCE_KEY = 'server.aiSource' + +let runtimeSource: ServerAiSource = 'managed' + +function normalizeServerAiSource(value: unknown): ServerAiSource { + return value === 'custom' ? 'custom' : 'managed' +} + +/** 从持久化设置刷新运行时值。Provider connect 时调用,保证冷启动与导入配置后正确。 */ +export async function loadServerAiSource(): Promise { + runtimeSource = normalizeServerAiSource(await getSetting(SERVER_AI_SOURCE_KEY, 'managed')) + return runtimeSource +} + +/** start() 必须同步读取这一份,不能依赖上一次 connect 留下的快照。 */ +export function getRuntimeServerAiSource(): ServerAiSource { + return runtimeSource +} + +/** 设置页保存时同步更新,避免同一个 ServerProvider 实例继续按旧来源发送下一次请求。 */ +export function setRuntimeServerAiSource(value: ServerAiSource): void { + runtimeSource = value +} diff --git a/client/src/services/transcription/types.ts b/client/src/services/transcription/types.ts index 0514db3..04fdf03 100644 --- a/client/src/services/transcription/types.ts +++ b/client/src/services/transcription/types.ts @@ -8,10 +8,16 @@ export type WorkMode = 'server' | 'cloud_api' | 'local' export type ProviderState = 'disconnected' | 'connecting' | 'connected' | 'error' +export type AiExecutionSource = 'server' | 'custom' | 'none' +export type AiExecutionStatus = 'applied' | 'skipped' | 'unavailable' | 'failed' + export interface ASRResult { text: string asrMs: number durationSec: number + /** 空 ASR 会先于 final 触发专用收尾,执行元数据必须随它一起落库。 */ + aiSource?: AiExecutionSource + aiStatus?: AiExecutionStatus } export interface FinalResult { @@ -24,6 +30,11 @@ export interface FinalResult { asrModel?: string /** True only when the AI actually received and processed this run's editor context. */ contextApplied?: boolean + /** Explicit execution metadata; text equality cannot prove whether AI ran. */ + aiSource?: AiExecutionSource + aiStatus?: AiExecutionStatus + aiProvider?: string + aiModel?: string } export interface TranscriptionCallbacks { diff --git a/client/src/services/webdavBackup.ts b/client/src/services/webdavBackup.ts new file mode 100644 index 0000000..1347358 --- /dev/null +++ b/client/src/services/webdavBackup.ts @@ -0,0 +1,129 @@ +// WebDAV 备份的前端封装。 +// +// 备份档位(是否含历史/音频)**只存在设置里**,不作为参数传给 Rust:手动「立即备份」 +// 和后台定时备份走同一个命令、读同一份设置,界面上勾了什么就一定是上传的内容。 +// +// 错误处理约定:Rust 对可预期的用户输入/服务器问题返回大写错误码(WEBDAV_*), +// 其余返回带 HTTP 状态码的英文说明。`describeWebDavError` 把前者翻成本地化文案, +// 后者原样透出——那类信息(状态码、响应体片段)才是排查时真正需要的。 + +import { invoke } from '@tauri-apps/api/core' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' +import { t, type TranslationKey } from '@/i18n' + +/** 坚果云的 WebDAV 根地址。 */ +export const JIANGUOYUN_DAV_BASE = 'https://dav.jianguoyun.com/dav' + +/** + * 地址栏的预填值。 + * + * **必须是 `/dav` 下的一个子目录,不能是 `/dav` 本身。** 坚果云的 `/dav` 根只列同步 + * 文件夹,往根上 PUT 文件会被拒 —— 各家 WebDAV 客户端的坚果云配置说明都要求先建 + * 一个一级目录再把地址指向它。目录不存在时我们自己 MKCOL 出来,所以用户不用先去 + * 网页上手动建。 + */ +export const DEFAULT_DAV_URL = `${JIANGUOYUN_DAV_BASE}/SayIt` + +export interface WebDavConfig { + url: string + username: string + password: string +} + +export interface WebDavEntry { + name: string + size: number +} + +export interface WebDavBackupResult { + fileName: string + bytes: number + includeHistory: boolean + includeAudio: boolean + finishedAt: number + /** 本次清理掉的旧备份份数 */ + pruned: number +} + +/** 上次备份的结果。失败也会记 —— 静默失败的备份比没有备份更糟。 */ +export interface WebDavLastResult { + at: number + ok: boolean + fileName: string + bytes: number + includeHistory: boolean + includeAudio: boolean + error: string | null +} + +export interface WebDavProgress { + status: 'running' | 'completed' | 'failed' + phase: + | 'preparing' + | 'packingData' + | 'packingAudio' + | 'finalizing' + | 'uploading' + | 'verifying' + | 'completed' + | 'failed' + fileName: string + currentFile: string | null + processedBytes: number + totalBytes: number + percent: number + error: string | null +} + +/** Rust 侧返回的错误码 → 文案键。没登记的码按原样显示。 */ +const ERROR_KEYS: Record = { + WEBDAV_URL_EMPTY: 'webdav.error.urlEmpty', + WEBDAV_URL_INSECURE: 'webdav.error.urlInsecure', + WEBDAV_URL_SCHEME: 'webdav.error.urlScheme', + WEBDAV_CREDENTIALS_EMPTY: 'webdav.error.credentialsEmpty', + WEBDAV_UNAUTHORIZED: 'webdav.error.unauthorized', + WEBDAV_MKCOL_CONFLICT: 'webdav.error.mkcolConflict', + WEBDAV_VERIFY_MISSING: 'webdav.error.verifyMissing', + WEBDAV_VERIFY_SIZE: 'webdav.error.verifySize', + WEBDAV_EMPTY_DOWNLOAD: 'webdav.error.emptyDownload', + WEBDAV_BAD_NAME: 'webdav.error.badName', +} + +/** + * 把 Rust 抛出的错误变成可以直接显示的一句话。 + * + * invoke 抛出的是字符串本身(Tauri 把 Err(String) 原样传过来),但外面可能已经被 + * 包成 Error,所以两种都要认。 + */ +export function describeWebDavError(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error) + const code = raw.trim() + const key = ERROR_KEYS[code] + return key ? t(key) : raw +} + +export function onWebDavBackupProgress( + handler: (progress: WebDavProgress) => void, +): Promise { + return listen('webdav-backup-progress', (event) => handler(event.payload)) +} + +/** 连接测试。返回服务器上已有的备份份数。 */ +export function testWebDavConnection(config: WebDavConfig): Promise { + return invoke('webdav_test', { config }) +} + +/** 列出服务器上的备份,从新到旧。 */ +export function listWebDavBackups(): Promise { + return invoke('webdav_list') +} + +/** 立即备份一次。档位取自设置。 */ +export function runWebDavBackup(): Promise { + return invoke('webdav_backup_now') +} + +/** 从服务器恢复。完成后需要由调用方触发重启(与本地导入一致)。 */ +export function restoreWebDavBackup(name: string): Promise { + return invoke('webdav_restore', { name }) +} diff --git a/client/src/stores/modeStatus.ts b/client/src/stores/modeStatus.ts index dfdddb0..2a82d1f 100644 --- a/client/src/stores/modeStatus.ts +++ b/client/src/stores/modeStatus.ts @@ -60,6 +60,7 @@ function cloudProviderShort(provider: string): string { case 'doubao_v2': return t('modeStatus.doubao') case 'qwen': return t('modeStatus.qwen') case 'qwen_realtime': return t('modeStatus.qwenRealtime') + case 'qwen_audio_stream': return t('modeStatus.qwenAudioStream') case 'qwen_omni_35_plus': case 'qwen_omni_35_flash': case 'qwen_omni_flash':