From 366f6c38eb241121437badece4927c1bb460f9d0 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 14 May 2026 20:27:05 +0800 Subject: [PATCH 1/4] feat: polish hotword overrides, audio archive, auto-update check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 围绕"ZIP 误识别 + AI 日报反馈"一波集中改动,共 7 项: polish 提示词 - COMMON_RULES 规则 2 加热词例外(热词列表正确写法优先于"原样保留") - 规则 5 自动纠错补英文短词同音示例(ZIP/VIP) - 规则 2 显式举例带次版本号产品名(GPT-5.6 / Claude 4.7 / iOS 26.1)不省略小数 - Structured mode 主题数上限放宽给"多条独立新闻/日报"场景 - Structured mode 要求主题标题保留关键实体名 - 热词块尾部加「优先于规则 2」强约束 History 详情面板 - 修复复制按钮没 await 没反馈的 bug(1.5s 显示"已复制") - 加 AudioRecordingPlayer(按需加载 wav → Blob → 原生 audio controls) - 加导出录音按钮(anchor.download 触发浏览器下载) Settings 新增 - 自动检查更新开关(默认 on,启动延 4s + 60min interval) - 历史条数上限输入框(5–200,留空 = 200) - 保留原始录音开关 + 录音条数上限输入框 录音归档(debug 开关版) - recorder.rs WavArchiver(Drop 时回填 RIFF/data header) - DictationSession.has_audio_recording 字段;history.id 与 SessionId 对齐 - persistence.rs 加 recordings_root / recording_path_for_session - prune_recordings 双重 cap(days + count),仅扫 .wav,metadata 失败按过期处理 IPC 安全 + 性能 - read_audio_recording 改 async + tokio::fs::read 防阻塞主循环 - session_id 白名单 UUID-v4 字面校验 + 2 个单测覆盖路径越界 后续待优化(review 标记 H2 / M1 / M3): - prune 在录音前调用,cap 边界少裁 1 条(最多多 1 个文件,不爆盘) - WavArchiver Drop unwinding 时 header 长度兜底(QuickTime 可能拒播) - has_audio_recording 跟磁盘脱钩(前端 catch 'recording not found' 已兜住) cargo test 258 全过;tsc 0 error;5 语言 i18n 完整。 --- openless-all/app/src-tauri/src/commands.rs | 82 ++++++++++++- openless-all/app/src-tauri/src/coordinator.rs | 15 ++- .../src-tauri/src/coordinator/dictation.rs | 48 ++++++-- openless-all/app/src-tauri/src/lib.rs | 1 + openless-all/app/src-tauri/src/persistence.rs | 98 +++++++++++++++- openless-all/app/src-tauri/src/polish.rs | 4 +- openless-all/app/src-tauri/src/recorder.rs | 92 +++++++++++++++ openless-all/app/src-tauri/src/types.rs | 60 +++++++++- openless-all/app/src/App.tsx | 2 + .../app/src/components/AutoUpdateGate.tsx | 53 +++++++++ openless-all/app/src/components/Icon.tsx | 4 + openless-all/app/src/i18n/en.ts | 13 +++ openless-all/app/src/i18n/ja.ts | 13 +++ openless-all/app/src/i18n/ko.ts | 13 +++ openless-all/app/src/i18n/zh-CN.ts | 13 +++ openless-all/app/src/i18n/zh-TW.ts | 13 +++ openless-all/app/src/lib/ipc.ts | 21 ++++ openless-all/app/src/lib/stylePrefs.test.ts | 4 + openless-all/app/src/lib/types.ts | 14 +++ openless-all/app/src/pages/History.tsx | 109 +++++++++++++++++- openless-all/app/src/pages/Settings.tsx | 67 +++++++++++ 21 files changed, 707 insertions(+), 32 deletions(-) create mode 100644 openless-all/app/src/components/AutoUpdateGate.tsx diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 0b338e7f..31d15756 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -437,7 +437,7 @@ pub async fn start_microphone_level_monitor( let _ = level_app.emit("microphone:level", serde_json::json!({ "level": level })); }); let (recorder, _runtime_errors) = - Recorder::start(microphone_device_name, consumer, level_handler) + Recorder::start(microphone_device_name, consumer, level_handler, None) .map_err(|e| e.to_string())?; *state.lock() = Some(recorder); Ok(()) @@ -1115,6 +1115,52 @@ pub fn clear_history(coord: CoordinatorState<'_>) -> Result<(), String> { coord.history().clear().map_err(|e| e.to_string()) } +/// 读取某次会话的原始麦克风 wav 字节流。仅当用户开过 +/// `prefs.record_audio_for_debug` 并且这条 session 是开关打开后录的,才会有文件。 +/// 文件名规约:`/recordings/.wav`,与 DictationSession.id 同名。 +/// +/// 路径校验:session_id **必须**严格匹配 UUID-v4 字面(36 字符 = 8-4-4-4-12 + 4 个 `-`, +/// 内容仅 ASCII 十六进制 + `-`)。白名单胜过黑名单——绝对路径前缀、Windows ADS、 +/// 百分号编码、NUL 字节都不在合法字符集里,挡掉所有 Path::join 越界的可能。 +/// session_id 在仓库内由 `Uuid::new_v4()` 生成 (`dictation.rs:1531`),前端只会回传 +/// 自己列出的合法 id,但 IPC = boundary,按 boundary 规则严格校验。 +/// +/// async fs:单条 5 分钟 wav 约 9.6MB,同步 `std::fs::read` 会阻塞 Tauri IPC 主循环。 +/// 改 `tokio::fs::read` 后让出线程给其它 IPC。 +#[tauri::command] +pub async fn read_audio_recording(session_id: String) -> Result, String> { + if !is_valid_session_id(&session_id) { + return Err("invalid session id".into()); + } + let path = + crate::persistence::recording_path_for_session(&session_id).map_err(|e| e.to_string())?; + if !path.exists() { + return Err("recording not found".into()); + } + tokio::fs::read(&path) + .await + .map_err(|e| format!("read wav failed: {e}")) +} + +/// UUID-v4 字面校验:36 字符 + 5 段 `-` 分隔(8-4-4-4-12)+ 仅 ASCII 十六进制。 +fn is_valid_session_id(s: &str) -> bool { + if s.len() != 36 { + return false; + } + let bytes = s.as_bytes(); + for (i, b) in bytes.iter().enumerate() { + let is_dash_position = matches!(i, 8 | 13 | 18 | 23); + if is_dash_position { + if *b != b'-' { + return false; + } + } else if !b.is_ascii_hexdigit() { + return false; + } + } + true +} + // ─────────────────────────── vocab ─────────────────────────── #[tauri::command] @@ -2225,9 +2271,9 @@ mod tests { use super::{ active_asr_is_keyless_for_validation, active_foundry_model_from_prefs, asr_configured_for_provider, asr_transcriptions_url, fetch_provider_models, - is_gemini_base_url, llm_configured_for_provider, local_asr_release_plan_for_provider, - models_url, normalize_foundry_language_hint, parse_gemini_model_ids, - parse_latest_beta_from_atom, parse_model_ids, persist_settings, + is_gemini_base_url, is_valid_session_id, llm_configured_for_provider, + local_asr_release_plan_for_provider, models_url, normalize_foundry_language_hint, + parse_gemini_model_ids, parse_latest_beta_from_atom, parse_model_ids, persist_settings, release_foundry_runtime_if_inactive, validate_foundry_model_alias, ProviderConfig, SettingsWriter, }; @@ -2942,4 +2988,32 @@ mod tests { assert_eq!(models, vec!["m1".to_string(), "m2".to_string()]); server.join().unwrap(); } + + #[test] + fn is_valid_session_id_accepts_canonical_uuid_v4() { + // canonical UUID-v4 字面:8-4-4-4-12,全小写、全大写、混合都接受。 + assert!(is_valid_session_id("550e8400-e29b-41d4-a716-446655440000")); + assert!(is_valid_session_id("550E8400-E29B-41D4-A716-446655440000")); + assert!(is_valid_session_id("Abc12345-6789-abcd-EF01-234567890abc")); + } + + #[test] + fn is_valid_session_id_rejects_path_traversal_and_garbage() { + assert!(!is_valid_session_id("")); + assert!(!is_valid_session_id("../../etc/passwd")); + assert!(!is_valid_session_id("..\\..\\windows\\system32")); + // 长度对但含 `/`:dash 位置错或非 hex 字符都不通过 + assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544/000")); + assert!(!is_valid_session_id("550e8400_e29b_41d4_a716_446655440000")); // 用 _ 代 - + // 非 hex 字符 + assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544000g")); + // 长度不对(35 / 37) + assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544000")); + assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-4466554400000")); + // NUL 字节 + assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544\x00000")); + // 百分号编码与绝对路径 + assert!(!is_valid_session_id("%2e%2e/recordings/x")); + assert!(!is_valid_session_id("/Users/attacker/secret.wav")); + } } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index b8e064cb..34bdf434 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2584,7 +2584,9 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { let microphone_device_name = selected_microphone_device_name(inner); stop_microphone_preview_monitor(inner, "QA recorder"); acquire_recording_mute(inner, "qa").await; - match Recorder::start(microphone_device_name, consumer, level_handler) { + // QA 默认不留痕(qa_save_history 默认 false),录音文件归档也跟着不开。 + // 调试 QA 麦克风请用主听写路径。 + match Recorder::start(microphone_device_name, consumer, level_handler, None) { Ok((rec, runtime_errors)) => { *inner.qa_recorder.lock() = Some(rec); // QA 也跟主听写一样监听 cpal runtime error。设备中途消失 / panic 时 @@ -2853,11 +2855,14 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { error_code: Some("qaSession".to_string()), duration_ms: Some(raw.duration_ms), dictionary_entry_count: None, + has_audio_recording: None, }; - if let Err(e) = inner - .history - .append_with_retention(session, inner.prefs.get().history_retention_days) - { + let prefs_snapshot = inner.prefs.get(); + if let Err(e) = inner.history.append_with_retention( + session, + prefs_snapshot.history_retention_days, + prefs_snapshot.history_max_entries, + ) { log::error!("[coord] QA history append failed: {e}"); } } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 50f1338b..017bd7e1 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -785,7 +785,25 @@ pub(super) async fn start_recorder_for_starting( let microphone_device_name = selected_microphone_device_name(inner); stop_microphone_preview_monitor(inner, "dictation recorder"); acquire_recording_mute(inner, "dictation").await; - match Recorder::start(microphone_device_name, consumer, level_handler) { + let audio_archive_path = if inner.prefs.get().record_audio_for_debug { + // 用 coordinator 的 SessionId 作为文件名,跟 history 那条记录 id 对齐(见 + // 下游 polish 收尾时 `history_session_id = current_session_id.to_string()`)。 + // 顺手把超龄 / 超量录音清理一下,避免 debug 开关常开时磁盘膨胀。 + let prefs = inner.prefs.get(); + let _ = crate::persistence::prune_recordings( + prefs.history_retention_days, + prefs.audio_recording_max_entries, + ); + crate::persistence::recording_path_for_session(&session_id.to_string()).ok() + } else { + None + }; + match Recorder::start( + microphone_device_name, + consumer, + level_handler, + audio_archive_path, + ) { Ok((rec, runtime_errors)) => { store_recorder_for_session(inner, session_id, rec); spawn_recorder_error_monitor(inner, runtime_errors); @@ -1230,11 +1248,14 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { error_code: Some("emptyTranscript".to_string()), duration_ms: Some(raw.duration_ms), dictionary_entry_count: Some(enabled_phrases(inner).len() as u32), + has_audio_recording: None, }; - if let Err(e) = inner - .history - .append_with_retention(session, inner.prefs.get().history_retention_days) - { + let prefs_snapshot = inner.prefs.get(); + if let Err(e) = inner.history.append_with_retention( + session, + prefs_snapshot.history_retention_days, + prefs_snapshot.history_max_entries, + ) { log::error!("[coord] history append failed: {e}"); } emit_capsule( @@ -1507,8 +1528,11 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { .map(str::to_string); let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired"); - let history_session_id = Uuid::new_v4().to_string(); + // 与 coordinator 内部 SessionId 对齐:方便 recorder 旁路写盘的 `.wav` + // 跟 history 这条 DictationSession.id 同名,前端凭 id 就能找到对应录音文件。 + let history_session_id = current_session_id.to_string(); let history_created_at = Utc::now().to_rfc3339(); + let prefs_snapshot = inner.prefs.get(); let session = DictationSession { id: history_session_id.clone(), created_at: history_created_at.clone(), @@ -1523,11 +1547,15 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // 历史详情页的"X 个热词"显示:用本次实际命中次数(每个匹配实例算一次), // 比"启用词条总数"更能反映本段口述命中了多少。u64 → u32 截断对单段听写足够。 dictionary_entry_count: Some(total_hits.min(u32::MAX as u64) as u32), + // recorder 旁路写盘开关在 begin_session 时已传给 recorder;这里只标记 + // 该会话是否有对应录音文件供 History 渲染播放按钮。 + has_audio_recording: Some(prefs_snapshot.record_audio_for_debug), }; - if let Err(e) = inner - .history - .append_with_retention(session, inner.prefs.get().history_retention_days) - { + if let Err(e) = inner.history.append_with_retention( + session, + prefs_snapshot.history_retention_days, + prefs_snapshot.history_max_entries, + ) { log::error!("[coord] history append failed: {e}"); } let done_message = if tsf_required_insert_failed { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 417e0a4c..82a3f54a 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -283,6 +283,7 @@ pub fn run() { commands::list_history, commands::delete_history_entry, commands::clear_history, + commands::read_audio_recording, commands::list_vocab, commands::add_vocab, commands::remove_vocab, diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 854b7d98..cd662f8e 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -139,6 +139,90 @@ pub fn local_models_root() -> Result { Ok(dir) } +/// 录音归档目录:`/recordings/`。 +/// 仅当用户开 `prefs.record_audio_for_debug` 时才会有内容(每次会话一个 `.wav`)。 +/// 同样受 `history_retention_days` 清理(写入新文件时顺手裁旧的)。 +pub fn recordings_root() -> Result { + let dir = data_dir()?.join("recordings"); + ensure_dir(&dir)?; + Ok(dir) +} + +/// 双重 cap 清理 `recordings/*.wav`: +/// - `retention_days > 0` → 把超过 N 天的删掉(沿用 history 的 retention 逻辑)。 +/// - `max_entries == Some(n)` → 按 mtime 倒序保留最新的 n 条(clamp 到 1..=HISTORY_CAP); +/// `None` 时退回 HISTORY_CAP (200) 硬上限,避免无限增长。 +/// 调用方:每次新建一条录音前。失败仅打 warn,避免影响主路径。 +pub fn prune_recordings(retention_days: u32, max_entries: Option) -> Result<()> { + let dir = match data_dir() { + Ok(d) => d.join("recordings"), + Err(_) => return Ok(()), + }; + if !dir.exists() { + return Ok(()); + } + + // 第一步:按天清理。仅扫 .wav,跟第二步保持一致;metadata 读不到的文件按"过期"处理 + // —— fs 损坏 / 未来格式不一致的孤儿文件应当被回收而不是无限累积。 + if retention_days > 0 { + let cutoff = std::time::SystemTime::now() + - std::time::Duration::from_secs(u64::from(retention_days) * 24 * 3600); + for entry in fs::read_dir(&dir).context("read recordings dir")?.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("wav") { + continue; + } + let modified = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .unwrap_or(std::time::UNIX_EPOCH); + if modified < cutoff { + if let Err(err) = fs::remove_file(&path) { + log::warn!("[recordings] prune (days) remove failed for {path:?}: {err}"); + } + } + } + } + + // 第二步:按条数清理。剩下的 wav 按 mtime 倒序,超出 cap 的删掉。 + let cap = max_entries + .map(|n| (n as usize).clamp(1, HISTORY_CAP)) + .unwrap_or(HISTORY_CAP); + let mut entries: Vec<(PathBuf, std::time::SystemTime)> = fs::read_dir(&dir) + .context("read recordings dir")? + .flatten() + .filter_map(|e| { + let path = e.path(); + // 只看 .wav,避免误删未来其他类型的归档文件。 + if path.extension().and_then(|ext| ext.to_str()) != Some("wav") { + return None; + } + let modified = e.metadata().ok()?.modified().ok()?; + Some((path, modified)) + }) + .collect(); + if entries.len() <= cap { + return Ok(()); + } + entries.sort_by(|a, b| b.1.cmp(&a.1)); + for (path, _) in entries.into_iter().skip(cap) { + if let Err(err) = fs::remove_file(&path) { + log::warn!( + "[recordings] prune (count) remove failed for {:?}: {err}", + path + ); + } + } + Ok(()) +} + +/// 单个 session 的录音文件路径。不保证文件已存在(DictationSession.has_audio_recording +/// 决定文件是否被写过)。前端用 `read_audio_recording` IPC 读字节流喂 HTMLAudio。 +pub fn recording_path_for_session(session_id: &str) -> Result { + Ok(recordings_root()?.join(format!("{session_id}.wav"))) +} + /// Foundry Local 下载与缓存根目录。DLL 和模型都不打进安装包,和 Qwen3-ASR /// 一样放在 OpenLess 的 models 目录下,卸载清理用户数据时可以一起删除。 #[cfg(target_os = "windows")] @@ -786,16 +870,19 @@ impl HistoryStore { } pub fn append(&self, session: DictationSession) -> Result<()> { - self.append_with_retention(session, 0) + self.append_with_retention(session, 0, None) } /// `retention_days == 0` 跟旧 append 行为一致(不按时间清理)。 /// `> 0` 时在写入新条目后顺手把超过 N 天的会话裁掉,写入时就完成清理, - /// 不需要后台轮询。最后再受 200 条硬上限约束(HISTORY_CAP)。 + /// 不需要后台轮询。最后再受条数上限约束: + /// - `max_entries == None` → HISTORY_CAP (200) + /// - `max_entries == Some(n)` → clamp 到 5..=HISTORY_CAP,避免用户填 0 / 极大值。 pub fn append_with_retention( &self, session: DictationSession, retention_days: u32, + max_entries: Option, ) -> Result<()> { let _guard = self.lock.lock(); let mut sessions = self.read_locked()?; @@ -810,8 +897,11 @@ impl HistoryStore { .unwrap_or(true) }); } - if sessions.len() > HISTORY_CAP { - sessions.truncate(HISTORY_CAP); + let cap = max_entries + .map(|n| (n as usize).clamp(5, HISTORY_CAP)) + .unwrap_or(HISTORY_CAP); + if sessions.len() > cap { + sessions.truncate(cap); } self.write_locked(&sessions) } diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index fc0c2086..a6adbdfc 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -1751,7 +1751,7 @@ fn compose_system_prompt(style_system_prompt: &str, hotwords: &[String]) -> Stri .collect::>() .join("\n"); format!( - "{}\n\n热词(用户希望以下写法在输出中保持准确;当转写中出现这些词的同音或形近误识别时,优先按上述写法输出,不做无关词的机械替换):\n{}", + "{}\n\n热词(用户希望以下写法在输出中保持准确;当转写中出现这些词的同音或形近误识别时,优先按上述写法输出,不做无关词的机械替换):\n{}\n\n上述热词的纠偏指令优先于通用规则 2 的「原样保留」——当转写词是热词的同音 / 形近误识别(例:转写出「VIP」而热词列表里有「ZIP」),就按热词写法输出,不要因为它看起来像英文专有名词或中英混输而保留误识别结果。", base, bullets ) } @@ -1771,7 +1771,7 @@ fn compose_hotword_block_preview(hotwords: &[String]) -> String { .collect::>() .join("\n"); format!( - "热词(用户希望以下写法在输出中保持准确;当转写中出现这些词的同音或形近误识别时,优先按上述写法输出,不做无关词的机械替换):\n{}", + "热词(用户希望以下写法在输出中保持准确;当转写中出现这些词的同音或形近误识别时,优先按上述写法输出,不做无关词的机械替换):\n{}\n\n上述热词的纠偏指令优先于通用规则 2 的「原样保留」——当转写词是热词的同音 / 形近误识别(例:转写出「VIP」而热词列表里有「ZIP」),就按热词写法输出,不要因为它看起来像英文专有名词或中英混输而保留误识别结果。", bullets ) } diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index a599fa3f..3c456086 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -10,6 +10,7 @@ //! - cpal `Stream` 是 `!Send`,所以独立线程持有它。 //! - 主线程通过 `AtomicBool` 通知"该停了",并 `join` 线程;线程内 `drop` Stream。 +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::mpsc::{channel, Receiver, Sender}; use std::sync::Arc; @@ -60,12 +61,15 @@ pub struct Recorder { impl Recorder { /// 启动采集。`consumer` 收到 16 kHz/Mono/Int16-LE 的 PCM; /// `level_handler` 收到 0..1 的 RMS 电平。 + /// `audio_archive_path` 不为 None 时,同样的 16 kHz/Mono/Int16-LE 旁路写入 WAV 文件, + /// 用于 debug 麦克风灵敏度 / ASR 误识别。Drop 时自动回填 RIFF / data 长度。 /// /// 实际的 cpal Stream 在独立线程里构造、播放、最终析构——因为它 `!Send`。 pub fn start( microphone_device_name: Option, consumer: Arc, level_handler: Arc, + audio_archive_path: Option, ) -> Result<(Self, Receiver), RecorderError> { // 启动信号:子线程构造 Stream 完成后通过 startup_tx 报告结果。 let (startup_tx, startup_rx) = channel::>(); @@ -81,6 +85,7 @@ impl Recorder { microphone_device_name, consumer, level_handler, + audio_archive_path, stop_for_thread, startup_tx, runtime_error_tx, @@ -148,14 +153,24 @@ fn run_audio_thread( microphone_device_name: Option, consumer: Arc, level_handler: Arc, + audio_archive_path: Option, stop_flag: Arc, startup_tx: Sender>, runtime_error_tx: Sender, ) { + let archiver = audio_archive_path.and_then(|path| match WavArchiver::create(&path) { + Ok(arch) => Some(Arc::new(Mutex::new(arch))), + Err(err) => { + // 写盘失败不阻塞录音:debug 归档失效但听写主路径正常。 + log::warn!("[recorder] wav archive create failed at {path:?}: {err}"); + None + } + }); let (stream, state) = match build_input_stream( microphone_device_name, consumer, level_handler, + archiver, runtime_error_tx.clone(), ) { Ok(s) => s, @@ -275,6 +290,7 @@ fn build_input_stream( microphone_device_name: Option, consumer: Arc, level_handler: Arc, + archiver: Option>>, runtime_error_tx: Sender, ) -> Result<(cpal::Stream, Arc), RecorderError> { let host = cpal::default_host(); @@ -304,6 +320,7 @@ fn build_input_stream( sample_format, consumer, level_handler, + archiver, Arc::clone(&state), input_sr, channels, @@ -368,6 +385,7 @@ fn build_stream_for_format( sample_format: SampleFormat, consumer: Arc, level_handler: Arc, + archiver: Option>>, state: Arc, input_sr: u32, channels: usize, @@ -377,6 +395,7 @@ fn build_stream_for_format( ($t:ty, $to_f32:expr) => {{ let consumer = Arc::clone(&consumer); let level_handler = Arc::clone(&level_handler); + let archiver = archiver.clone(); let state = Arc::clone(&state); let runtime_error_tx = runtime_error_tx.clone(); let err_cb = move |err| { @@ -398,6 +417,7 @@ fn build_stream_for_format( input_sr, consumer.as_ref(), level_handler.as_ref(), + archiver.as_deref(), &state, ); }, @@ -461,6 +481,7 @@ fn process_callback( input_sr: u32, consumer: &dyn AudioConsumer, level_handler: &(dyn Fn(f32) + Send + Sync), + archiver: Option<&Mutex>, state: &StreamState, ) { if interleaved.is_empty() || channels == 0 { @@ -479,6 +500,9 @@ fn process_callback( let level = (output_rms * LEVEL_RMS_GAIN).clamp(0.0, 1.0); consumer.consume_pcm_chunk(&pcm_bytes); + if let Some(arch) = archiver { + arch.lock().append(&pcm_bytes); + } level_handler(level); // 更新最后一次成功调用的时间戳(用于 liveness 检测) @@ -620,6 +644,69 @@ fn update_peak(slot: &AtomicUsize, current: f32) { } } +/// 16 kHz / mono / 16-bit PCM WAV 的简易追加写入器。 +/// 构造时写一个 data_size=0 的 header 占位,每次 append 把 i16 PCM bytes 追加到文件, +/// Drop 时 seek 回 0 把 RIFF / data 长度字段回填——避免依赖外部 finalize 调用点。 +struct WavArchiver { + file: std::fs::File, + bytes_written: u32, +} + +impl WavArchiver { + fn create(path: &Path) -> std::io::Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = std::fs::File::create(path)?; + use std::io::Write; + file.write_all(&build_wav_header(0))?; + Ok(Self { + file, + bytes_written: 0, + }) + } + + fn append(&mut self, pcm_bytes: &[u8]) { + use std::io::Write; + if self.file.write_all(pcm_bytes).is_ok() { + self.bytes_written = self + .bytes_written + .saturating_add(pcm_bytes.len().min(u32::MAX as usize) as u32); + } + } +} + +impl Drop for WavArchiver { + fn drop(&mut self) { + use std::io::{Seek, SeekFrom, Write}; + let header = build_wav_header(self.bytes_written); + if self.file.seek(SeekFrom::Start(0)).is_ok() { + let _ = self.file.write_all(&header); + let _ = self.file.sync_all(); + } + } +} + +fn build_wav_header(data_size: u32) -> [u8; 44] { + // RIFF/WAVE PCM 标准 44-byte header,16 kHz / mono / 16-bit 写死。 + let total_size = data_size.saturating_add(36); + let mut h = [0u8; 44]; + h[0..4].copy_from_slice(b"RIFF"); + h[4..8].copy_from_slice(&total_size.to_le_bytes()); + h[8..12].copy_from_slice(b"WAVE"); + h[12..16].copy_from_slice(b"fmt "); + h[16..20].copy_from_slice(&16u32.to_le_bytes()); // fmt chunk size + h[20..22].copy_from_slice(&1u16.to_le_bytes()); // PCM + h[22..24].copy_from_slice(&1u16.to_le_bytes()); // mono + h[24..28].copy_from_slice(&(TARGET_SAMPLE_RATE).to_le_bytes()); + h[28..32].copy_from_slice(&(TARGET_SAMPLE_RATE * 2).to_le_bytes()); // byte rate (sr * block_align) + h[32..34].copy_from_slice(&2u16.to_le_bytes()); // block align + h[34..36].copy_from_slice(&16u16.to_le_bytes()); // bits per sample + h[36..40].copy_from_slice(b"data"); + h[40..44].copy_from_slice(&data_size.to_le_bytes()); + h +} + #[cfg(test)] mod tests { use super::*; @@ -703,6 +790,7 @@ mod tests { 8_000, &consumer, &move |level| levels_for_handler.lock().unwrap().push(level), + None, &state, ); @@ -726,6 +814,7 @@ mod tests { TARGET_SAMPLE_RATE, &consumer, &move |level| levels_for_handler.lock().unwrap().push(level), + None, &state, ); @@ -749,6 +838,7 @@ mod tests { TARGET_SAMPLE_RATE, &consumer, &move |level| levels_for_handler.lock().unwrap().push(level), + None, &state, ); @@ -773,6 +863,7 @@ mod tests { TARGET_SAMPLE_RATE, &consumer, &move |level| levels_for_handler.lock().unwrap().push(level), + None, &state, ); process_callback( @@ -781,6 +872,7 @@ mod tests { TARGET_SAMPLE_RATE, &consumer, &move |level| levels.lock().unwrap().push(level), + None, &state, ); diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index a7525b85..959a9351 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -98,6 +98,11 @@ pub struct DictationSession { pub error_code: Option, pub duration_ms: Option, pub dictionary_entry_count: Option, + /// 当 `prefs.record_audio_for_debug` 开启时,本次会话的原始麦克风音频被写到 + /// `recordings/.wav`。前端凭这个字段决定是否在 History 渲染播放按钮。 + /// `None` / `Some(false)` 都按"无录音"处理;旧 JSON 不带这字段也兼容。 + #[serde(default)] + pub has_audio_recording: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -596,6 +601,25 @@ pub struct UserPreferences { /// 默认 true(更接近用户习惯)。 #[serde(default = "default_true")] pub streaming_insert_save_clipboard: bool, + /// 主窗口启动 + 后台每 60 分钟自动检查云端新版本。默认 true。 + /// 用户在 Settings → 关于 里可关。关闭后仅手动「检查更新」按钮可用。 + #[serde(default = "default_true")] + pub auto_update_check: bool, + /// 历史记录上限(条数)。`None` = 使用代码内 200 条硬上限; + /// `Some(n)` 表示用户在 Settings 自定义了上限(5..=200 之间)。 + #[serde(default)] + pub history_max_entries: Option, + /// 是否为每次会话保留原始麦克风音频文件(wav)到 `recordings/` 目录, + /// 用于排查 ASR 误识别 / 麦克风灵敏度问题。默认 false。开启会占磁盘空间, + /// 受 `history_retention_days` 同样的清理策略约束。 + #[serde(default)] + pub record_audio_for_debug: bool, + /// `recordings/` 里保留的最近 wav 文件数(按 mtime 倒序保留最新的)。 + /// `None` = 跟随 `HISTORY_CAP` (200);`Some(n)` 时 clamp 到 1..=200。 + /// 调用点:每次开新会话前裁旧。让用户在「文本历史保留 200 条但 wav 只留最近 5 条」 + /// 这种「文本档案多 + 录音不占盘」组合下精确控制。 + #[serde(default)] + pub audio_recording_max_entries: Option, } fn default_local_asr_model() -> String { @@ -701,6 +725,14 @@ struct UserPreferencesWire { streaming_insert: bool, #[serde(default = "default_true")] streaming_insert_save_clipboard: bool, + #[serde(default = "default_true")] + auto_update_check: bool, + #[serde(default)] + history_max_entries: Option, + #[serde(default)] + record_audio_for_debug: bool, + #[serde(default)] + audio_recording_max_entries: Option, } impl Default for UserPreferencesWire { @@ -747,6 +779,10 @@ impl Default for UserPreferencesWire { start_minimized: prefs.start_minimized, streaming_insert: prefs.streaming_insert, streaming_insert_save_clipboard: prefs.streaming_insert_save_clipboard, + auto_update_check: prefs.auto_update_check, + history_max_entries: prefs.history_max_entries, + record_audio_for_debug: prefs.record_audio_for_debug, + audio_recording_max_entries: prefs.audio_recording_max_entries, } } } @@ -815,6 +851,10 @@ impl<'de> Deserialize<'de> for UserPreferences { start_minimized: wire.start_minimized, streaming_insert: wire.streaming_insert, streaming_insert_save_clipboard: wire.streaming_insert_save_clipboard, + auto_update_check: wire.auto_update_check, + history_max_entries: wire.history_max_entries, + record_audio_for_debug: wire.record_audio_for_debug, + audio_recording_max_entries: wire.audio_recording_max_entries, }) } } @@ -893,13 +933,17 @@ const ROLE_BLOCK: &str = "# 角色\n\ const COMMON_RULES: &str = "# 通用规则\n\ 1) \u{4E0D}确定 / 转写明显不完整 / 断句在半截 \u{2192} 保留原话,\u{4E0D}要替用户补全或猜测。\n\ - 2) 中英混输、专有名词、产品名、代码 / 命令 / 路径 / URL、数字与单位、emoji \u{2192} 原样保留。\n\ + 2) 中英混输、专有名词、产品名、代码 / 命令 / 路径 / URL、数字与单位、emoji \u{2192} 原样保留。\ + 带次版本号的产品名(如 GPT-5.6、Claude 4.7、iOS 26.1、Python 3.13、Tauri 2.10)也算\u{201C}数字与单位\u{201D}的一部分,\ + 完整保留小数 / 次版本号,\u{4E0D}省略成主版本(GPT-5.6 \u{4E0D}写成 GPT-5、Claude 4.7 \u{4E0D}写成 Claude 4)。\ + (例外:当转写词是 # 热词列表中某个词的同音 / 形近误识别时,按热词列表里的正确写法输出,这一条比\u{201C}原样保留\u{201D}优先。)\n\ 3) \u{4E0D}引入用户没说过的事实;中途改口以最终版本为准。在保留原意和语气的前提下,按用户的整体意图把零碎口语组织成协调、自然的书面表达。\n\ 4) 如果原始转写本身是在\u{201C}询问 / 要求别人做某事\u{201D},只整理为清楚的问题或请求,\u{4E0D}代替对方回答。\n\ 5) 自动纠错:明显的 ASR 同音 / 形近错字按上下文纠回正确字面,常见模式包括\ \u{201C}跟目录 / 根木鹿\u{201D}\u{2192}\u{201C}根目录\u{201D}、\u{201C}代码厂\u{201D}\u{2192}\u{201C}代码仓\u{201D}、\ \u{201C}编一编\u{201D}\u{2192}\u{201C}编译\u{201D}、\u{201C}的 / 得 / 地\u{201D}用法、\u{201C}做 / 作\u{201D} 等常见错别字。\ - 专有名词(见 # 热词)、人名、品牌名、不在常见中文词典里的词原样保留,\u{4E0D}强行改字;改了之后含义会发生变化的不改。"; + 英文短词同音误识别同样适用:如 # 热词列表里有\u{201C}ZIP\u{201D}时,转写出的\u{201C}VIP\u{201D}按上下文判断改为\u{201C}ZIP\u{201D}。\ + 人名、品牌名、不在常见中文词典里的词原样保留,\u{4E0D}强行改字;改了之后含义会发生变化的不改。"; const OUTPUT_BLOCK: &str = "# 输出\n\ 直接输出最终文本正文。需要结构化时直接从标题 / 段落 / 编号开始。\n\ @@ -945,6 +989,9 @@ pub fn default_style_system_prompt_for_mode(mode: PolishMode) -> String { 把口述整理为脉络清晰、可直接复制走的结构化文本:保留用户的口语引子(润色后作为首行过渡),\ 主动按语义把扁平事项归类成 2\u{2013}4 个主题,用双层格式呈现,尾巴查询用自然收尾句。\n\ \n\ + **多条独立条目场景例外**:当输入是「多条互相独立的新闻 / 公司动态 / 产品发布 / 行业进展」拼成的播报式内容(典型如 AI 日报、行业资讯整理、多家公司发布、多个独立事件回顾),\ + 每条独立成一个主题,可以超过 4 个,\u{4E0D}强行合并到 2\u{2013}4 类。判断信号:条目之间没有共享主体、彼此互不相关、用户用\u{201C}下面是几条新闻\u{201D}\u{201C}今天的资讯\u{201D}\u{201C}最新进展\u{201D}等播报式引子。\n\ + \n\ **默认行为:双层 list。判断事项的标准**:\ 以下任意一种都算一个事项 \u{2192} \u{4E0D}\u{4F9D}\u{8D56}\u{7528}\u{6237}\u{662F}\u{5426}\u{660E}\u{8BF4}\u{201C}\u{7B2C}\u{4E00}\u{201D}\u{201C}\u{7B2C}\u{4E8C}\u{201D}\u{201C}\u{53E6}\u{5916}\u{201D}\u{7B49}\u{8FDE}\u{63A5}\u{8BCD}\u{3002}\n\ \u{2003}\u{2003}1) 可独立成句的陈述(\u{4E3B}+\u{8C13}+\u{5BBE},如\u{201C}\u{300A}\u{67D0}\u{4E1C}\u{897F}\u{300B}\u{8FD8}\u{662F}\u{767D}\u{8272}\u{201D})\n\ @@ -968,7 +1015,10 @@ pub fn default_style_system_prompt_for_mode(mode: PolishMode) -> String { 都必须按语义重新归类成下面定义的双层格式。\u{200D}\u{200D}照抄原结构 = 失败。\n\ \n\ 双层格式(主清单标准写法):\n\ - - 第一层(主题):行首用 \"1.\" \"2.\" \"3.\" \u{2026},每个主题一行短标题(4\u{2013}8 字最佳);\n\ + - 第一层(主题):行首用 \"1.\" \"2.\" \"3.\" \u{2026},每个主题一行短标题(4\u{2013}8 字最佳);\ + 主题标题应包含事项中的关键实体名(人名 / 公司名 / 产品名 / 平台名),\ + 例如\u{300C}OpenAI 模型动态\u{300D}\u{300C}苹果与欧盟监管争议\u{300D},而非纯抽象类别如\u{300C}模型进展\u{300D}\u{300C}监管争议\u{300D};\ + 只有当某主题包含多个不同实体且无法压缩时,才退回到抽象命名。\n\ - 第二层(子项):另起一行,行首用 \"(a)\" \"(b)\" \"(c)\" \u{2026},每条一句完整陈述。\n\ 顶层\u{4E0D}使用半括号写法(如 \"1)\" \"2)\");不在子项内再嵌第三层。\n\ \n\ @@ -1139,6 +1189,10 @@ impl Default for UserPreferences { start_minimized: false, streaming_insert: false, streaming_insert_save_clipboard: true, + auto_update_check: true, + history_max_entries: None, + record_audio_for_debug: false, + audio_recording_max_entries: None, } } } diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index 37b82aed..af14908e 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { AutoUpdateGate } from './components/AutoUpdateGate'; import { Capsule } from './components/Capsule'; import { FloatingShell } from './components/FloatingShell'; import { Onboarding } from './components/Onboarding'; @@ -150,6 +151,7 @@ export function App({ isCapsule, isQa }: AppProps) { return ( {gate === 'onboarding' ? setGate('ready')} /> : } + {gate === 'ready' && } ); } diff --git a/openless-all/app/src/components/AutoUpdateGate.tsx b/openless-all/app/src/components/AutoUpdateGate.tsx new file mode 100644 index 00000000..230b3070 --- /dev/null +++ b/openless-all/app/src/components/AutoUpdateGate.tsx @@ -0,0 +1,53 @@ +// 主窗口启动 + 后台每 60 分钟自动调一次 plugin-updater check。 +// 受 prefs.autoUpdateCheck 开关控制;关闭时只走 Settings → 关于 的手动按钮。 +// 找到新版本时直接挂 UpdateDialog;不弹自定义通知,沿用既有 dialog 视觉。 + +import { useEffect } from 'react'; +import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; + +const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000; +const STARTUP_DELAY_MS = 4_000; + +export function AutoUpdateGate() { + const { prefs } = useHotkeySettings(); + const u = useAutoUpdate(); + const enabled = prefs?.autoUpdateCheck ?? true; + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + + const tick = () => { + if (cancelled) return; + if (u.checking || u.busy || isDialogStatus(u.status)) return; + void u.checkForUpdates().catch(error => { + console.warn('[auto-update] background check failed', error); + }); + }; + + const startupTimer = window.setTimeout(tick, STARTUP_DELAY_MS); + const intervalTimer = window.setInterval(tick, AUTO_CHECK_INTERVAL_MS); + return () => { + cancelled = true; + window.clearTimeout(startupTimer); + window.clearInterval(intervalTimer); + }; + // checkForUpdates / status 故意不放依赖:tick 内部已经做了忙碌态短路, + // 把 hook 返回值塞进依赖会让 interval 在每次 status 变化时重建,反而漏 tick。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled]); + + if (!isDialogStatus(u.status)) return null; + return ( + + ); +} diff --git a/openless-all/app/src/components/Icon.tsx b/openless-all/app/src/components/Icon.tsx index 8bcc6ca6..6fe6bede 100644 --- a/openless-all/app/src/components/Icon.tsx +++ b/openless-all/app/src/components/Icon.tsx @@ -55,6 +55,10 @@ export const ICONS: Record = { info: 'M12 8h.01M11 12h1v4h1M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z', external:'M9 5h10v10M19 5L9 15M5 9v10h10', close: 'M6 6l12 12M6 18L18 6', + // play — 右指三角箭头,标识"播放录音"按钮(History 详情) + play: 'M8 5v14l11-7z', + // download — 向下箭头 + 底托,标识"导出录音"按钮(History 详情) + download:'M12 3v12M7 12l5 5 5-5M5 21h14', }; export interface IconProps { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index f4e017e7..2937c671 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -155,6 +155,11 @@ export const en: typeof zhCN = { retry: 'Retry', clearFailed: 'Failed to clear history: {{err}}', deleteFailed: 'Failed to delete entry: {{err}}', + copyFailed: 'Failed to copy: {{err}}', + playRecording: 'Play recording', + audioLoading: 'Loading…', + exportRecording: 'Export recording', + exportFailed: 'Failed to export: {{err}}', rawLabel: 'Raw', rawEmpty: '(empty)', selectHint: 'Select an entry on the left to see details.', @@ -350,11 +355,19 @@ export const en: typeof zhCN = { historyGroupTitle: 'History & context', historyRetentionLabel: 'History retention (days)', historyRetentionDesc: 'Entries older than this are pruned on new writes; 0 = no time-based pruning.', + historyMaxEntriesLabel: 'Max history entries', + historyMaxEntriesDesc: 'Cap on locally retained sessions; blank = 200. Range 5–200. Oldest are pruned past the cap.', polishContextWindowLabel: 'Polish context window (minutes)', polishContextWindowDesc: 'Use the last N minutes of polished transcripts as multi-turn context; 0 = disabled.', + recordAudioForDebugLabel: 'Keep raw recording (debug)', + recordAudioForDebugDesc: 'When on, each session saves the raw microphone audio as wav for diagnosing mic sensitivity / ASR misrecognition. All speech is stored locally in plaintext, subject to the same retention as history.', + audioRecordingMaxEntriesLabel: 'Max raw recordings', + audioRecordingMaxEntriesDesc: 'Cap on locally retained wav files; blank = 200. Range 1–200. Oldest are pruned past the cap. Independent from text history cap.', startupGroupTitle: 'Startup', startMinimizedLabel: 'Start minimized (no main window)', startMinimizedDesc: 'No main window on any launch path — menu bar / tray only.', + autoUpdateCheckLabel: 'Auto-check for updates', + autoUpdateCheckDesc: 'Check for new releases on main window launch and every 60 minutes. When off, only the manual "Check for updates" button in About works.', startupAtBoot: 'Launch at login', startupAtBootDesc: 'Start OpenLess automatically when you sign in.', startupAtBootError: 'Failed to toggle launch at login: {{message}}', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 9f89fc58..78b77a53 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -157,6 +157,11 @@ export const ja: typeof zhCN = { retry: '再試行', clearFailed: '履歴の消去に失敗:{{err}}', deleteFailed: '記録の削除に失敗:{{err}}', + copyFailed: 'コピーに失敗:{{err}}', + playRecording: '録音を再生', + audioLoading: '読み込み中…', + exportRecording: '録音をエクスポート', + exportFailed: 'エクスポート失敗:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側から 1 件選択して詳細を表示。', @@ -352,11 +357,19 @@ export const ja: typeof zhCN = { historyGroupTitle: '履歴とコンテキスト', historyRetentionLabel: '履歴保持期間(日)', historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。', + historyMaxEntriesLabel: '履歴件数の上限', + historyMaxEntriesDesc: 'ローカルに保持する直近セッション数。空欄 = 200。範囲 5–200。超過分は古い順に削除。', polishContextWindowLabel: '会話コンテキスト窓(分)', polishContextWindowDesc: '直近 N 分間の整文済み転写をマルチターン文脈として渡します。0 = 無効。', + recordAudioForDebugLabel: '元の録音を保持(デバッグ)', + recordAudioForDebugDesc: 'オンにすると各セッションのマイク音声を wav として保存し、マイク感度 / ASR 誤認識の診断に使えます。発話は平文でローカルに保存され、履歴の保持期間に従って削除されます。', + audioRecordingMaxEntriesLabel: '元音声の保持件数', + audioRecordingMaxEntriesDesc: 'ローカルに保持する最近の wav 件数。空欄 = 200。範囲 1–200。超過分は古い順に削除。テキスト履歴件数とは独立。', startupGroupTitle: '起動', startMinimizedLabel: '起動時にメインウィンドウを表示しない', startMinimizedDesc: 'どの起動経路でもメインウィンドウを開かず、メニューバー / トレイのみで動作。', + autoUpdateCheckLabel: 'アップデートを自動チェック', + autoUpdateCheckDesc: 'メインウィンドウ起動時と 60 分ごとに新バージョンを確認します。オフ時は「バージョン情報」内の手動ボタンのみ有効。', startupAtBoot: '起動時に自動起動', startupAtBootDesc: 'ログイン時に OpenLess を自動起動。', startupAtBootError: '自動起動の切り替えに失敗:{{message}}', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 60461e87..ea7af7cd 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -157,6 +157,11 @@ export const ko: typeof zhCN = { retry: '다시 시도', clearFailed: '기록 비우기 실패: {{err}}', deleteFailed: '항목 삭제 실패: {{err}}', + copyFailed: '복사 실패: {{err}}', + playRecording: '녹음 재생', + audioLoading: '로딩 중…', + exportRecording: '녹음 내보내기', + exportFailed: '내보내기 실패: {{err}}', rawLabel: '원문', rawEmpty: '(비어 있음)', selectHint: '왼쪽에서 하나를 선택하여 자세히 보기.', @@ -352,11 +357,19 @@ export const ko: typeof zhCN = { historyGroupTitle: '기록 및 컨텍스트', historyRetentionLabel: '기록 보관 기간(일)', historyRetentionDesc: '보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 = 시간 기반 정리 비활성화.', + historyMaxEntriesLabel: '기록 개수 상한', + historyMaxEntriesDesc: '로컬에 보관할 최근 세션 수. 비워두면 200. 범위 5–200. 초과 시 오래된 것부터 삭제.', polishContextWindowLabel: '대화 컨텍스트 윈도(분)', polishContextWindowDesc: '최근 N 분간 정리된 전사를 멀티턴 컨텍스트로 전달합니다. 0 = 비활성화.', + recordAudioForDebugLabel: '원본 녹음 보관(디버그)', + recordAudioForDebugDesc: '켜면 각 세션의 마이크 원본을 wav로 저장하여 마이크 감도 / ASR 오인식 진단에 사용합니다. 음성이 평문으로 로컬에 저장되며 기록 보관 기간과 동일한 정리 규칙을 따릅니다.', + audioRecordingMaxEntriesLabel: '원본 녹음 보관 개수', + audioRecordingMaxEntriesDesc: '로컬에 보관할 최근 wav 개수. 비워두면 200. 범위 1–200. 초과 시 오래된 것부터 삭제. 텍스트 기록 개수와 독립.', startupGroupTitle: '시작', startMinimizedLabel: '시작 시 메인 창 숨기기', startMinimizedDesc: '모든 시작 경로에서 메인 창을 열지 않고 메뉴 막대 / 트레이에서만 실행합니다.', + autoUpdateCheckLabel: '자동 업데이트 확인', + autoUpdateCheckDesc: '메인 창 시작 시와 60분마다 새 버전을 확인합니다. 끄면 "정보" 패널의 수동 버튼만 동작합니다.', startupAtBoot: '부팅 시 자동 시작', startupAtBootDesc: '로그인 시 OpenLess 자동 시작.', startupAtBootError: '자동 시작 전환 실패: {{message}}', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 1789698e..1f381e1f 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -153,6 +153,11 @@ export const zhCN = { retry: '重试', clearFailed: '清空失败:{{err}}', deleteFailed: '删除失败:{{err}}', + copyFailed: '复制失败:{{err}}', + playRecording: '播放录音', + audioLoading: '加载中…', + exportRecording: '导出录音', + exportFailed: '导出失败:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左侧选一条查看详情。', @@ -348,11 +353,19 @@ export const zhCN = { historyGroupTitle: '历史与上下文', historyRetentionLabel: '历史保留天数', historyRetentionDesc: '超过保留天数的历史在写入新条目时被清理;0 = 不按时间清理。', + historyMaxEntriesLabel: '历史条数上限', + historyMaxEntriesDesc: '本地保留的最近会话数;留空 = 200。范围 5–200。超出会从最旧的开始删。', polishContextWindowLabel: '对话上下文窗口(分钟)', polishContextWindowDesc: '把最近 N 分钟内已润色的转写作为多轮上下文,0 = 关闭。', + recordAudioForDebugLabel: '保留原始录音(调试)', + recordAudioForDebugDesc: '开启后每次会话会把原始麦克风音频存为 wav,便于判断麦克风灵敏度 / ASR 误识别。录音落盘后所有话明文存本地,受"历史保留天数"清理。', + audioRecordingMaxEntriesLabel: '原始录音保留条数', + audioRecordingMaxEntriesDesc: '本地保留的最近 wav 文件数;留空 = 200。范围 1–200。超出会从最旧的开始删,与文本历史条数独立。', startupGroupTitle: '启动', startMinimizedLabel: '启动时静默运行', startMinimizedDesc: '所有启动路径都不弹主窗口,仅菜单栏 / 托盘运行。', + autoUpdateCheckLabel: '自动检查更新', + autoUpdateCheckDesc: '主窗口启动 + 后台每 60 分钟检查云端新版本。关闭后仅"关于"的手动按钮可用。', startupAtBoot: '开机自启', startupAtBootDesc: '登录系统时自动启动 OpenLess。', startupAtBootError: '开机自启切换失败:{{message}}', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index d3820b8a..a07c5fdc 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -155,6 +155,11 @@ export const zhTW: typeof zhCN = { retry: '重試', clearFailed: '清空失敗:{{err}}', deleteFailed: '刪除失敗:{{err}}', + copyFailed: '複製失敗:{{err}}', + playRecording: '播放錄音', + audioLoading: '載入中…', + exportRecording: '匯出錄音', + exportFailed: '匯出失敗:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側選一條查看詳情。', @@ -350,11 +355,19 @@ export const zhTW: typeof zhCN = { historyGroupTitle: '歷史與上下文', historyRetentionLabel: '歷史保留天數', historyRetentionDesc: '超過保留天數的歷史在寫入新條目時被清理;0 = 不按時間清理。', + historyMaxEntriesLabel: '歷史條數上限', + historyMaxEntriesDesc: '本地保留的最近會話數;留空 = 200。範圍 5–200。超出會從最舊的開始刪。', polishContextWindowLabel: '對話上下文窗口(分鐘)', polishContextWindowDesc: '把最近 N 分鐘內已潤色的轉寫作為多輪上下文,0 = 關閉。', + recordAudioForDebugLabel: '保留原始錄音(除錯)', + recordAudioForDebugDesc: '開啟後每次會話會把原始麥克風音訊存為 wav,便於判斷麥克風靈敏度 / ASR 誤識別。錄音落盤後所有話明文存本地,受「歷史保留天數」清理。', + audioRecordingMaxEntriesLabel: '原始錄音保留條數', + audioRecordingMaxEntriesDesc: '本地保留的最近 wav 檔案數;留空 = 200。範圍 1–200。超出會從最舊的開始刪,與文字歷史條數獨立。', startupGroupTitle: '啟動', startMinimizedLabel: '啓動時靜默運行', startMinimizedDesc: '所有啓動路徑都不彈主窗口,僅選單欄 / 托盤運行。', + autoUpdateCheckLabel: '自動檢查更新', + autoUpdateCheckDesc: '主視窗啟動時 + 後台每 60 分鐘檢查雲端新版本。關閉後僅「關於」的手動按鈕可用。', startupAtBoot: '開機自啓', startupAtBootDesc: '登錄系統時自動啓動 OpenLess。', startupAtBootError: '開機自啓切換失敗:{{message}}', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 8faa5063..b6247138 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -97,6 +97,10 @@ let mockSettings: UserPreferences = { updateChannel: 'stable', streamingInsert: false, streamingInsertSaveClipboard: true, + autoUpdateCheck: true, + historyMaxEntries: null, + recordAudioForDebug: false, + audioRecordingMaxEntries: null, }; const mockFullStylePrompts: StyleSystemPrompts = { @@ -419,6 +423,7 @@ const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ errorCode: null, durationMs: 600, dictionaryEntryCount: 28, + hasAudioRecording: null, })); const mockVocab: DictionaryEntry[] = OL_DATA.vocab.map((v, i) => ({ @@ -563,6 +568,22 @@ export function clearHistory(): Promise { return invokeOrMock('clear_history', undefined, () => undefined); } +/** 读取某次会话的原始麦克风 wav 字节流。仅当 prefs.recordAudioForDebug 当时打开 + * 并且文件没被 retention 清理掉时才有内容;其他情况后端会返回 "recording not found" 错。 + * 调用方应仅在 session.hasAudioRecording === true 时触发,避免无效 IPC。 */ +export function readAudioRecording(sessionId: string): Promise { + return invokeOrMock( + 'read_audio_recording', + { sessionId }, + () => new Uint8Array(), + ).then(value => { + // Tauri 默认把 Vec 序列化为 number[],前端拿到的是普通数组;统一转 Uint8Array。 + if (value instanceof Uint8Array) return value; + if (Array.isArray(value)) return new Uint8Array(value as number[]); + return new Uint8Array(value as ArrayBuffer); + }); +} + // ── Vocab ────────────────────────────────────────────────────────────── export function listVocab(): Promise { return invokeOrMock('list_vocab', undefined, () => mockVocab); diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 848fdd5f..2035e1a1 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -61,6 +61,10 @@ const previousPrefs: UserPreferences = { updateChannel: 'stable', streamingInsert: false, streamingInsertSaveClipboard: true, + autoUpdateCheck: true, + historyMaxEntries: null, + recordAudioForDebug: false, + audioRecordingMaxEntries: null, }; const nextPrefs: UserPreferences = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 441e3646..d1c1bd5c 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -18,6 +18,9 @@ export interface DictationSession { errorCode: string | null; durationMs: number | null; dictionaryEntryCount: number | null; + /** 该会话是否在录音时归档了原始 wav(取决于当时 prefs.recordAudioForDebug)。 + * true 时前端在 History 渲染播放按钮,凭 id 通过 read_audio_recording IPC 拿字节流。 */ + hasAudioRecording: boolean | null; } export interface DictionaryEntry { @@ -278,6 +281,17 @@ export interface UserPreferences { /** 流式输入成功后是否把最终润色文本写回剪贴板。开启后 Cmd+V 还能重复粘贴该次输出, * 与一次性路径行为对齐。默认 true。 */ streamingInsertSaveClipboard: boolean; + /** 主窗口启动 + 后台每 60 分钟自动检查云端新版本。默认 true。 + * 关闭后仅 Settings → 关于 的「检查更新」手动按钮可用。 */ + autoUpdateCheck: boolean; + /** 历史记录上限(条数)。null = 走默认 200;5..=200 之间为用户自定义。 */ + historyMaxEntries: number | null; + /** 是否为每次会话保留原始麦克风音频文件(wav),用于排查 ASR 误识别 / 麦克风灵敏度。 + * 默认 false。开启后会占磁盘空间,受 historyRetentionDays 同样的清理策略约束。 */ + recordAudioForDebug: boolean; + /** recordings/ 里保留的最近 wav 文件数。null = 跟随 200 硬上限;1..=200 之间为用户自定义。 + * 跟 historyMaxEntries 解耦——「文本档案多但 wav 只留最近 5 条」是合法组合。 */ + audioRecordingMaxEntries: number | null; } export interface MicrophoneDevice { diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 22644f5b..c1429305 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { detectOS } from '../components/WindowChrome'; import { formatComboLabel } from '../lib/hotkey'; -import { clearHistory, deleteHistoryEntry, listHistory } from '../lib/ipc'; +import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording } from '../lib/ipc'; import type { DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; @@ -43,6 +43,7 @@ export function History() { const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [actionError, setActionError] = useState(null); + const [justCopied, setJustCopied] = useState(false); const { prefs } = useHotkeySettings(); const refresh = useCallback(async () => { @@ -102,9 +103,44 @@ export function History() { } }; - const onCopy = () => { + const onCopy = async () => { if (!item) return; - navigator.clipboard?.writeText(item.finalText); + try { + if (!navigator.clipboard?.writeText) { + throw new Error('clipboard unavailable'); + } + await navigator.clipboard.writeText(item.finalText); + setActionError(null); + setJustCopied(true); + window.setTimeout(() => setJustCopied(false), 1500); + } catch (error) { + console.error('[history] failed to copy entry', error); + setActionError(t('history.copyFailed', { err: errorMessage(error) })); + } + }; + + const onExportAudio = async () => { + if (!item || !item.hasAudioRecording) return; + try { + const bytes = await readAudioRecording(item.id); + if (bytes.byteLength === 0) throw new Error('empty recording'); + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const blob = new Blob([buffer], { type: 'audio/wav' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `openless-recording-${item.id}.wav`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + // 浏览器异步触发下载,立刻 revoke 偶尔被中断;延后 60s 兜底。 + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + setActionError(null); + } catch (error) { + console.error('[history] failed to export recording', error); + setActionError(t('history.exportFailed', { err: errorMessage(error) })); + } }; return ( @@ -208,10 +244,16 @@ export function History() { {formatDuration(item.durationMs, t)}
- {t('common.copy')} + void onCopy()}>{justCopied ? t('common.copied') : t('common.copy')} + {item.hasAudioRecording && ( + void onExportAudio()}>{t('history.exportRecording')} + )} {t('common.delete')}
+ {item.hasAudioRecording && ( + + )}
{t('history.rawLabel')} @@ -260,6 +302,65 @@ function errorMessage(error: unknown): string { return String(error); } +/** 当 session.hasAudioRecording 为 true 时渲染:一个加载按钮 + 拿到字节后切换为 + * 原生 audio controls。Blob URL 在组件 unmount 时 revoke,避免泄漏。 */ +function AudioRecordingPlayer({ sessionId }: { sessionId: string }) { + const { t } = useTranslation(); + const [url, setUrl] = useState(null); + const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle'); + const [errorText, setErrorText] = useState(null); + + useEffect(() => { + return () => { + if (url) URL.revokeObjectURL(url); + }; + }, [url]); + + const load = async () => { + setStatus('loading'); + setErrorText(null); + try { + const bytes = await readAudioRecording(sessionId); + if (bytes.byteLength === 0) throw new Error('empty recording'); + // typed array 在严格 TS lib 下不直接是 BlobPart;构造独立 ArrayBuffer 后 cast。 + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const blob = new Blob([buffer], { type: 'audio/wav' }); + const objectUrl = URL.createObjectURL(blob); + setUrl(objectUrl); + setStatus('ready'); + } catch (error) { + console.error('[history] load recording failed', error); + setStatus('error'); + setErrorText(errorMessage(error)); + } + }; + + if (status === 'ready' && url) { + return ( +
+
+ ); + } + return ( +
+ void load()} + disabled={status === 'loading'} + > + {status === 'loading' ? t('history.audioLoading') : t('history.playRecording')} + + {status === 'error' && ( + {errorText} + )} +
+ ); +} + function formatTime(iso: string): string { const d = new Date(iso); if (isNaN(d.getTime())) return iso; diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index e89fb27b..d3a077f4 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -315,6 +315,32 @@ function RecordingSection() { }; const onStartMinimizedChange = (startMinimized: boolean) => savePrefs({ ...prefs, startMinimized }); + const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) => + savePrefs({ ...prefs, autoUpdateCheck }); + const onRecordAudioForDebugChange = (recordAudioForDebug: boolean) => + savePrefs({ ...prefs, recordAudioForDebug }); + // 历史条数 200 是当前 HISTORY_CAP(persistence.rs:32),下限 5 是避免用户填 0 导致 + // 写一条就立刻被清光;空字符串视为不限制,落回 null → 后端走 200 默认。 + const onHistoryMaxEntriesChange = (raw: string) => { + const trimmed = raw.trim(); + if (trimmed === '') { + void savePrefs({ ...prefs, historyMaxEntries: null }); + return; + } + const parsed = Number.parseInt(trimmed, 10); + if (Number.isNaN(parsed)) return; + void savePrefs({ ...prefs, historyMaxEntries: clamp(parsed, 5, 200) }); + }; + const onAudioRecordingMaxEntriesChange = (raw: string) => { + const trimmed = raw.trim(); + if (trimmed === '') { + void savePrefs({ ...prefs, audioRecordingMaxEntries: null }); + return; + } + const parsed = Number.parseInt(trimmed, 10); + if (Number.isNaN(parsed)) return; + void savePrefs({ ...prefs, audioRecordingMaxEntries: clamp(parsed, 1, 200) }); + }; const choices: Array<[HotkeyMode, string]> = [ ['toggle', t('settings.recording.modeToggle')], @@ -516,6 +542,20 @@ function RecordingSection() { style={{ ...inputStyle, width: 80, textAlign: 'right' }} /> + + onHistoryMaxEntriesChange(e.target.value)} + style={{ ...inputStyle, width: 80, textAlign: 'right' }} + /> + + + + + + onAudioRecordingMaxEntriesChange(e.target.value)} + style={{ ...inputStyle, width: 80, textAlign: 'right' }} + disabled={!prefs.recordAudioForDebug} + /> + {/* ─── 启动(折叠) ──────────────────────────────────────────── */} @@ -540,6 +601,12 @@ function RecordingSection() { > + + + {capability.statusHint && (
{capability.statusHint} From 13839a2fd4c4ada2f93f9e03fc63f1e114fede92 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 14 May 2026 20:56:05 +0800 Subject: [PATCH 2/4] fix: address pr_agent Wrong Flag + Missing Audio review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr_agent review #436 提了两个跟 has_audio_recording 字段相关的 issue,本 commit 全部修复: Wrong Flag (dictation.rs:1551 polish 收尾路径) - 原实现:has_audio_recording: Some(prefs.record_audio_for_debug) 以「开关是否打开」当真值。但 Recorder::start 内部 WavArchiver::create 可能失败 (recordings/ 创建不出 / 权限不足 / 磁盘满),开关 on 但 wav 没真写盘时,前端会 渲染播放按钮、点击得到 'recording not found'。 - 修法:Recorder::start 返回值加第三个 bool = archive_active。Inner 加 audio_archive_active: AtomicBool 字段,begin_session 时写入。history 写入路径 读这个字段而不是 prefs,反映真实写盘状态。 Missing Audio (dictation.rs:1233 empty-transcript 失败路径) - 原实现:has_audio_recording: None,即使开关 on 也强行 None。但磁盘上 wav 仍存在 (recorder 已走完整段录音)。讽刺的是:ASR 没识别到文字恰好是用户最想听原始录音 诊断的场景。 - 修法:empty-transcript 路径同样用 inner.audio_archive_active.load(...)。 与 polish 路径一致。 副作用最小: - SessionState 不动(不污染 10 个构造点) - 改 Inner 加一个 AtomicBool(2 个构造点已补) - Recorder::start 返回元组扩第 3 项;3 个调用点(dictation/QA/preview)都已更新 cargo test 258 全过。 --- openless-all/app/src-tauri/src/commands.rs | 2 +- openless-all/app/src-tauri/src/coordinator.rs | 14 +++++++- .../src-tauri/src/coordinator/dictation.rs | 18 ++++++++--- openless-all/app/src-tauri/src/recorder.rs | 32 ++++++++++++------- 4 files changed, 48 insertions(+), 18 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 31d15756..9368ec9c 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -436,7 +436,7 @@ pub async fn start_microphone_level_monitor( let level_handler: Arc = Arc::new(move |level| { let _ = level_app.emit("microphone:level", serde_json::json!({ "level": level })); }); - let (recorder, _runtime_errors) = + let (recorder, _runtime_errors, _archive_active) = Recorder::start(microphone_device_name, consumer, level_handler, None) .map_err(|e| e.to_string())?; *state.lock() = Some(recorder); diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 34bdf434..05c2eed2 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -116,6 +116,11 @@ struct Inner { #[cfg(target_os = "windows")] foundry_local_runtime: Arc, recorder: Mutex>>, + /// 当前 dictation / QA session 的 wav 归档是否真的被写到磁盘上。 + /// 由 Recorder::start 返回值 (archive_active) 写入;history.append 路径读取, + /// 决定 DictationSession.has_audio_recording 字段。比单纯读 prefs.record_audio_for_debug + /// 更准确:用户开了开关但路径无法创建(权限 / 磁盘满)也算 false。 + audio_archive_active: AtomicBool, recording_mute: Mutex, hotkey: Mutex>, hotkey_status: Mutex, @@ -203,6 +208,7 @@ impl Coordinator { state: Mutex::new(SessionState::default()), asr: Mutex::new(None), recorder: Mutex::new(None), + audio_archive_active: AtomicBool::new(false), recording_mute: Mutex::new(SharedRecordingMuteState::new()), hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), @@ -252,6 +258,7 @@ impl Coordinator { state: Mutex::new(SessionState::default()), asr: Mutex::new(None), recorder: Mutex::new(None), + audio_archive_active: AtomicBool::new(false), recording_mute: Mutex::new(SharedRecordingMuteState::new()), hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), @@ -2587,7 +2594,12 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { // QA 默认不留痕(qa_save_history 默认 false),录音文件归档也跟着不开。 // 调试 QA 麦克风请用主听写路径。 match Recorder::start(microphone_device_name, consumer, level_handler, None) { - Ok((rec, runtime_errors)) => { + Ok((rec, runtime_errors, archive_active)) => { + // QA 路径不写 dictation 的 history,但仍把 archive 状态归零,避免 dictation + // 接力时读到上一个 QA session 的过期值。 + inner + .audio_archive_active + .store(archive_active, std::sync::atomic::Ordering::Relaxed); *inner.qa_recorder.lock() = Some(rec); // QA 也跟主听写一样监听 cpal runtime error。设备中途消失 / panic 时 // 不能让 QA 永远卡在 Recording 没反馈。详见 issue #168。 diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 017bd7e1..6b998f4c 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -804,7 +804,12 @@ pub(super) async fn start_recorder_for_starting( level_handler, audio_archive_path, ) { - Ok((rec, runtime_errors)) => { + Ok((rec, runtime_errors, archive_active)) => { + // 把 archive 实际创建状态存到 Inner,让 history 写入路径(含 empty-transcript + // 失败分支)读真实情况,而不是 prefs 开关。修 pr_agent "Wrong Flag" 反馈。 + inner + .audio_archive_active + .store(archive_active, std::sync::atomic::Ordering::Relaxed); store_recorder_for_session(inner, session_id, rec); spawn_recorder_error_monitor(inner, runtime_errors); // 不在这里 emit Recording capsule。 @@ -1248,7 +1253,10 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { error_code: Some("emptyTranscript".to_string()), duration_ms: Some(raw.duration_ms), dictionary_entry_count: Some(enabled_phrases(inner).len() as u32), - has_audio_recording: None, + // empty-transcript(ASR 没识别到任何文字)也保留 wav 标记——这是用户最想 + // 通过原始录音定位"是不是麦克风太小声 / ASR 模型问题"的场景。修 pr_agent + // "Missing Audio" 反馈。 + has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)), }; let prefs_snapshot = inner.prefs.get(); if let Err(e) = inner.history.append_with_retention( @@ -1547,9 +1555,9 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // 历史详情页的"X 个热词"显示:用本次实际命中次数(每个匹配实例算一次), // 比"启用词条总数"更能反映本段口述命中了多少。u64 → u32 截断对单段听写足够。 dictionary_entry_count: Some(total_hits.min(u32::MAX as u64) as u32), - // recorder 旁路写盘开关在 begin_session 时已传给 recorder;这里只标记 - // 该会话是否有对应录音文件供 History 渲染播放按钮。 - has_audio_recording: Some(prefs_snapshot.record_audio_for_debug), + // 用 begin_session 时 Recorder::start 返回的实际写盘状态,而不是 prefs 开关—— + // 开关打开但路径创建失败时这里是 false,避免前端渲染播放按钮后端 404。 + has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)), }; if let Err(e) = inner.history.append_with_retention( session, diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index 3c456086..a943b783 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -64,13 +64,17 @@ impl Recorder { /// `audio_archive_path` 不为 None 时,同样的 16 kHz/Mono/Int16-LE 旁路写入 WAV 文件, /// 用于 debug 麦克风灵敏度 / ASR 误识别。Drop 时自动回填 RIFF / data 长度。 /// + /// 返回值第三个 `bool` = "archive 实际成功创建":caller 写 history 时应当用这个值 + /// 决定 `has_audio_recording`,而不是 prefs 开关。开关打开但写盘失败(路径不存在 / + /// 权限不足 / 磁盘满)时仍返回 false,避免前端渲染播放按钮后端却 404。 + /// /// 实际的 cpal Stream 在独立线程里构造、播放、最终析构——因为它 `!Send`。 pub fn start( microphone_device_name: Option, consumer: Arc, level_handler: Arc, audio_archive_path: Option, - ) -> Result<(Self, Receiver), RecorderError> { + ) -> Result<(Self, Receiver, bool), RecorderError> { // 启动信号:子线程构造 Stream 完成后通过 startup_tx 报告结果。 let (startup_tx, startup_rx) = channel::>(); // 运行期错误:Stream 已成功启动后,cpal 通过 err_cb 异步上报。 @@ -78,6 +82,17 @@ impl Recorder { let stop_flag = Arc::new(AtomicBool::new(false)); let stop_for_thread = Arc::clone(&stop_flag); + // 同步路径上尝试创建 WavArchiver——成功 / 失败都立刻知道,传给 caller 决定 + // 是否在 history 标 has_audio_recording。失败仅 log::warn 不抛错,主路径继续。 + let archiver = audio_archive_path.and_then(|path| match WavArchiver::create(&path) { + Ok(arch) => Some(Arc::new(Mutex::new(arch))), + Err(err) => { + log::warn!("[recorder] wav archive create failed at {path:?}: {err}"); + None + } + }); + let archive_active = archiver.is_some(); + let join_handle = thread::Builder::new() .name("openless-recorder".into()) .spawn(move || { @@ -85,7 +100,7 @@ impl Recorder { microphone_device_name, consumer, level_handler, - audio_archive_path, + archiver, stop_for_thread, startup_tx, runtime_error_tx, @@ -106,6 +121,7 @@ impl Recorder { join_handle: Mutex::new(Some(join_handle)), }, runtime_error_rx, + archive_active, )) } @@ -149,23 +165,17 @@ pub fn list_input_devices() -> Result, RecorderError> { } /// 音频线程主体:构造 Stream → 通过 startup_tx 报告 → 循环到 stop_flag。 +/// `archiver` 由 caller 在同步路径上已经尝试创建好(成功 → Some / 失败 → None), +/// 这里只负责把它穿透到 build_input_stream 给 cpal callback 用。 fn run_audio_thread( microphone_device_name: Option, consumer: Arc, level_handler: Arc, - audio_archive_path: Option, + archiver: Option>>, stop_flag: Arc, startup_tx: Sender>, runtime_error_tx: Sender, ) { - let archiver = audio_archive_path.and_then(|path| match WavArchiver::create(&path) { - Ok(arch) => Some(Arc::new(Mutex::new(arch))), - Err(err) => { - // 写盘失败不阻塞录音:debug 归档失效但听写主路径正常。 - log::warn!("[recorder] wav archive create failed at {path:?}: {err}"); - None - } - }); let (stream, state) = match build_input_stream( microphone_device_name, consumer, From 67d1277361c7f4a16226da35a775bfda9a148368 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 14 May 2026 21:15:18 +0800 Subject: [PATCH 3/4] fix: address pr_agent Stale closure + Missing file check (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr_agent 二轮 review 又提了 2 个 issue,本 commit 解决: Stale closure (AutoUpdateGate.tsx) - 原 useEffect 依赖 [enabled],tick 闭包捕获渲染时的 u;u.status 变化时 tick 读不到新值,极端时序下 60min interval 触发的 tick 可能在用户已经手动打开 UpdateDialog 的情况下错过 busy 检查,并发触发第二次 checkForUpdates。 - 修:useRef(u) + 每次渲染同步 uRef.current = u;tick 读 uRef.current 始终拿 最新 useAutoUpdate 返回值,避免 stale 状态判断。 Missing file check (History.tsx) - hasAudioRecording 只代表「录音时 wav 写盘成功」,但 audioRecordingMaxEntries / historyRetentionDays 之后 prune 可能已删 wav。前端无条件渲染按钮 → click → 后端返回 'recording not found'。属于 UX 不优雅(功能有 error UI 兜住)。 - 修:父组件加 audioMissingIds: Set;AudioRecordingPlayer 加 onMissing 回调,遇 'not found' 错误时调用父让 id 加入 set;导出按钮 catch 同样错误 也 mark missing;按钮组渲染条件改为 hasAudioRecording && !audioMissingIds. has(id),一次点击后永久隐藏,不再让用户撞同样的 error。 cargo test 258 / tsc 0 error。 --- .../app/src/components/AutoUpdateGate.tsx | 17 +++--- openless-all/app/src/pages/History.tsx | 52 ++++++++++++++++--- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/openless-all/app/src/components/AutoUpdateGate.tsx b/openless-all/app/src/components/AutoUpdateGate.tsx index 230b3070..a1953dac 100644 --- a/openless-all/app/src/components/AutoUpdateGate.tsx +++ b/openless-all/app/src/components/AutoUpdateGate.tsx @@ -2,7 +2,7 @@ // 受 prefs.autoUpdateCheck 开关控制;关闭时只走 Settings → 关于 的手动按钮。 // 找到新版本时直接挂 UpdateDialog;不弹自定义通知,沿用既有 dialog 视觉。 -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; @@ -14,14 +14,22 @@ export function AutoUpdateGate() { const u = useAutoUpdate(); const enabled = prefs?.autoUpdateCheck ?? true; + // 用 ref 保持 tick 闭包始终读到最新的 useAutoUpdate 返回值。 + // 之前直接捕获 `u` 会让 60min interval 触发时读旧 status 闭包——例如用户已经 + // 手动打开 UpdateDialog 后,tick 仍可能错过 busy 检查触发并发 check。 + // 修 pr_agent "Stale closure" 反馈。 + const uRef = useRef(u); + uRef.current = u; + useEffect(() => { if (!enabled) return; let cancelled = false; const tick = () => { if (cancelled) return; - if (u.checking || u.busy || isDialogStatus(u.status)) return; - void u.checkForUpdates().catch(error => { + const current = uRef.current; + if (current.checking || current.busy || isDialogStatus(current.status)) return; + void current.checkForUpdates().catch(error => { console.warn('[auto-update] background check failed', error); }); }; @@ -33,9 +41,6 @@ export function AutoUpdateGate() { window.clearTimeout(startupTimer); window.clearInterval(intervalTimer); }; - // checkForUpdates / status 故意不放依赖:tick 内部已经做了忙碌态短路, - // 把 hook 返回值塞进依赖会让 interval 在每次 status 变化时重建,反而漏 tick。 - // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled]); if (!isDialogStatus(u.status)) return null; diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index c1429305..f030fe9b 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -44,6 +44,20 @@ export function History() { const [loadError, setLoadError] = useState(null); const [actionError, setActionError] = useState(null); const [justCopied, setJustCopied] = useState(false); + // 录音文件 lazily-detected missing 状态:retention / 条数 cap 清理后磁盘上 wav + // 可能已被删,但 history 条目 hasAudioRecording 仍写 true。任一组件 + // (播放 / 导出)首次 IPC 拿到 'recording not found' 时把 id 加进来, + // 之后渲染按钮的条件就转 false,避免反复点击得到同样的 error。 + // 修 pr_agent "Missing file check" 反馈。 + const [audioMissingIds, setAudioMissingIds] = useState>(() => new Set()); + const markAudioMissing = useCallback((id: string) => { + setAudioMissingIds(prev => { + if (prev.has(id)) return prev; + const next = new Set(prev); + next.add(id); + return next; + }); + }, []); const { prefs } = useHotkeySettings(); const refresh = useCallback(async () => { @@ -139,7 +153,13 @@ export function History() { setActionError(null); } catch (error) { console.error('[history] failed to export recording', error); - setActionError(t('history.exportFailed', { err: errorMessage(error) })); + const msg = errorMessage(error); + // wav 已被 retention / 条数 cap 清理:把按钮隐藏,不显示错误(用户没干错事)。 + if (msg.includes('recording not found') || msg.includes('not found')) { + markAudioMissing(item.id); + return; + } + setActionError(t('history.exportFailed', { err: msg })); } }; @@ -245,14 +265,18 @@ export function History() {
void onCopy()}>{justCopied ? t('common.copied') : t('common.copy')} - {item.hasAudioRecording && ( + {item.hasAudioRecording && !audioMissingIds.has(item.id) && ( void onExportAudio()}>{t('history.exportRecording')} )} {t('common.delete')}
- {item.hasAudioRecording && ( - + {item.hasAudioRecording && !audioMissingIds.has(item.id) && ( + markAudioMissing(item.id)} + key={item.id} + /> )}
@@ -303,8 +327,16 @@ function errorMessage(error: unknown): string { } /** 当 session.hasAudioRecording 为 true 时渲染:一个加载按钮 + 拿到字节后切换为 - * 原生 audio controls。Blob URL 在组件 unmount 时 revoke,避免泄漏。 */ -function AudioRecordingPlayer({ sessionId }: { sessionId: string }) { + * 原生 audio controls。Blob URL 在组件 unmount 时 revoke,避免泄漏。 + * `onMissing` 在后端返回 'recording not found'(wav 已被 prune)时触发,让父组件 + * 把按钮永久隐藏,避免用户继续点击得到同样错误。 */ +function AudioRecordingPlayer({ + sessionId, + onMissing, +}: { + sessionId: string; + onMissing?: () => void; +}) { const { t } = useTranslation(); const [url, setUrl] = useState(null); const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle'); @@ -331,8 +363,14 @@ function AudioRecordingPlayer({ sessionId }: { sessionId: string }) { setStatus('ready'); } catch (error) { console.error('[history] load recording failed', error); + const msg = errorMessage(error); + // 文件被清理:通知父组件隐藏按钮组,自身不显示 error UI(用户没干错事)。 + if (msg.includes('recording not found') || msg.includes('not found')) { + onMissing?.(); + return; + } setStatus('error'); - setErrorText(errorMessage(error)); + setErrorText(msg); } }; From 3125de88fb9426cd18c55384c94ba904bf8ae553 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 14 May 2026 21:31:47 +0800 Subject: [PATCH 4/4] fix: normalize TOCTOU NotFound in read_audio_recording (round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr_agent round 3 提的 Missing-file handling 兜底: read_audio_recording 在 exists() 检查后到 tokio::fs::read 之间,文件可能因 prune(条数 cap / retention 清理 / 用户手动删)而消失。原实现把 NotFound 错 格式化成 'read wav failed: No such file...' 这种 OS 原文,前端字符串匹配 'recording not found' 接不住,UI 显示一次 generic error 而不是隐藏按钮。 修法:read 失败时显式判 ErrorKind::NotFound → 返回跟 exists() 失败同样的 'recording not found' 字符串。前端单条 catch 就稳,不依赖本地化 OS 错误。 注:pr_agent 同轮提的 "Build break"(arch.lock() 返回 Result)是 false positive —— recorder.rs 用 parking_lot::Mutex 而非 std::sync::Mutex,.lock() 直接返回 MutexGuard 无 Result。本地 cargo test 258 全过、3 平台 CI build 都绿可证。 cargo test 258 全过。 --- openless-all/app/src-tauri/src/commands.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 9368ec9c..da1f70b2 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -1137,9 +1137,16 @@ pub async fn read_audio_recording(session_id: String) -> Result, String> if !path.exists() { return Err("recording not found".into()); } - tokio::fs::read(&path) - .await - .map_err(|e| format!("read wav failed: {e}")) + // TOCTOU 兜底:exists() 通过到 read 之间文件可能被 prune(条数 cap / retention + // 清理 / 用户手动删)。把 NotFound 标准化成跟 exists() 失败同样的错误字符串, + // 前端单条 'recording not found' catch 就能稳定隐藏按钮,不依赖本地化 OS 错误。 + tokio::fs::read(&path).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + "recording not found".into() + } else { + format!("read wav failed: {e}") + } + }) } /// UUID-v4 字面校验:36 字符 + 5 段 `-` 分隔(8-4-4-4-12)+ 仅 ASCII 十六进制。