Merge pull request #436 from Open-Less/feat/voice-quality-audio-archive

feat: polish hotword overrides, audio archive, auto-update check
This commit is contained in:
TRIP authored and GitHub committed 2026-05-15 08:40:35 +08:00
commit a0c239a716
21 files changed
+791 -36

No files matched your search

+86 -5
View File
@@ -436,8 +436,8 @@ pub async fn start_microphone_level_monitor(
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = Arc::new(move |level| {
let _ = level_app.emit("microphone:level", serde_json::json!({ "level": level }));
});
let (recorder, _runtime_errors) =
Recorder::start(microphone_device_name, consumer, level_handler)
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);
Ok(())
@@ -1115,6 +1115,59 @@ pub fn clear_history(coord: CoordinatorState<'_>) -> Result<(), String> {
coord.history().clear().map_err(|e| e.to_string())
}
/// 读取某次会话的原始麦克风 wav 字节流。仅当用户开过
/// `prefs.record_audio_for_debug` 并且这条 session 是开关打开后录的,才会有文件。
/// 文件名规约:`<data_dir>/recordings/<session_id>.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<Vec<u8>, 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());
}
// 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 十六进制。
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 +2278,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 +2995,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"));
}
}
+23 -6
View File
@@ -116,6 +116,11 @@ struct Inner {
#[cfg(target_os = "windows")]
foundry_local_runtime: Arc<FoundryLocalRuntime>,
recorder: Mutex<Option<SessionResource<Recorder>>>,
/// 当前 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<SharedRecordingMuteState>,
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
@@ -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()),
@@ -2584,8 +2591,15 @@ async fn begin_qa_session(inner: &Arc<Inner>) -> 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) {
Ok((rec, runtime_errors)) => {
// QA 默认不留痕(qa_save_history 默认 false),录音文件归档也跟着不开。
// 调试 QA 麦克风请用主听写路径。
match Recorder::start(microphone_device_name, consumer, level_handler, None) {
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。
@@ -2853,11 +2867,14 @@ async fn end_qa_session(inner: &Arc<Inner>) -> 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}");
}
}
@@ -785,8 +785,31 @@ 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) {
Ok((rec, runtime_errors)) => {
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, 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。
@@ -1230,11 +1253,17 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
error_code: Some("emptyTranscript".to_string()),
duration_ms: Some(raw.duration_ms),
dictionary_entry_count: Some(enabled_phrases(inner).len() as u32),
// empty-transcript(ASR 没识别到任何文字)也保留 wav 标记——这是用户最想
// 通过原始录音定位"是不是麦克风太小声 / ASR 模型问题"的场景。修 pr_agent
// "Missing Audio" 反馈。
has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)),
};
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 +1536,11 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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 旁路写盘的 `<session_id>.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 +1555,15 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
// 历史详情页的"X 个热词"显示:用本次实际命中次数(每个匹配实例算一次),
// 比"启用词条总数"更能反映本段口述命中了多少。u64 → u32 截断对单段听写足够。
dictionary_entry_count: Some(total_hits.min(u32::MAX as u64) as u32),
// 用 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, 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 {
+1
View File
@@ -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,
+94 -4
View File
@@ -139,6 +139,90 @@ pub fn local_models_root() -> Result<PathBuf> {
Ok(dir)
}
/// 录音归档目录:`<data_dir>/recordings/`。
/// 仅当用户开 `prefs.record_audio_for_debug` 时才会有内容(每次会话一个 `<session_id>.wav`)。
/// 同样受 `history_retention_days` 清理(写入新文件时顺手裁旧的)。
pub fn recordings_root() -> Result<PathBuf> {
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<u32>) -> 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<PathBuf> {
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<u32>,
) -> 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)
}
+2 -2
View File
@@ -1751,7 +1751,7 @@ fn compose_system_prompt(style_system_prompt: &str, hotwords: &[String]) -> Stri
.collect::<Vec<_>>()
.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::<Vec<_>>()
.join("\n");
format!(
"热词(用户希望以下写法在输出中保持准确;当转写中出现这些词的同音或形近误识别时,优先按上述写法输出,不做无关词的机械替换):\n{}",
"热词(用户希望以下写法在输出中保持准确;当转写中出现这些词的同音或形近误识别时,优先按上述写法输出,不做无关词的机械替换):\n{}\n\n上述热词的纠偏指令优先于通用规则 2 的「原样保留」——当转写词是热词的同音 / 形近误识别(例:转写出「VIP」而热词列表里有「ZIP」),就按热词写法输出,不要因为它看起来像英文专有名词或中英混输而保留误识别结果。",
bullets
)
}
+103 -1
View File
@@ -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,13 +61,20 @@ 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 长度。
///
/// 返回值第三个 `bool` = "archive 实际成功创建":caller 写 history 时应当用这个值
/// 决定 `has_audio_recording`,而不是 prefs 开关。开关打开但写盘失败(路径不存在 /
/// 权限不足 / 磁盘满)时仍返回 false,避免前端渲染播放按钮后端却 404。
///
/// 实际的 cpal Stream 在独立线程里构造、播放、最终析构——因为它 `!Send`。
pub fn start(
microphone_device_name: Option<String>,
consumer: Arc<dyn AudioConsumer>,
level_handler: Arc<dyn Fn(f32) + Send + Sync>,
) -> Result<(Self, Receiver<RecorderError>), RecorderError> {
audio_archive_path: Option<PathBuf>,
) -> Result<(Self, Receiver<RecorderError>, bool), RecorderError> {
// 启动信号:子线程构造 Stream 完成后通过 startup_tx 报告结果。
let (startup_tx, startup_rx) = channel::<Result<(), RecorderError>>();
// 运行期错误:Stream 已成功启动后,cpal 通过 err_cb 异步上报。
@@ -74,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 || {
@@ -81,6 +100,7 @@ impl Recorder {
microphone_device_name,
consumer,
level_handler,
archiver,
stop_for_thread,
startup_tx,
runtime_error_tx,
@@ -101,6 +121,7 @@ impl Recorder {
join_handle: Mutex::new(Some(join_handle)),
},
runtime_error_rx,
archive_active,
))
}
@@ -144,10 +165,13 @@ pub fn list_input_devices() -> Result<Vec<MicrophoneDevice>, RecorderError> {
}
/// 音频线程主体:构造 Stream → 通过 startup_tx 报告 → 循环到 stop_flag。
/// `archiver` 由 caller 在同步路径上已经尝试创建好(成功 → Some / 失败 → None),
/// 这里只负责把它穿透到 build_input_stream 给 cpal callback 用。
fn run_audio_thread(
microphone_device_name: Option<String>,
consumer: Arc<dyn AudioConsumer>,
level_handler: Arc<dyn Fn(f32) + Send + Sync>,
archiver: Option<Arc<Mutex<WavArchiver>>>,
stop_flag: Arc<AtomicBool>,
startup_tx: Sender<Result<(), RecorderError>>,
runtime_error_tx: Sender<RecorderError>,
@@ -156,6 +180,7 @@ fn run_audio_thread(
microphone_device_name,
consumer,
level_handler,
archiver,
runtime_error_tx.clone(),
) {
Ok(s) => s,
@@ -275,6 +300,7 @@ fn build_input_stream(
microphone_device_name: Option<String>,
consumer: Arc<dyn AudioConsumer>,
level_handler: Arc<dyn Fn(f32) + Send + Sync>,
archiver: Option<Arc<Mutex<WavArchiver>>>,
runtime_error_tx: Sender<RecorderError>,
) -> Result<(cpal::Stream, Arc<StreamState>), RecorderError> {
let host = cpal::default_host();
@@ -304,6 +330,7 @@ fn build_input_stream(
sample_format,
consumer,
level_handler,
archiver,
Arc::clone(&state),
input_sr,
channels,
@@ -368,6 +395,7 @@ fn build_stream_for_format(
sample_format: SampleFormat,
consumer: Arc<dyn AudioConsumer>,
level_handler: Arc<dyn Fn(f32) + Send + Sync>,
archiver: Option<Arc<Mutex<WavArchiver>>>,
state: Arc<StreamState>,
input_sr: u32,
channels: usize,
@@ -377,6 +405,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 +427,7 @@ fn build_stream_for_format(
input_sr,
consumer.as_ref(),
level_handler.as_ref(),
archiver.as_deref(),
&state,
);
},
@@ -461,6 +491,7 @@ fn process_callback(
input_sr: u32,
consumer: &dyn AudioConsumer,
level_handler: &(dyn Fn(f32) + Send + Sync),
archiver: Option<&Mutex<WavArchiver>>,
state: &StreamState,
) {
if interleaved.is_empty() || channels == 0 {
@@ -479,6 +510,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 +654,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<Self> {
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 +800,7 @@ mod tests {
8_000,
&consumer,
&move |level| levels_for_handler.lock().unwrap().push(level),
None,
&state,
);
@@ -726,6 +824,7 @@ mod tests {
TARGET_SAMPLE_RATE,
&consumer,
&move |level| levels_for_handler.lock().unwrap().push(level),
None,
&state,
);
@@ -749,6 +848,7 @@ mod tests {
TARGET_SAMPLE_RATE,
&consumer,
&move |level| levels_for_handler.lock().unwrap().push(level),
None,
&state,
);
@@ -773,6 +873,7 @@ mod tests {
TARGET_SAMPLE_RATE,
&consumer,
&move |level| levels_for_handler.lock().unwrap().push(level),
None,
&state,
);
process_callback(
@@ -781,6 +882,7 @@ mod tests {
TARGET_SAMPLE_RATE,
&consumer,
&move |level| levels.lock().unwrap().push(level),
None,
&state,
);
+57 -3
View File
@@ -98,6 +98,11 @@ pub struct DictationSession {
pub error_code: Option<String>,
pub duration_ms: Option<u64>,
pub dictionary_entry_count: Option<u32>,
/// 当 `prefs.record_audio_for_debug` 开启时,本次会话的原始麦克风音频被写到
/// `recordings/<id>.wav`。前端凭这个字段决定是否在 History 渲染播放按钮。
/// `None` / `Some(false)` 都按"无录音"处理;旧 JSON 不带这字段也兼容。
#[serde(default)]
pub has_audio_recording: Option<bool>,
}
#[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<u32>,
/// 是否为每次会话保留原始麦克风音频文件(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<u32>,
}
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<u32>,
#[serde(default)]
record_audio_for_debug: bool,
#[serde(default)]
audio_recording_max_entries: Option<u32>,
}
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,
}
}
}
+2
View File
@@ -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 (
<HotkeySettingsProvider>
{gate === 'onboarding' ? <Onboarding onComplete={() => setGate('ready')} /> : <FloatingShell />}
{gate === 'ready' && <AutoUpdateGate />}
</HotkeySettingsProvider>
);
}
@@ -0,0 +1,58 @@
// 主窗口启动 + 后台每 60 分钟自动调一次 plugin-updater check。
// 受 prefs.autoUpdateCheck 开关控制;关闭时只走 Settings → 关于 的手动按钮。
// 找到新版本时直接挂 UpdateDialog;不弹自定义通知,沿用既有 dialog 视觉。
import { useEffect, useRef } 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;
// 用 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;
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);
});
};
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);
};
}, [enabled]);
if (!isDialogStatus(u.status)) return null;
return (
<UpdateDialog
status={u.status}
version={u.version}
progress={u.progress}
downloaded={u.downloaded}
contentLength={u.contentLength}
onInstall={u.installUpdate}
onClose={u.dismissDialog}
/>
);
}
+4
View File
@@ -55,6 +55,10 @@ export const ICONS: Record<string, string> = {
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 {
+13
View File
@@ -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}}',
+13
View File
@@ -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}}',
+13
View File
@@ -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}}',
+13
View File
@@ -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}}',
+13
View File
@@ -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}}',
+21
View File
@@ -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<void> {
return invokeOrMock('clear_history', undefined, () => undefined);
}
/** 读取某次会话的原始麦克风 wav 字节流。仅当 prefs.recordAudioForDebug 当时打开
* 并且文件没被 retention 清理掉时才有内容;其他情况后端会返回 "recording not found" 错。
* 调用方应仅在 session.hasAudioRecording === true 时触发,避免无效 IPC。 */
export function readAudioRecording(sessionId: string): Promise<Uint8Array> {
return invokeOrMock(
'read_audio_recording',
{ sessionId },
() => new Uint8Array(),
).then(value => {
// Tauri 默认把 Vec<u8> 序列化为 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<DictionaryEntry[]> {
return invokeOrMock('list_vocab', undefined, () => mockVocab);
@@ -61,6 +61,10 @@ const previousPrefs: UserPreferences = {
updateChannel: 'stable',
streamingInsert: false,
streamingInsertSaveClipboard: true,
autoUpdateCheck: true,
historyMaxEntries: null,
recordAudioForDebug: false,
audioRecordingMaxEntries: null,
};
const nextPrefs: UserPreferences = {
+14
View File
@@ -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 {
+143 -4
View File
@@ -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,21 @@ export function History() {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(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<Set<string>>(() => 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 () => {
@@ -102,9 +117,50 @@ 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);
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 }));
}
};
return (
@@ -208,10 +264,20 @@ export function History() {
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>{formatDuration(item.durationMs, t)}</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<Btn icon="copy" variant="ghost" size="sm" onClick={onCopy}>{t('common.copy')}</Btn>
<Btn icon={justCopied ? 'check' : 'copy'} variant="ghost" size="sm" onClick={() => void onCopy()}>{justCopied ? t('common.copied') : t('common.copy')}</Btn>
{item.hasAudioRecording && !audioMissingIds.has(item.id) && (
<Btn icon="download" variant="ghost" size="sm" onClick={() => void onExportAudio()}>{t('history.exportRecording')}</Btn>
)}
<Btn icon="trash" variant="ghost" size="sm" onClick={onDelete}>{t('common.delete')}</Btn>
</div>
</div>
{item.hasAudioRecording && !audioMissingIds.has(item.id) && (
<AudioRecordingPlayer
sessionId={item.id}
onMissing={() => markAudioMissing(item.id)}
key={item.id}
/>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div style={{ padding: 14, border: '0.5px solid var(--ol-line)', borderRadius: 10, background: 'var(--ol-surface-2)' }}>
<Pill size="sm" tone="outline" style={{ marginBottom: 10 }}>{t('history.rawLabel')}</Pill>
@@ -260,6 +326,79 @@ function errorMessage(error: unknown): string {
return String(error);
}
/** 当 session.hasAudioRecording 为 true 时渲染:一个加载按钮 + 拿到字节后切换为
* 原生 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<string | null>(null);
const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
const [errorText, setErrorText] = useState<string | null>(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);
const msg = errorMessage(error);
// 文件被清理:通知父组件隐藏按钮组,自身不显示 error UI(用户没干错事)。
if (msg.includes('recording not found') || msg.includes('not found')) {
onMissing?.();
return;
}
setStatus('error');
setErrorText(msg);
}
};
if (status === 'ready' && url) {
return (
<div style={{ marginBottom: 14 }}>
<audio src={url} controls preload="auto" autoPlay style={{ width: '100%' }} />
</div>
);
}
return (
<div style={{ marginBottom: 14, display: 'flex', alignItems: 'center', gap: 10 }}>
<Btn
icon="play"
variant="ghost"
size="sm"
onClick={() => void load()}
disabled={status === 'loading'}
>
{status === 'loading' ? t('history.audioLoading') : t('history.playRecording')}
</Btn>
{status === 'error' && (
<span style={{ fontSize: 11, color: 'var(--ol-err)' }}>{errorText}</span>
)}
</div>
);
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
+67
View File
@@ -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' }}
/>
</SettingRow>
<SettingRow
label={t('settings.recording.historyMaxEntriesLabel')}
desc={t('settings.recording.historyMaxEntriesDesc')}
>
<input
type="number"
min={5}
max={200}
placeholder="200"
value={prefs.historyMaxEntries ?? ''}
onChange={e => onHistoryMaxEntriesChange(e.target.value)}
style={{ ...inputStyle, width: 80, textAlign: 'right' }}
/>
</SettingRow>
<SettingRow
label={t('settings.recording.polishContextWindowLabel')}
desc={t('settings.recording.polishContextWindowDesc')}
@@ -529,6 +569,27 @@ function RecordingSection() {
style={{ ...inputStyle, width: 80, textAlign: 'right' }}
/>
</SettingRow>
<SettingRow
label={t('settings.recording.recordAudioForDebugLabel')}
desc={t('settings.recording.recordAudioForDebugDesc')}
>
<Toggle on={prefs.recordAudioForDebug} onToggle={onRecordAudioForDebugChange} />
</SettingRow>
<SettingRow
label={t('settings.recording.audioRecordingMaxEntriesLabel')}
desc={t('settings.recording.audioRecordingMaxEntriesDesc')}
>
<input
type="number"
min={1}
max={200}
placeholder="200"
value={prefs.audioRecordingMaxEntries ?? ''}
onChange={e => onAudioRecordingMaxEntriesChange(e.target.value)}
style={{ ...inputStyle, width: 80, textAlign: 'right' }}
disabled={!prefs.recordAudioForDebug}
/>
</SettingRow>
</Collapsible>
{/* ─── 启动(折叠) ──────────────────────────────────────────── */}
@@ -540,6 +601,12 @@ function RecordingSection() {
>
<Toggle on={prefs.startMinimized} onToggle={onStartMinimizedChange} />
</SettingRow>
<SettingRow
label={t('settings.recording.autoUpdateCheckLabel')}
desc={t('settings.recording.autoUpdateCheckDesc')}
>
<Toggle on={prefs.autoUpdateCheck} onToggle={onAutoUpdateCheckChange} />
</SettingRow>
{capability.statusHint && (
<div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
{capability.statusHint}