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
Binary file added docs/images/pr-716/cumulative-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/pr-716/heatmap-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/pr-716/heatmap-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/pr-716/hover-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/pr-716/settings-toggle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/pr-716/weekly-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
65 changes: 55 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 Expand Up @@ -133,3 +131,50 @@ impl HistoryStore {
atomic_write(&self.path, &json)
}
}

#[cfg(test)]
mod tests {
use super::HistoryStore;
use crate::types::{DictationSession, InsertStatus, PolishMode};
use parking_lot::Mutex;

fn session(id: usize) -> DictationSession {
DictationSession {
id: format!("session-{id}"),
created_at: "2026-01-01T00:00:00Z".into(),
raw_transcript: String::new(),
final_text: format!("text {id}"),
mode: PolishMode::Light,
style_pack_id: None,
translation_active: false,
polish_source: None,
app_bundle_id: None,
app_name: None,
insert_status: InsertStatus::Inserted,
error_code: None,
duration_ms: Some(1000),
dictionary_entry_count: None,
has_audio_recording: None,
}
}

#[test]
fn append_with_no_max_entries_keeps_more_than_legacy_cap() {
let dir =
std::env::temp_dir().join(format!("openless-history-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("create temp dir");
let store = HistoryStore {
path: dir.join("history.json"),
lock: Mutex::new(()),
};

for id in 0..205 {
store
.append_with_retention(session(id), 0, None)
.expect("append session");
}

assert_eq!(store.list().expect("list history").len(), 205);
let _ = std::fs::remove_dir_all(&dir);
}
}
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);
}
}
34 changes: 29 additions & 5 deletions openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -712,10 +712,14 @@ pub struct UserPreferences {
/// 一个手动下载 Beta 的入口。不影响 plugin-updater 的自动检查路径。
#[serde(default)]
pub update_channel: UpdateChannel,
/// 历史记录保留天数。0 = 不按时间清理(仅受 200 条上限)。默认 7 天
/// 历史记录保留天数。0 = 不按时间清理(仅受 200 条上限)。默认 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 All @@ -729,6 +733,9 @@ pub struct UserPreferences {
/// UI theme: follow OS, force light, or force dark. Frontend applies via data-ol-theme.
#[serde(default)]
pub theme_mode: ThemeMode,
/// Whether the Overview page shows the annual activity heatmap. Default true.
#[serde(default = "default_true")]
pub show_overview_activity_heatmap: bool,
/// 流式输入:润色 SSE 一边到达一边逐字模拟键盘事件输出到当前焦点。开启后用户感知到
/// 的处理时延显著降低(润色 LLM 第一个 token 即开始落字)。
///
Expand Down Expand Up @@ -762,8 +769,8 @@ 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/` 目录,
Expand Down Expand Up @@ -818,7 +825,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 @@ -946,13 +953,17 @@ 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)]
start_minimized: bool,
#[serde(default)]
theme_mode: ThemeMode,
#[serde(default = "default_true")]
show_overview_activity_heatmap: bool,
#[serde(default = "default_true")]
streaming_insert: bool,
#[serde(default)]
streaming_insert_default_migrated: bool,
Expand Down Expand Up @@ -1042,9 +1053,11 @@ 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,
show_overview_activity_heatmap: prefs.show_overview_activity_heatmap,
streaming_insert: prefs.streaming_insert,
streaming_insert_default_migrated: prefs.streaming_insert_default_migrated,
streaming_insert_save_clipboard: prefs.streaming_insert_save_clipboard,
Expand Down Expand Up @@ -1081,6 +1094,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 @@ -1148,10 +1168,12 @@ 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,
show_overview_activity_heatmap: wire.show_overview_activity_heatmap,
streaming_insert,
streaming_insert_default_migrated: true,
streaming_insert_save_clipboard: wire.streaming_insert_save_clipboard,
Expand Down Expand Up @@ -1884,9 +1906,11 @@ 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(),
show_overview_activity_heatmap: true,
streaming_insert: true,
streaming_insert_default_migrated: true,
streaming_insert_save_clipboard: true,
Expand Down
28 changes: 24 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,9 +279,25 @@ 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',
activityTitle: 'Annual activity',
activityModeDaily: 'Daily',
activityMode: {
daily: 'Daily',
weekly: 'Weekly',
cumulative: 'Total',
},
activityUnit: 'Past year · {{days}} active days',
activityEmpty: 'No history in the past year.',
activityTooltip: '{{date}} · {{count}} records · {{chars}} chars',
activitySummaryDaily: '{{date}} used {{count}} records / {{chars}} chars / {{duration}}',
activitySummaryWeekly: 'Week of {{start}} used {{count}} records / {{chars}} chars / {{duration}}',
activitySummaryCumulative: 'Through week of {{start}} used {{count}} records / {{chars}} chars / {{duration}}',
activityLegendLow: 'Less',
activityLegendHigh: 'More',
monthNames: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
recentTitle: 'Recent transcripts',
recentAll: 'View all →',
recentEmpty: 'No records yet. Press {{trigger}} to start your first recording.',
Expand Down Expand Up @@ -686,9 +704,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 @@ -966,6 +984,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
Loading