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:
21 files changed
+791
-36
No files matched your search
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user