feat: 新增千问 Audio 3.0 流式识别、WebDAV 备份、服务器模式本地 AI 整理
三项一起提交是因为它们共用命令注册与供应商目录,拆开任一条都编不过。 千问 Audio 3.0 流式:热词交给模型自带的即时热词,可带权重、上限 100 条, 不再需要业务空间 ID。 WebDAV 备份:配置、历史、音频是同一份 zip 的三个子集,配置为默认项; 选 WebDAV 而不是各家网盘 SDK,是为了让坚果云这类自建入口直接可用。 包体可能有几个 GB,所以走流式 PUT,不整包读进内存。 服务器模式本地 AI 整理:此前 ASR 与 AI 整理绑在一起,若因成本停掉服务器 AI 会连带失去语音识别。拆开来源后服务器只做识别,整理由本机直连用户自己的供应商, API Key、提示词与上下文感知读到的文字都不再经过服务器。默认关闭。
This commit is contained in:
1 parent
bad38dabe5
commit
4015cf80ef
37 files changed
+4127
-373
No files matched your search
Generated
+2
-1
@@ -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",
|
||||
|
||||
@@ -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<u8> 会直接把
|
||||
# 内存打满。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
|
||||
|
||||
@@ -41,6 +41,18 @@ pub struct ConfigExportSelection {
|
||||
prompt_preset_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// 全量备份里除配置之外还装什么。
|
||||
///
|
||||
/// 两个字段都**不给 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<i64, String> {
|
||||
@@ -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<AudioExportFile> {
|
||||
fn collect_audio_export_files(include_audio: bool) -> Vec<AudioExportFile> {
|
||||
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<String, String> {
|
||||
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::<u64>();
|
||||
|
||||
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<String, String> {
|
||||
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![
|
||||
|
||||
@@ -8,4 +8,5 @@ pub mod shortcuts;
|
||||
pub mod storage;
|
||||
pub mod system;
|
||||
pub mod tray;
|
||||
pub mod webdav;
|
||||
pub mod window;
|
||||
@@ -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", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验并规范化目录 URL。
|
||||
///
|
||||
/// 只接受 https:Basic 认证等于把网盘密码随每个请求明文发出去,走 http 就是把它
|
||||
/// 广播到链路上的每一跳。坚果云等服务本身也只提供 https,所以这里不留开关。
|
||||
///
|
||||
/// 返回的错误是**错误码**而不是文案:这几种都是可预期的用户输入问题,交给前端本地化
|
||||
/// 才能给出针对性的提示(比如坚果云要用「应用密码」而不是登录密码)。
|
||||
fn normalize_dir_url(raw: &str) -> Result<String, String> {
|
||||
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<Self, String> {
|
||||
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, String> {
|
||||
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<reqwest::Response, String> {
|
||||
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<reqwest::Response, String> {
|
||||
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<String, String> {
|
||||
let response = request(target, "PROPFIND", url)
|
||||
.header("Depth", "1")
|
||||
.header("Content-Type", "application/xml; charset=utf-8")
|
||||
.timeout(META_TIMEOUT)
|
||||
.body(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<propfind xmlns="DAV:"><prop><getcontentlength/><getlastmodified/><resourcetype/></prop></propfind>"#,
|
||||
)
|
||||
.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<MkcolOutcome, String> {
|
||||
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<String> {
|
||||
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<usize> {
|
||||
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<String> {
|
||||
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<WebDavEntry> {
|
||||
let mut entries: Vec<WebDavEntry> = 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::<u64>().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<String>,
|
||||
processed_bytes: u64,
|
||||
total_bytes: u64,
|
||||
percent: f64,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
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<u64, String> {
|
||||
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<Vec<WebDavEntry>, 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<WebDavBackupResult, String> {
|
||||
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<WebDavBackupResult, String> {
|
||||
// 先校验连接信息再打包:地址填错时没必要先花几分钟压几个 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<String> = 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<u64, String> {
|
||||
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#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/dav/SayIt/</d:href>
|
||||
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav/SayIt/sayit-backup-2026-08-26_10-00-00-000.zip</d:href>
|
||||
<d:propstat><d:prop><d:getcontentlength>4096</d:getcontentlength></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav/SayIt/sayit-backup-2026-08-25_10-00-00-000.zip</d:href>
|
||||
<d:propstat><d:prop><d:getcontentlength>2048</d:getcontentlength></d:prop></d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"#;
|
||||
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#"<D:multistatus xmlns:D="DAV:">
|
||||
<D:response><D:href>/dav/SayIt/sayit-backup-a.zip</D:href>
|
||||
<D:propstat><D:prop><lp1:getcontentlength>11</lp1:getcontentlength></D:prop></D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>"#;
|
||||
let entries = parse_backup_entries(xml);
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].size, 11);
|
||||
|
||||
let no_prefix = r#"<multistatus xmlns="DAV:">
|
||||
<response><href>/dav/SayIt/sayit-backup-b.zip</href>
|
||||
<propstat><prop><getcontentlength>22</getcontentlength></prop></propstat>
|
||||
</response>
|
||||
</multistatus>"#;
|
||||
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#"<d:multistatus xmlns:d="DAV:">
|
||||
<d:response><d:href>/dav/SayIt/</d:href></d:response>
|
||||
<d:response><d:href>/dav/SayIt/notes.txt</d:href></d:response>
|
||||
<d:response><d:href>/dav/SayIt/sayit-config-2026.json</d:href></d:response>
|
||||
<d:response><d:href>/dav/SayIt/sayit-backup-x.zip.part</d:href></d:response>
|
||||
</d:multistatus>"#;
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<tokio::net::TcpStream>,
|
||||
>;
|
||||
type WsSink = SplitSink<WsStream, tungstenite::Message>;
|
||||
|
||||
// ─────────────────────────── 协议编解码 ───────────────────────────
|
||||
|
||||
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<String>,
|
||||
/// 收到过多少条带文本的中间结果,只用于日志
|
||||
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<Task, String> {
|
||||
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::<serde_json::Value>(&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<S>(sink: &mut S, pcm: &[u8], stage: &'static str) -> Result<(), String>
|
||||
where
|
||||
S: Sink<tungstenite::Message, Error = tungstenite::Error> + 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<AsrResult, String> {
|
||||
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::<serde_json::Value>(&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<Arc<Mutex<Option<WsSink>>>> =
|
||||
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None)));
|
||||
static READER: once_cell::sync::Lazy<Arc<Mutex<Option<JoinHandle<()>>>>> =
|
||||
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None)));
|
||||
static STATE: once_cell::sync::Lazy<Arc<Mutex<Transcript>>> =
|
||||
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(Transcript::default())));
|
||||
static TASK_ID: once_cell::sync::Lazy<Arc<Mutex<String>>> =
|
||||
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<Vec<String>>,
|
||||
realtime: Option<bool>,
|
||||
workspace_id: Option<String>,
|
||||
) -> 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<WsStream>,
|
||||
app: AppHandle,
|
||||
state: Arc<Mutex<Transcript>>,
|
||||
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::<serde_json::Value>(&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<String, String> {
|
||||
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<String> = (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(), "");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<AsrResu
|
||||
)
|
||||
.await
|
||||
}
|
||||
// Qwen-Audio-3.0 流式:关掉实时字幕、以及设置页的识别测试都走这条一次性路径。
|
||||
// 用的仍是同一个 duplex 协议,只是整段音频推完再收结果。
|
||||
"qwen_audio_stream" => {
|
||||
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<TestResult
|
||||
"doubao" => 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),
|
||||
|
||||
+10
-8
@@ -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()
|
||||
|
||||
@@ -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<WebDavBackupResult | null> | null = null
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null
|
||||
let firstCheckTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function setState(patch: Partial<WebDavBackupState>) {
|
||||
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<WebDavLastResult | null> {
|
||||
const value = await getSetting<WebDavLastResult | null>('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<WebDavBackupResult | null> {
|
||||
if (inFlight) return inFlight
|
||||
|
||||
const task = (async (): Promise<WebDavBackupResult | null> => {
|
||||
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<boolean> {
|
||||
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> {
|
||||
// 整个函数包一层:调用方是 `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) })
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Card>
|
||||
@@ -101,11 +103,32 @@ export default function AIProofreadToggle() {
|
||||
label={t('aiProofread.shortcutLabel')}
|
||||
description={t('aiProofread.shortcutDesc')}
|
||||
/>
|
||||
{/* 这一项只在 AI 整理开启时才有意义。关闭时不能只是留着让人填 ——
|
||||
原来照样可编辑,用户填完一个数字,它却根本不参与判断,界面还一声不响。
|
||||
所以关闭时真的 disabled(读屏也能听到),并把说明换成「不生效」的原因。
|
||||
上面的快捷键那行不跟着灰:它正是用来把 AI 整理打开的手段。 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{t('aiProofread.minDurationLabel')}</p>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{t('aiProofread.minDurationDesc')}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<p
|
||||
id="ai-min-duration-label"
|
||||
className={`text-sm font-medium ${aiEnabled ? '' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{t('aiProofread.minDurationLabel')}
|
||||
</p>
|
||||
{aiEnabled && (
|
||||
<span
|
||||
id="ai-min-duration-effect"
|
||||
className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${effectToneClass}`}
|
||||
>
|
||||
{minDurationEffect}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p id="ai-min-duration-desc" className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||
{aiEnabled ? t('aiProofread.minDurationDesc') : t('aiProofread.minDurationDisabled')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="mr-2 flex shrink-0 items-center gap-1.5">
|
||||
<input
|
||||
@@ -114,25 +137,18 @@ export default function AIProofreadToggle() {
|
||||
max={MAX_RECORDING_SEC}
|
||||
step="1"
|
||||
value={minDurationSec}
|
||||
disabled={!aiEnabled}
|
||||
placeholder={t('aiProofread.minDurationOff')}
|
||||
onChange={(event) => handleMinDurationChange(event.target.value, event.currentTarget)}
|
||||
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-right text-sm tabular-nums outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||
aria-label={t('aiProofread.minDurationLabel')}
|
||||
aria-describedby="ai-min-duration-effect"
|
||||
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-right text-sm tabular-nums outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-labelledby="ai-min-duration-label"
|
||||
aria-describedby={aiEnabled ? 'ai-min-duration-effect ai-min-duration-desc' : 'ai-min-duration-desc'}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t('aiProofread.seconds')}</span>
|
||||
<span className={`text-sm ${aiEnabled ? 'text-muted-foreground' : 'text-muted-foreground/50'}`}>
|
||||
{t('aiProofread.seconds')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-end">
|
||||
<p
|
||||
id="ai-min-duration-effect"
|
||||
className={`text-xs leading-relaxed text-muted-foreground ${centerDefaultChineseEffect
|
||||
? 'mr-[1.875rem] w-16 text-center'
|
||||
: 'w-fit max-w-full text-right max-w-[22rem]'}`}
|
||||
>
|
||||
{minDurationEffect}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -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<Notice | null>(null)
|
||||
const [workMode, setWorkMode] = useState(getWorkMode)
|
||||
const [serverAiSource, setServerAiSource] = useState<ServerAiSource>('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<string[]>([])
|
||||
|
||||
// 服务器模式下本页配置不参与实际请求。曾经整页禁用过,但那样太粗暴:想提前配好、
|
||||
// 或者只是想测一下自己的 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 (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
{/* 卡头留在 fieldset 外面:整块禁用时,说明"为什么禁用"的那行字必须照常可读 */}
|
||||
{/* 只在服务器模式出现:本地/云 API 模式下 AI 整理本来就走下面选中的服务,没有第二个
|
||||
选项可选,摆一个恒定生效的开关只会让人以为它还管着别的事。
|
||||
形态用设置页里那 8 处同构的开关行 —— 分段控件在本应用表示页面/短枚举切换,
|
||||
而两个短选项撑成等宽卡会留一大片空白,还会和下方真正的服务卡争层级。
|
||||
说明文字跟着开关状态走,直接说“当前由谁整理”,比让用户从「关」反推更省事。 */}
|
||||
{serverAiSourceLoaded && isServerMode && (
|
||||
<section className="mb-4 border-b border-border pb-4" aria-labelledby="server-ai-source-heading">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 字号与图标间距都对齐下面的「AI 服务」标题:两者是同一层级的两块内容,
|
||||
标题小一号会让它看着像附属于下面那块 */}
|
||||
<h2 id="server-ai-source-heading" className="text-lg font-semibold">
|
||||
{t('ai.serverSource.title')}
|
||||
</h2>
|
||||
<Tooltip variant="light" content={t('ai.serverSource.help')}>
|
||||
<Info
|
||||
aria-label={t('settings.helpAria', { label: t('ai.serverSource.title') })}
|
||||
className={helpIconClass}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t(serverAiSource === 'custom' ? 'ai.serverSource.descCustom' : 'ai.serverSource.descManaged')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={serverAiSource === 'custom'}
|
||||
onChange={() => void handleServerAiSource(serverAiSource === 'custom' ? 'managed' : 'custom')}
|
||||
labelledBy="server-ai-source-heading"
|
||||
disabled={savingServerAiSource}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{serverAiSourceError && (
|
||||
<Feedback className="mt-2" tone="error" message={t('ai.err.saveFailed')} />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 grow basis-[18rem]">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 id="ai-service-heading" className="text-lg font-semibold">{t('ai.title')}</h2>
|
||||
{/* 徽标沿用工作模式卡上「待配置」那一款(同样的形状和 warning 配色),
|
||||
比正文亮一档 —— 服务器模式下它是本页最需要被读到的一行。
|
||||
完整解释挂在徽标自己的 tooltip 上,而不是塞进右边的 ⓘ:
|
||||
「为什么不生效」和「选哪家」是两件事,各自有各自的入口,谁也别挤占谁。 */}
|
||||
{configIgnored && (
|
||||
<Tooltip variant="light" content={serverModeHint()}>
|
||||
<span className="inline-flex cursor-help items-center gap-1.5 rounded-full bg-warning/10 px-2.5 py-1 text-xs font-medium text-warning-strong">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" aria-hidden />
|
||||
{t('ai.serverModeBadge')}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip variant="light" content={pickAdvice()}>
|
||||
<Info aria-label={t('ai.helpAria')} className={helpIconClass} />
|
||||
</Tooltip>
|
||||
@@ -887,9 +951,7 @@ export default function AIProviderSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* fieldset 只当分组容器留着。服务器模式下不再禁用它:能照常新增、切换、测试,
|
||||
配好了等切到云 API / 本地模式就直接生效。为什么不生效由上面那枚徽标说明,
|
||||
而不是把整页涂灰 —— 涂灰会把"想提前配"和"想测 key"的人一起挡在外面。 */}
|
||||
{/* 自定义服务始终可编辑和测试;服务器模式是否实际使用由上方来源选择决定。 */}
|
||||
<fieldset className="min-w-0">
|
||||
{!loaded ? (
|
||||
<p className="py-2 text-sm text-muted-foreground">{t('ai.loading')}</p>
|
||||
|
||||
@@ -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<BackupExportProgress['phase'], TranslationKey> = {
|
||||
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,
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
key={profile.id}
|
||||
@@ -645,7 +646,9 @@ export default function CloudAPISection() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-muted-foreground/80">
|
||||
{entry.blurb} <span className="font-medium">{asrAvailabilityLabel(entry)}</span>
|
||||
{entry.blurb}
|
||||
{/* 地区提示多数供应商为空(见 asrAvailabilityLabel),条件渲染避免留一个空 span 和多余空格 */}
|
||||
{availability !== '' && <> <span className="font-medium">{availability}</span></>}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -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 (
|
||||
<Modal
|
||||
@@ -680,10 +684,8 @@ export default function CloudAPISection() {
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{draftEntry?.blurb}{' '}
|
||||
{draftEntry && (
|
||||
<span className="font-medium">{asrAvailabilityLabel(draftEntry)}</span>
|
||||
)}
|
||||
{draftEntry?.blurb}
|
||||
{draftAvailability !== '' && <> <span className="font-medium">{draftAvailability}</span></>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</Card>
|
||||
|
||||
<BackupSection />
|
||||
|
||||
<WebDavSection />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<WebDavProgress['phase'], TranslationKey> = {
|
||||
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<string, TranslationKey> = {
|
||||
'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<Busy>(null)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [progress, setProgress] = useState<WebDavProgress | null>(null)
|
||||
const [lastResult, setLastResult] = useState<WebDavLastResult | null>(null)
|
||||
const [testMessage, setTestMessage] = useState('')
|
||||
const [actionError, setActionError] = useState('')
|
||||
|
||||
const [serverOpen, setServerOpen] = useState(false)
|
||||
const [restoreList, setRestoreList] = useState<WebDavEntry[] | null>(null)
|
||||
const [restoreTarget, setRestoreTarget] = useState<WebDavEntry | null>(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 <T,>(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 (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
{/* items-center 是本页卡片头部的既有写法(音频卡、日志卡都是),右侧控件对着
|
||||
标题+说明整块居中。用 items-start 会让 32px 高的按钮压着 28px 的标题行,
|
||||
视觉上往下坠;而且「配置服务器」按钮和「自动备份」开关高度不同,
|
||||
顶对齐时两个状态的右侧内容会错开。 */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold">{t('webdav.title')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('webdav.desc')}</p>
|
||||
</div>
|
||||
{/* 右上角这一格是这张卡的开关位:没配置时是唯一的入口,配置完就换成自动备份开关。
|
||||
入口按钮单独放在说明下面会显得像一个孤立的动作,和卡片其它内容对不上。 */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{configured ? (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground" id="webdav-enabled-label">
|
||||
{t('webdav.autoLabel')}
|
||||
</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
labelledBy="webdav-enabled-label"
|
||||
onChange={() => void patch('webdav.enabled', !enabled, setEnabled)}
|
||||
noAnimation={!animate}
|
||||
hidden={!ready}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => setServerOpen(true)}>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{t('webdav.configure')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{configured && (
|
||||
<>
|
||||
{/* 备份目标做成一张有边框的小卡,写法沿用「AI 供应商」的服务卡:
|
||||
地址 + 状态徽标一行,摘要一行。原先这些是三段没有容器的散排文字,
|
||||
夹在标题和按钮之间,读起来像还在写说明,而不是「这是已经配好的那个东西」。
|
||||
自动备份开启时用 border-primary + bg-primary/5 —— 这是本仓库表示
|
||||
「这一份正在被使用」的既有语汇,不额外增加元素。 */}
|
||||
<div
|
||||
className={cn(
|
||||
'mt-4 rounded-lg border p-3',
|
||||
enabled ? 'border-primary bg-primary/5' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium" title={url}>
|
||||
{shortServer(url)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded-md px-1.5 py-0.5 text-[11px] font-medium tabular-nums',
|
||||
chip.box,
|
||||
)}
|
||||
>
|
||||
{chip.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 三个动作收成卡上的图标 + tooltip,和「AI 供应商」服务卡一致。
|
||||
原先是卡片下面三颗并排的文字按钮:一个实心加两个 ghost,宽窄不齐、
|
||||
又和卡片抢层级,而它们本来就是「对这台服务器做的三件事」,属于这张卡。 */}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">
|
||||
{uploading && progress ? t(phaseLabelKeys[progress.phase]) : metaParts.join(' · ')}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Tooltip content={running ? t('webdav.backingUp') : t('webdav.backupNow')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runBackupNow('manual')}
|
||||
disabled={isBusy}
|
||||
aria-label={running ? t('webdav.backingUp') : t('webdav.backupNow')}
|
||||
className={cardIconButtonClass}
|
||||
>
|
||||
{running
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
: <CloudUpload className="h-3.5 w-3.5" aria-hidden />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('webdav.restore')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleOpenRestore()}
|
||||
disabled={isBusy}
|
||||
aria-label={t('webdav.restore')}
|
||||
className={cardIconButtonClass}
|
||||
>
|
||||
{busy === 'list' || busy === 'restore'
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
: <RotateCcw className="h-3.5 w-3.5" aria-hidden />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('webdav.settings')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setServerOpen(true)}
|
||||
aria-label={t('webdav.settings')}
|
||||
className={cardIconButtonClass}
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploading && progress && (
|
||||
<div
|
||||
className="mt-1.5 h-1 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-label={t('webdav.progressAria')}
|
||||
aria-valuenow={Math.round(percent)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width]"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 失败原因单独一行:它可能很长,挤进摘要会把前面的信息顶掉。
|
||||
成功和失败都要能看见 —— 少了失败那一半,一个静默失败几个月的
|
||||
备份在界面上和一个正常工作的备份长得一模一样。 */}
|
||||
{!uploading && lastResult && !lastResult.ok && (
|
||||
<p className="mt-1.5 text-[11px] text-destructive">
|
||||
{describeWebDavError(lastResult.error ?? '')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{actionError && <p className="mt-1.5 text-[11px] text-destructive">{actionError}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{serverOpen && (
|
||||
<Modal
|
||||
title={t('webdav.settingsTitle')}
|
||||
onClose={closeServerDialog}
|
||||
showCloseButton
|
||||
panelClassName="w-[520px]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="webdav-url" className="mb-1 block text-sm text-muted-foreground">
|
||||
{t('webdav.url')}
|
||||
</label>
|
||||
<input
|
||||
id="webdav-url"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={url}
|
||||
onChange={(e) => void patch('webdav.url', e.target.value, setUrl)}
|
||||
placeholder={DEFAULT_DAV_URL}
|
||||
className={inputClass}
|
||||
/>
|
||||
{urlWarning
|
||||
? <FormatHint text={urlWarning} />
|
||||
: <p className="mt-1 text-[11px] text-muted-foreground">{t('webdav.urlHint')}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="webdav-username" className="mb-1 block text-sm text-muted-foreground">
|
||||
{t('webdav.username')}
|
||||
</label>
|
||||
<input
|
||||
id="webdav-username"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={username}
|
||||
onChange={(e) => void patch('webdav.username', e.target.value, setUsername)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div data-modal-autofocus>
|
||||
<label htmlFor="webdav-password" className="mb-1 block text-sm text-muted-foreground">
|
||||
{t('webdav.password')}
|
||||
</label>
|
||||
<PasswordInput
|
||||
id="webdav-password"
|
||||
label={t('webdav.password')}
|
||||
value={password}
|
||||
onChange={(value) => void patch('webdav.password', value, setPassword)}
|
||||
onSubmit={() => void handleTest()}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 坚果云是目前验证过的服务商,它那个必踩的点(要用应用密码)值得占一行。 */}
|
||||
<p className="text-[11px] text-muted-foreground">{t('webdav.jianguoyunHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* ── 备份内容 ──
|
||||
三项全部列出,配置那一项标「始终包含」而不是不显示:「会同步哪些文件」
|
||||
这个问题需要一个看得见的完整答案,而不是从两个开关反推。 */}
|
||||
<div className="mt-5 border-t border-border pt-4">
|
||||
<p className="text-sm font-medium">{t('webdav.contentTitle')}</p>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<span className="text-sm">{t('webdav.includeConfig')}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{t('webdav.always')}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<label className="text-sm" id="webdav-history-label">{t('webdav.includeHistory')}</label>
|
||||
<Switch
|
||||
checked={includeHistory}
|
||||
labelledBy="webdav-history-label"
|
||||
onChange={() => void patch('webdav.includeHistory', !includeHistory, setIncludeHistory)}
|
||||
noAnimation={!animate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<label className="text-sm" id="webdav-audio-label">{t('webdav.includeAudio')}</label>
|
||||
<Switch
|
||||
checked={includeAudio}
|
||||
labelledBy="webdav-audio-label"
|
||||
onChange={() => void patch('webdav.includeAudio', !includeAudio, setIncludeAudio)}
|
||||
noAnimation={!animate}
|
||||
/>
|
||||
</div>
|
||||
{/* 就地提示而不是二级确认框:弹窗里再套弹窗是反模式,而且只要录音是开着的,
|
||||
代价就应该一直写在那儿,而不是点一次就消失。 */}
|
||||
{includeAudio && <FormatHint text={t('webdav.audioOnHint')} />}
|
||||
</div>
|
||||
|
||||
{/* ── 频率与保留 ── */}
|
||||
<div className="mt-4 space-y-3 border-t border-border pt-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-sm text-muted-foreground">{t('webdav.intervalLabel')}</label>
|
||||
<Segmented
|
||||
label={t('webdav.intervalLabel')}
|
||||
value={intervalHours}
|
||||
options={intervalOptions.map((opt) => ({ value: opt.value, label: t(opt.labelKey) }))}
|
||||
onChange={(value) => void patch('webdav.intervalHours', value, setIntervalHours)}
|
||||
className="shrink-0 justify-end"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-sm text-muted-foreground">{t('webdav.keepLabel')}</label>
|
||||
<Segmented
|
||||
label={t('webdav.keepLabel')}
|
||||
value={keepCount}
|
||||
options={[3, 5, 10].map((value) => ({ value, label: String(value) }))}
|
||||
onChange={(value) => void patch('webdav.keepCount', value, setKeepCount)}
|
||||
className="shrink-0 justify-end"
|
||||
/>
|
||||
</div>
|
||||
{/* 「保留份数」本身说不清会不会删东西。每次备份是一个新文件、不覆盖旧的,
|
||||
所以必须明说超出的那些会被删掉 —— 用户拿这个数做决定,就得知道代价。 */}
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">{t('webdav.keepHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
{/* 测试结果和报错共用左边这一格,两者不会同时有意义。 */}
|
||||
<span className={`min-w-0 flex-1 text-xs ${actionError ? 'text-destructive' : 'text-success-strong'}`}>
|
||||
{actionError || testMessage}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
onClick={() => void handleTest()}
|
||||
disabled={busy === 'test' || !configured}
|
||||
>
|
||||
{busy === 'test' && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('webdav.test')}
|
||||
</Button>
|
||||
<Button size="sm" className="shrink-0" onClick={closeServerDialog}>
|
||||
{t('webdav.done')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{restoreList && (
|
||||
<Modal title={t('webdav.restoreTitle')} onClose={() => setRestoreList(null)} panelClassName="w-[460px]">
|
||||
{restoreList.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('webdav.restoreEmpty')}</p>
|
||||
) : (
|
||||
<ul className="max-h-64 space-y-1 overflow-y-auto">
|
||||
{restoreList.map((entry) => (
|
||||
<li key={entry.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRestoreTarget(entry)}
|
||||
className="flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<span className="min-w-0 truncate">{entry.name}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{formatBytes(entry.size)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-5 flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => setRestoreList(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{restoreTarget && (
|
||||
<Modal title={t('webdav.restoreConfirmTitle')} onClose={() => setRestoreTarget(null)} showCloseButton={false}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('webdav.restoreConfirmBody', { name: restoreTarget.name })}
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setRestoreTarget(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void handleRestore()}>
|
||||
{t('webdav.restoreConfirmOk')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{restoreDone && (
|
||||
<Modal title={t('webdav.restoreDoneTitle')} onClose={() => undefined} locked showCloseButton={false}>
|
||||
<p className="text-sm text-muted-foreground">{t('webdav.restoreDoneBody')}</p>
|
||||
</Modal>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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('豆包的控制台代次与两代密钥都带过去', () => {
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
|
||||
+84
-6
@@ -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 {
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ export function resolveQwenOmniModel(provider: string): string | undefined {
|
||||
const ASR_DISPLAY_MODEL_MAP: Record<string, string> = {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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]}`
|
||||
}
|
||||
@@ -196,6 +196,7 @@ export default function Dictionary() {
|
||||
<tbody>
|
||||
{/* 模型名是品牌 + 型号,两种语言下都用官方写法,只有"支持/说明"两列走翻译 */}
|
||||
<tr><td>Doubao Seed-ASR 2.0</td><td>{t('dict.support.yes')}</td><td>{t('dict.note.doubao')}</td></tr>
|
||||
<tr><td>{t('dict.model.qwenAudio30')}</td><td>{t('dict.support.yes')}</td><td>{t('dict.note.qwenAudio30')}</td></tr>
|
||||
<tr><td>{t('dict.model.qwenRealtime')}</td><td>{t('dict.support.yes')}</td><td>{t('dict.note.qwenRealtime')}</td></tr>
|
||||
<tr><td>{t('dict.model.qwenFlash')}</td><td>{t('dict.support.yes')}</td><td>{t('dict.note.qwenFlash')}</td></tr>
|
||||
<tr><td>{t('dict.model.localQwen')}</td><td>{t('dict.support.notYet')}</td><td>{t('dict.note.localQwen')}</td></tr>
|
||||
|
||||
@@ -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<ReprocessResult> {
|
||||
const { getWSUrl } = await import('@/services/runtimeConfig')
|
||||
const wsUrl = getWSUrl()
|
||||
const [aiSource, rawAiMinDurationSec] = await Promise.all([
|
||||
getSetting(SERVER_AI_SOURCE_KEY, 'managed') as Promise<string>,
|
||||
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<ReprocessResult>((resolve, reject) => {
|
||||
const serverResult = await new Promise<ReprocessResult>((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<string, unknown> = {
|
||||
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,
|
||||
|
||||
@@ -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<ExportResult> {
|
||||
const path = await invoke<string>('export_full')
|
||||
const path = await invoke<string>('export_full', {
|
||||
scope: { includeHistory: true, includeAudio: true },
|
||||
})
|
||||
return { canceled: false, filePath: path }
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,9 @@ export const DEFAULTS: Record<string, unknown> = {
|
||||
'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<string, unknown> = {
|
||||
// 映射成 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<string, unknown> = {
|
||||
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, // 智能分段(仅极速模式生效),默认开启
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<typeof setInterval> | 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<void>): Promise<void> {
|
||||
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<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
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<string | undefined> {
|
||||
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<AsrResult>('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<AiResult>('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?.()
|
||||
|
||||
@@ -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<AiResult>('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?.()
|
||||
}
|
||||
|
||||
@@ -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<StartOptions> | undefined
|
||||
private aiSource: ServerAiSource = 'managed'
|
||||
private customAiReady = false
|
||||
private customFinalPending = false
|
||||
private serverDonePending = false
|
||||
|
||||
async connect(callbacks: TranscriptionCallbacks): Promise<void> {
|
||||
this.callbacks = callbacks
|
||||
const [storedSource, customProvider, customUrl, customKey, customModel] = await Promise.all([
|
||||
loadServerAiSource(),
|
||||
getSetting('cloudAi.provider', '') as Promise<string>,
|
||||
getSetting('cloudAi.apiUrl', '') as Promise<string>,
|
||||
getSetting('cloudAi.apiKey', '') as Promise<string>,
|
||||
getSetting('cloudAi.model', '') as Promise<string>,
|
||||
])
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<StartOptions>
|
||||
logSource: string
|
||||
/** 异步读设置和原生请求结束后都要验代;false 表示结果已经属于旧录音。 */
|
||||
isCurrent?: () => boolean
|
||||
}
|
||||
|
||||
async function loadClientAiConfig(): Promise<ClientAiConfig> {
|
||||
const [provider, apiUrl, apiKey, model] = await Promise.all([
|
||||
getSetting('cloudAi.provider', 'openai_compat') as Promise<string>,
|
||||
getSetting('cloudAi.apiUrl', '') as Promise<string>,
|
||||
getSetting('cloudAi.apiKey', '') as Promise<string>,
|
||||
getSetting('cloudAi.model', '') as Promise<string>,
|
||||
])
|
||||
return { provider, apiUrl, apiKey, model }
|
||||
}
|
||||
|
||||
function safeFallbackText(asrText: string, startOptions?: Readonly<StartOptions>): string {
|
||||
// 选区编辑依赖 AI 理解口述命令。AI 没有真正完成时,回填原选区才是安全 no-op;
|
||||
// 直接回填 ASR 会把“改成更正式”这种命令本身覆盖进正文。
|
||||
return startOptions?.textContext?.selectedText || asrText
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用当前启用的客户端 AI 档案整理一段 ASR 文本。
|
||||
*
|
||||
* 服务器、云 API、本地三种识别来源共用这一处,避免 Ollama 免密、短语音门槛、
|
||||
* 选区编辑回退在三条链路里逐渐漂移。返回 null 表示录音代次已经失效。
|
||||
*/
|
||||
export async function polishWithClientAi(
|
||||
options: ClientAiPolishOptions,
|
||||
): Promise<ClientAiPolishResult | null> {
|
||||
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<typeof setTimeout> | undefined
|
||||
try {
|
||||
const request = invoke<AiResult>('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<never>((_, 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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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<ServerAiSource> {
|
||||
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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, TranslationKey> = {
|
||||
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<UnlistenFn> {
|
||||
return listen<WebDavProgress>('webdav-backup-progress', (event) => handler(event.payload))
|
||||
}
|
||||
|
||||
/** 连接测试。返回服务器上已有的备份份数。 */
|
||||
export function testWebDavConnection(config: WebDavConfig): Promise<number> {
|
||||
return invoke<number>('webdav_test', { config })
|
||||
}
|
||||
|
||||
/** 列出服务器上的备份,从新到旧。 */
|
||||
export function listWebDavBackups(): Promise<WebDavEntry[]> {
|
||||
return invoke<WebDavEntry[]>('webdav_list')
|
||||
}
|
||||
|
||||
/** 立即备份一次。档位取自设置。 */
|
||||
export function runWebDavBackup(): Promise<WebDavBackupResult> {
|
||||
return invoke<WebDavBackupResult>('webdav_backup_now')
|
||||
}
|
||||
|
||||
/** 从服务器恢复。完成后需要由调用方触发重启(与本地导入一致)。 */
|
||||
export function restoreWebDavBackup(name: string): Promise<void> {
|
||||
return invoke('webdav_restore', { name })
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
Reference in new issue
Block a user