Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions openless-all/app/src-tauri/src/persistence/history.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))]
//! Dictation history store: newest-first JSON list with retention + count caps.
//! Dictation history store: newest-first JSON list with optional retention + count caps.

use std::path::PathBuf;

use anyhow::{Context, Result};
use parking_lot::Mutex;

use super::{atomic_write, data_dir, ensure_dir, read_or_default, HISTORY_CAP};
use super::{atomic_write, data_dir, ensure_dir, read_or_default};
use crate::types::DictationSession;

const HISTORY_FILE: &str = "history.json";
Expand Down Expand Up @@ -43,9 +43,7 @@ impl HistoryStore {

/// `retention_days == 0` 跟旧 append 行为一致(不按时间清理)。
/// `> 0` 时在写入新条目后顺手把超过 N 天的会话裁掉,写入时就完成清理,
/// 不需要后台轮询。最后再受条数上限约束:
/// - `max_entries == None` → HISTORY_CAP (200)
/// - `max_entries == Some(n)` → clamp 到 5..=HISTORY_CAP,避免用户填 0 / 极大值。
/// 不需要后台轮询。`max_entries == None` 时不按条数裁剪。
pub fn append_with_retention(
&self,
session: DictationSession,
Expand All @@ -65,11 +63,11 @@ impl HistoryStore {
.unwrap_or(true)
});
}
let cap = max_entries
.map(|n| (n as usize).clamp(5, HISTORY_CAP))
.unwrap_or(HISTORY_CAP);
if sessions.len() > cap {
sessions.truncate(cap);
if let Some(max_entries) = max_entries {
let cap = (max_entries as usize).max(1);
if sessions.len() > cap {
sessions.truncate(cap);
}
}
self.write_locked(&sessions)
}
Expand Down
6 changes: 4 additions & 2 deletions openless-all/app/src-tauri/src/persistence/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,16 @@ pub fn local_models_root() -> Result<PathBuf> {

/// 录音归档目录:`<data_dir>/recordings/`。
/// 仅当用户开 `prefs.record_audio_for_debug` 时才会有内容(每次会话一个 `<session_id>.wav`)。
/// 同样受 `history_retention_days` 清理(写入新文件时顺手裁旧的)
/// 默认仍按旧策略清理录音文件,避免文本历史不限后 wav 无限增长
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 逻辑)。
/// - `retention_days > 0` → 把超过 N 天的删掉。
/// - `retention_days == 0` → 文本历史不限天数,但录音仍按 7 天默认清理。
/// - `max_entries == Some(n)` → 按 mtime 倒序保留最新的 n 条(clamp 到 1..=HISTORY_CAP);
/// `None` 时退回 HISTORY_CAP (200) 硬上限,避免无限增长。
/// 调用方:每次新建一条录音前。失败仅打 warn,避免影响主路径。
Expand All @@ -189,6 +190,7 @@ pub fn prune_recordings(retention_days: u32, max_entries: Option<u32>) -> Result

// 第一步:按天清理。仅扫 .wav,跟第二步保持一致;metadata 读不到的文件按"过期"处理
// —— fs 损坏 / 未来格式不一致的孤儿文件应当被回收而不是无限累积。
let retention_days = if retention_days == 0 { 7 } else { retention_days };
if retention_days > 0 {
let cutoff = std::time::SystemTime::now()
- std::time::Duration::from_secs(u64::from(retention_days) * 24 * 3600);
Expand Down
62 changes: 56 additions & 6 deletions openless-all/app/src-tauri/src/persistence/preferences.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))]
//! User preferences store: a single JSON document held in memory behind a lock,
//! with a one-time `streamingInsert` default migration on load.
//! with one-time default migrations on load.

