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:
crosswk committed 2026-08-30 09:06:42 +08:00
1 parent bad38dabe5
commit 4015cf80ef
37 files changed
+4127 -373

No files matched your search

+2 -1
View File
@@ -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",
+5 -2
View File
@@ -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
+253 -97
View File
@@ -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![
+1
View File
@@ -8,4 +8,5 @@ pub mod shortcuts;
pub mod storage;
pub mod system;
pub mod tray;
pub mod webdav;
pub mod window;
+998
View File
@@ -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));
}
}
+10
View File
@@ -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(), "");
}
}
+1
View File
@@ -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;
+13 -1
View File
@@ -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
View File
@@ -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>
+1 -12
View File
@@ -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
View File
@@ -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 {
+11 -2
View File
@@ -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())
}
+18
View File
@@ -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]}`
}
+1
View File
@@ -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>
+75 -9
View File
@@ -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,
+10 -2
View File
@@ -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 }
}
+25 -1
View File
@@ -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, // 智能分段(仅极速模式生效),默认开启
+2
View File
@@ -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)
}
}
+12 -1
View File
@@ -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 {
+129
View File
@@ -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 })
}
+1
View File
@@ -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':