use std::fs;
use std::path::{Path, PathBuf};
Expand All @@ -25,22 +25,31 @@ fn read_preferences(path: &Path) -> Result<UserPreferences> {
// issue #440:老版本可能已把旧默认 `streamingInsert:false` 写进 preferences.json。
// 反序列化会在内存里迁到 true,但还必须把迁移标记落盘,否则每次启动都停留在
// “旧文件”状态,无法表达用户后续手动关闭后的 durable opt-out。
let streaming_default_migrated = serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
let raw = serde_json::from_slice::<serde_json::Value>(&bytes).ok();
let streaming_default_migrated = raw
.as_ref()
.and_then(|value| {
value
.get("streamingInsertDefaultMigrated")
.and_then(|flag| flag.as_bool())
})
.unwrap_or(false);
if !streaming_default_migrated {
let history_retention_default_migrated = raw
.as_ref()
.and_then(|value| {
value
.get("historyRetentionDefaultMigrated")
.and_then(|flag| flag.as_bool())
})
.unwrap_or(false);
if !streaming_default_migrated || !history_retention_default_migrated {
match serde_json::to_vec_pretty(&prefs)
.context("encode prefs failed")
.and_then(|json| atomic_write(path, &json))
{
Ok(()) => log::info!("[prefs] migrated streamingInsert default marker"),
Ok(()) => log::info!("[prefs] migrated preferences default markers"),
Err(err) => log::warn!(
"[prefs] failed to persist streamingInsert migration marker for {}: {}",
"[prefs] failed to persist preferences migration markers for {}: {}",
path.display(),
err
),
Expand Down Expand Up @@ -123,6 +132,7 @@ mod tests {
let prefs = read_preferences(&path).expect("read prefs");
assert!(prefs.streaming_insert);
assert!(prefs.streaming_insert_default_migrated);
assert!(prefs.history_retention_default_migrated);

let saved: serde_json::Value =
serde_json::from_slice(&fs::read(&path).expect("read saved prefs"))
Expand All @@ -142,4 +152,44 @@ mod tests {

let _ = fs::remove_dir_all(&tmp);
}

#[test]
fn legacy_history_retention_default_is_migrated_to_unlimited() {
let tmp: PathBuf = std::env::temp_dir().join(format!(
"openless-history-retention-prefs-test-{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&tmp).expect("create temp dir");
let path = tmp.join("preferences.json");
fs::write(
&path,
r#"{
"historyRetentionDays": 7,
"streamingInsertDefaultMigrated": true
}"#,
)
.expect("write legacy prefs");

let prefs = read_preferences(&path).expect("read prefs");
assert_eq!(prefs.history_retention_days, 0);
assert!(prefs.history_retention_default_migrated);

let saved: serde_json::Value =
serde_json::from_slice(&fs::read(&path).expect("read saved prefs"))
.expect("decode saved prefs");
assert_eq!(
saved
.get("historyRetentionDays")
.and_then(|value| value.as_u64()),
Some(0)
);
assert_eq!(
saved
.get("historyRetentionDefaultMigrated")
.and_then(|value| value.as_bool()),
Some(true)
);

let _ = fs::remove_dir_all(&tmp);
}
}
28 changes: 22 additions & 6 deletions openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -713,10 +713,14 @@ pub struct UserPreferences {
/// 手动检查按钮显式指定 channel,与此 pref 解耦。
#[serde(default)]
pub update_channel: UpdateChannel,
/// 历史记录保留天数。0 = 不按时间清理(仅受 200 条上限)。默认 7 天
/// 历史记录保留天数。0 = 不按时间清理。默认 0(不限天数)
/// 写入新条目时执行清理,避免后台轮询。
#[serde(default = "default_history_retention_days")]
pub history_retention_days: u32,
/// 老版本会把默认 7 天写进 preferences.json。缺少此标记且值仍为 7 时,
/// 升级后迁到 0(不限);用户显式设置的其他天数保留。
#[serde(default)]
pub history_retention_default_migrated: bool,
/// 对话感知 polish 的上下文窗口(分钟):把最近 N 分钟的转写 + 已润色文本
/// 作为多轮上下文喂给 LLM,让代词 / 不完整句子能被正确解析。
/// 0 = 关闭(每次润色独立单轮,跟历史行为一致)。默认 5 分钟。
Expand Down Expand Up @@ -764,13 +768,13 @@ pub struct UserPreferences {
/// 关闭后仅 Settings 手动「检查更新」按钮可用。
#[serde(default = "default_true")]
pub auto_update_check: bool,
/// 历史记录上限(条数)。`None` = 使用代码内 200 条硬上限
/// `Some(n)` 表示用户在 Settings 自定义了上限(5..=200 之间)
/// 历史记录上限(条数)。`None` = 不按条数清理
/// `Some(n)` 表示用户在 Settings 自定义了上限。
#[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 倒序保留最新的)。
Expand Down Expand Up @@ -820,7 +824,7 @@ fn default_remote_input_mode() -> String {
}

fn default_history_retention_days() -> u32 {
7
0
}

fn default_polish_context_window_minutes() -> u32 {
Expand Down Expand Up @@ -950,6 +954,8 @@ struct UserPreferencesWire {
update_channel: UpdateChannel,
#[serde(default = "default_history_retention_days")]
history_retention_days: u32,
#[serde(default)]
history_retention_default_migrated: bool,
#[serde(default = "default_polish_context_window_minutes")]
polish_context_window_minutes: u32,
#[serde(default)]
Expand Down Expand Up @@ -1047,6 +1053,7 @@ impl Default for UserPreferencesWire {
sherpa_onnx_keep_loaded_secs: prefs.sherpa_onnx_keep_loaded_secs,
update_channel: prefs.update_channel,
history_retention_days: prefs.history_retention_days,
history_retention_default_migrated: prefs.history_retention_default_migrated,
polish_context_window_minutes: prefs.polish_context_window_minutes,
start_minimized: prefs.start_minimized,
theme_mode: prefs.theme_mode,
Expand Down Expand Up @@ -1086,6 +1093,13 @@ impl<'de> Deserialize<'de> for UserPreferences {
} else {
true
};
let history_retention_default_migrated = wire.history_retention_default_migrated;
let history_retention_days =
if !history_retention_default_migrated && wire.history_retention_days == 7 {
0
} else {
wire.history_retention_days
};

Ok(Self {
hotkey: wire.hotkey,
Expand Down Expand Up @@ -1154,7 +1168,8 @@ impl<'de> Deserialize<'de> for UserPreferences {
sherpa_onnx_language_hint: wire.sherpa_onnx_language_hint,
sherpa_onnx_keep_loaded_secs: wire.sherpa_onnx_keep_loaded_secs,
update_channel: wire.update_channel,
history_retention_days: wire.history_retention_days,
history_retention_days,
history_retention_default_migrated: true,
polish_context_window_minutes: wire.polish_context_window_minutes,
start_minimized: wire.start_minimized,
theme_mode: wire.theme_mode,
Expand Down Expand Up @@ -1891,6 +1906,7 @@ impl Default for UserPreferences {
sherpa_onnx_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
update_channel: UpdateChannel::default(),
history_retention_days: default_history_retention_days(),
history_retention_default_migrated: true,
polish_context_window_minutes: default_polish_context_window_minutes(),
start_minimized: false,
theme_mode: ThemeMode::default(),
Expand Down
12 changes: 8 additions & 4 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ export const en: typeof zhCN = {
copied: 'Copied',
operationFailed: 'Operation failed',
add: 'Add',
unlimited: 'Unlimited',
durationSeconds: '{{value}}s',
durationMinutes: '{{value}}m',
durationMinutes: '{{value}}min',
durationHours: '{{value}}h',
},
capsule: {
thinking: 'thinking',
Expand Down Expand Up @@ -277,7 +279,7 @@ export const en: typeof zhCN = {
metricNoData: 'No data',
historyLoadError: 'History load failed',
metricTotal: 'Total records',
metricTotalTrend: 'Local archive (max 200)',
metricTotalTrend: 'Local archive',
weekTitle: 'Last 7 days',
weekUnit: 'count / day',
recentTitle: 'Recent transcripts',
Expand Down Expand Up @@ -688,9 +690,9 @@ export const en: typeof zhCN = {
allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.',
historyGroupTitle: 'History & context',
historyRetentionLabel: 'History retention (days)',
historyRetentionDesc: 'Entries older than this are pruned on new writes; 0 = no time-based pruning.',
historyRetentionDesc: 'Entries older than this are pruned on new writes; 0 / blank = no time-based pruning.',
historyMaxEntriesLabel: 'Max history entries',
historyMaxEntriesDesc: 'Max sessions retained locally. Blank = 200. Range 5–200.',
historyMaxEntriesDesc: 'Max sessions retained locally. 0 / blank = no count-based pruning.',
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)',
Expand Down Expand Up @@ -968,6 +970,8 @@ export const en: typeof zhCN = {
system: 'Follow system',
light: 'Light',
dark: 'Dark',
overviewActivityLabel: 'Show overview activity heatmap',
overviewActivityDesc: 'Show the daily / weekly / cumulative activity heatmap at the bottom of Overview. Turning this off does not delete history.',
},
remoteInput: {
title: 'Remote Input',
Expand Down
14 changes: 9 additions & 5 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ export const ja: typeof zhCN = {
copied: 'コピーしました',
operationFailed: '操作に失敗しました',
add: '追加',
durationSeconds: '{{value}} 秒',
durationMinutes: '{{value}} 分',
unlimited: '無制限',
durationSeconds: '{{value}}s',
durationMinutes: '{{value}}min',
durationHours: '{{value}}h',
},
capsule: {
thinking: 'thinking',
Expand Down Expand Up @@ -279,7 +281,7 @@ export const ja: typeof zhCN = {
metricNoData: 'データなし',
historyLoadError: '履歴の読み込みに失敗',
metricTotal: '累計記録',
metricTotalTrend: 'ローカル保存(上限 200)',
metricTotalTrend: 'ローカル保存',
weekTitle: '直近 7 日',
weekUnit: '件 / 日',
recentTitle: '最近の認識',
Expand Down Expand Up @@ -690,9 +692,9 @@ export const ja: typeof zhCN = {
allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。',
historyGroupTitle: '履歴とコンテキスト',
historyRetentionLabel: '履歴保持期間(日)',
historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。',
historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 / 空欄 = 時間で削除しない。',
historyMaxEntriesLabel: '履歴件数の上限',
historyMaxEntriesDesc: 'ローカル保持セッション上限。空欄 = 200。範囲 5–200。',
historyMaxEntriesDesc: 'ローカル保持セッション上限。0 / 空欄 = 件数で削除しない。',
polishContextWindowLabel: '会話コンテキスト窓(分)',
polishContextWindowDesc: '直近 N 分間の整文済み転写をマルチターン文脈として渡します。0 = 無効。',
recordAudioForDebugLabel: '元の録音を保持(デバッグ)',
Expand Down Expand Up @@ -936,6 +938,8 @@ export const ja: typeof zhCN = {
system: 'システムに従う',
light: 'ライト',
dark: 'ダーク',
overviewActivityLabel: '概要の活動ヒートマップを表示',
overviewActivityDesc: '今日の概要の下部に日別 / 週別 / 累計の活動ヒートマップを表示します。オフにしても履歴は削除されません。',
},
remoteInput: {
title: 'リモート入力',
Expand Down
14 changes: 9 additions & 5 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ export const ko: typeof zhCN = {
copied: '복사됨',
operationFailed: '작업 실패',
add: '추가',
durationSeconds: '{{value}}초',
durationMinutes: '{{value}}분',
unlimited: '무제한',
durationSeconds: '{{value}}s',
durationMinutes: '{{value}}min',
durationHours: '{{value}}h',
},
capsule: {
thinking: 'thinking',
Expand Down Expand Up @@ -279,7 +281,7 @@ export const ko: typeof zhCN = {
metricNoData: '데이터 없음',
historyLoadError: '기록 로드 실패',
metricTotal: '누적 기록',
metricTotalTrend: '로컬 보관(상한 200)',
metricTotalTrend: '로컬 보관',
weekTitle: '최근 7일',
weekUnit: '건/일',
recentTitle: '최근 인식',
Expand Down Expand Up @@ -690,9 +692,9 @@ export const ko: typeof zhCN = {
allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.',
historyGroupTitle: '기록 및 컨텍스트',
historyRetentionLabel: '기록 보관 기간(일)',
historyRetentionDesc: '보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 = 시간 기반 정리 비활성화.',
historyRetentionDesc: '보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 / 빈칸 = 시간 기반 정리 비활성화.',
historyMaxEntriesLabel: '기록 개수 상한',
historyMaxEntriesDesc: '로컬 보관 세션 상한. 빈칸 = 200. 범위 5–200.',
historyMaxEntriesDesc: '로컬 보관 세션 상한. 0 / 빈칸 = 개수 기반 정리 비활성화.',
polishContextWindowLabel: '대화 컨텍스트 윈도(분)',
polishContextWindowDesc: '최근 N 분간 정리된 전사를 멀티턴 컨텍스트로 전달합니다. 0 = 비활성화.',
recordAudioForDebugLabel: '원본 녹음 보관(디버그)',
Expand Down Expand Up @@ -936,6 +938,8 @@ export const ko: typeof zhCN = {
system: '시스템 따르기',
light: '라이트',
dark: '다크',
overviewActivityLabel: '개요 활동 히트맵 표시',
overviewActivityDesc: '오늘의 개요 하단에 일별 / 주별 / 누적 활동 히트맵을 표시합니다. 꺼도 기록은 삭제되지 않습니다.',
},
remoteInput: {
title: '원격 입력',
Expand Down
Loading