diff --git a/openless-all/app/src-tauri/src/persistence/history.rs b/openless-all/app/src-tauri/src/persistence/history.rs index 0e50792f..ad4215e4 100644 --- a/openless-all/app/src-tauri/src/persistence/history.rs +++ b/openless-all/app/src-tauri/src/persistence/history.rs @@ -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"; @@ -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, @@ -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) } diff --git a/openless-all/app/src-tauri/src/persistence/paths.rs b/openless-all/app/src-tauri/src/persistence/paths.rs index fbafdfa8..e29daa23 100644 --- a/openless-all/app/src-tauri/src/persistence/paths.rs +++ b/openless-all/app/src-tauri/src/persistence/paths.rs @@ -166,7 +166,7 @@ pub fn local_models_root() -> Result { /// 录音归档目录:`/recordings/`。 /// 仅当用户开 `prefs.record_audio_for_debug` 时才会有内容(每次会话一个 `.wav`)。 -/// 同样受 `history_retention_days` 清理(写入新文件时顺手裁旧的)。 +/// 默认仍按旧策略清理录音文件,避免文本历史不限后 wav 无限增长。 pub fn recordings_root() -> Result { let dir = data_dir()?.join("recordings"); ensure_dir(&dir)?; @@ -174,7 +174,8 @@ pub fn recordings_root() -> Result { } /// 双重 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,避免影响主路径。 @@ -189,6 +190,7 @@ pub fn prune_recordings(retention_days: u32, max_entries: Option) -> 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); diff --git a/openless-all/app/src-tauri/src/persistence/preferences.rs b/openless-all/app/src-tauri/src/persistence/preferences.rs index 1f3fbb4a..e03d113a 100644 --- a/openless-all/app/src-tauri/src/persistence/preferences.rs +++ b/openless-all/app/src-tauri/src/persistence/preferences.rs @@ -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}; @@ -25,22 +25,31 @@ fn read_preferences(path: &Path) -> Result { // issue #440:老版本可能已把旧默认 `streamingInsert:false` 写进 preferences.json。 // 反序列化会在内存里迁到 true,但还必须把迁移标记落盘,否则每次启动都停留在 // “旧文件”状态,无法表达用户后续手动关闭后的 durable opt-out。 - let streaming_default_migrated = serde_json::from_slice::(&bytes) - .ok() + let raw = serde_json::from_slice::(&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 ), @@ -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")) @@ -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); + } } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 775a455a..f5419051 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -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 分钟。 @@ -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, /// 是否为每次会话保留原始麦克风音频文件(wav)到 `recordings/` 目录, /// 用于排查 ASR 误识别 / 麦克风灵敏度问题。默认 false。开启会占磁盘空间, - /// 受 `history_retention_days` 同样的清理策略约束。 + /// 文本历史不限天数时,录音仍按旧默认天数清理。 #[serde(default)] pub record_audio_for_debug: bool, /// `recordings/` 里保留的最近 wav 文件数(按 mtime 倒序保留最新的)。 @@ -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 { @@ -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)] @@ -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, @@ -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, @@ -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, @@ -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(), diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index f645b70c..5a770f21 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -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', @@ -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', @@ -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)', @@ -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', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index ca1c4dd0..0231a57d 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -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', @@ -279,7 +281,7 @@ export const ja: typeof zhCN = { metricNoData: 'データなし', historyLoadError: '履歴の読み込みに失敗', metricTotal: '累計記録', - metricTotalTrend: 'ローカル保存(上限 200)', + metricTotalTrend: 'ローカル保存', weekTitle: '直近 7 日', weekUnit: '件 / 日', recentTitle: '最近の認識', @@ -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: '元の録音を保持(デバッグ)', @@ -936,6 +938,8 @@ export const ja: typeof zhCN = { system: 'システムに従う', light: 'ライト', dark: 'ダーク', + overviewActivityLabel: '概要の活動ヒートマップを表示', + overviewActivityDesc: '今日の概要の下部に日別 / 週別 / 累計の活動ヒートマップを表示します。オフにしても履歴は削除されません。', }, remoteInput: { title: 'リモート入力', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 36a0619b..e6b1b930 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -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', @@ -279,7 +281,7 @@ export const ko: typeof zhCN = { metricNoData: '데이터 없음', historyLoadError: '기록 로드 실패', metricTotal: '누적 기록', - metricTotalTrend: '로컬 보관(상한 200)', + metricTotalTrend: '로컬 보관', weekTitle: '최근 7일', weekUnit: '건/일', recentTitle: '최근 인식', @@ -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: '원본 녹음 보관(디버그)', @@ -936,6 +938,8 @@ export const ko: typeof zhCN = { system: '시스템 따르기', light: '라이트', dark: '다크', + overviewActivityLabel: '개요 활동 히트맵 표시', + overviewActivityDesc: '오늘의 개요 하단에 일별 / 주별 / 누적 활동 히트맵을 표시합니다. 꺼도 기록은 삭제되지 않습니다.', }, remoteInput: { title: '원격 입력', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index fad45064..2738ba9e 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -24,8 +24,10 @@ export const zhCN = { copied: '已复制', operationFailed: '操作失败', add: '添加', - durationSeconds: '{{value}} 秒', - durationMinutes: '{{value}} 分钟', + unlimited: '无限制', + durationSeconds: '{{value}}s', + durationMinutes: '{{value}}min', + durationHours: '{{value}}h', }, capsule: { thinking: 'thinking', @@ -275,7 +277,7 @@ export const zhCN = { metricNoData: '暂无数据', historyLoadError: '历史读取失败', metricTotal: '累计记录', - metricTotalTrend: '本机存档 (上限 200)', + metricTotalTrend: '本机存档', weekTitle: '近 7 天', weekUnit: '条数 / 天', recentTitle: '最近识别', @@ -686,9 +688,9 @@ export const zhCN = { allowNonTsfFallbackDesc: 'Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。', historyGroupTitle: '历史与上下文', historyRetentionLabel: '历史保留天数', - historyRetentionDesc: '超过保留天数的历史在写入新条目时被清理;0 = 不按时间清理。', + historyRetentionDesc: '超过保留天数的历史在写入新条目时被清理;0 / 留空 = 不按时间清理。', historyMaxEntriesLabel: '历史条数上限', - historyMaxEntriesDesc: '本地保留会话上限,留空 = 200。范围 5–200。', + historyMaxEntriesDesc: '本地保留会话上限,0 / 留空 = 不按条数清理。', polishContextWindowLabel: '对话上下文窗口(分钟)', polishContextWindowDesc: '把最近 N 分钟内已润色的转写作为多轮上下文,0 = 关闭。', recordAudioForDebugLabel: '保留原始录音(调试)', @@ -966,6 +968,8 @@ export const zhCN = { system: '跟随系统', light: '浅色', dark: '深色', + overviewActivityLabel: '显示概况活动热图', + overviewActivityDesc: '在今日概况底部显示每日 / 每周 / 累计活动热图;关闭不会删除历史记录。', }, remoteInput: { title: '远程输入', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 2d51e8cc..3c810cdd 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -26,8 +26,10 @@ export const zhTW: typeof zhCN = { copied: '已複製', operationFailed: '操作失敗', add: '添加', - durationSeconds: '{{value}} 秒', - durationMinutes: '{{value}} 分鐘', + unlimited: '無限制', + durationSeconds: '{{value}}s', + durationMinutes: '{{value}}min', + durationHours: '{{value}}h', }, capsule: { thinking: 'thinking', @@ -277,7 +279,7 @@ export const zhTW: typeof zhCN = { metricNoData: '暫無數據', historyLoadError: '歷史讀取失敗', metricTotal: '累計記錄', - metricTotalTrend: '本機存檔 (上限 200)', + metricTotalTrend: '本機存檔', weekTitle: '近 7 天', weekUnit: '條數 / 天', recentTitle: '最近識別', @@ -688,9 +690,9 @@ export const zhTW: 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: '保留原始錄音(除錯)', @@ -934,6 +936,8 @@ export const zhTW: typeof zhCN = { system: '跟隨系統', light: '淺色', dark: '深色', + overviewActivityLabel: '顯示概況活動熱圖', + overviewActivityDesc: '在今日概況底部顯示每日 / 每週 / 累計活動熱圖;關閉不會刪除歷史記錄。', }, remoteInput: { title: '遠端輸入', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index b7681a1b..520f1569 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -81,7 +81,7 @@ export let mockSettings: UserPreferences = { sherpaOnnxModel: "sense-voice-small-zh", sherpaOnnxLanguageHint: "", sherpaOnnxKeepLoadedSecs: 300, - historyRetentionDays: 7, + historyRetentionDays: 0, polishContextWindowMinutes: 5, startMinimized: false, themeMode: "system", diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 06a986f7..bd366be6 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -68,7 +68,7 @@ const previousPrefs: UserPreferences = { sherpaOnnxModel: 'sense-voice-small-zh', sherpaOnnxLanguageHint: '', sherpaOnnxKeepLoadedSecs: 300, - historyRetentionDays: 7, + historyRetentionDays: 0, polishContextWindowMinutes: 5, startMinimized: false, themeMode: 'system', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 161fa0f0..5de26a58 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -327,7 +327,7 @@ export interface UserPreferences { sherpaOnnxLanguageHint: string; /** Windows sherpa-onnx 模型在 runtime 中保持加载的秒数。 */ sherpaOnnxKeepLoadedSecs: number; - /** 历史记录保留天数。0 = 不按时间清理(仍受 200 条上限)。默认 7。 */ + /** 历史记录保留天数。0 = 不按时间清理。默认 0(不限天数)。 */ historyRetentionDays: number; /** 对话感知 polish 上下文窗口(分钟)。0 = 关闭。默认 5。详见 PR-A。 */ polishContextWindowMinutes: number; @@ -354,10 +354,10 @@ export interface UserPreferences { * 桌面:开启后自动检查,发现更新弹窗由用户确认安装。 * 关闭后仅 Settings 手动「检查更新」按钮可用。 */ autoUpdateCheck: boolean; - /** 历史记录上限(条数)。null = 走默认 200;5..=200 之间为用户自定义。 */ + /** 历史记录上限(条数)。null = 不按条数清理。 */ historyMaxEntries: number | null; /** 是否为每次会话保留原始麦克风音频文件(wav),用于排查 ASR 误识别 / 麦克风灵敏度。 - * 默认 false。开启后会占磁盘空间,受 historyRetentionDays 同样的清理策略约束。 */ + * 默认 false。开启后会占磁盘空间,文本历史不限天数时录音仍按旧默认天数清理。 */ recordAudioForDebug: boolean; /** recordings/ 里保留的最近 wav 文件数。null = 跟随 200 硬上限;1..=200 之间为用户自定义。 * 跟 historyMaxEntries 解耦——「文本档案多但 wav 只留最近 5 条」是合法组合。 */ diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 6e9d77ff..433ca1fa 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; +import { Modal } from '../components/ui/Modal'; import { formatComboLabel } from '../lib/hotkey'; import { getCredentials, listHistory } from '../lib/ipc'; import { useMobileLayout } from '../lib/useMobileLayout'; @@ -52,6 +53,9 @@ const LLM_NAME_KEY_BY_ID: Record = { custom: 'custom', }; +// ponytail: single estimate for a feel-good stat; make it a setting only if users ask to calibrate it. +const TYPING_CHARS_PER_MINUTE = 100; + export function Overview({ onOpenHistory }: OverviewProps) { const { t } = useTranslation(); const mobile = useMobileLayout(); @@ -67,6 +71,7 @@ export function Overview({ onOpenHistory }: OverviewProps) { volcengineConfigured: false, arkConfigured: false, }); + const [shareOpen, setShareOpen] = useState(false); const { prefs } = useHotkeySettings(); const credentialsRequestSeq = useRef(0); @@ -141,7 +146,10 @@ export function Overview({ onOpenHistory }: OverviewProps) { const segmentsToday = todays.length; const totalDurationMs = todays.reduce((acc, s) => acc + (s.durationMs ?? 0), 0); const avgLatencyMs = segmentsToday > 0 ? totalDurationMs / segmentsToday : 0; - return { charsToday, segmentsToday, totalDurationMs, avgLatencyMs }; + const typingMs = (charsToday / TYPING_CHARS_PER_MINUTE) * 60000; + const savedMs = totalDurationMs > 0 ? Math.max(0, typingMs - totalDurationMs) : 0; + const speedRatio = totalDurationMs > 0 ? typingMs / totalDurationMs : 0; + return { charsToday, segmentsToday, totalDurationMs, avgLatencyMs, savedMs, speedRatio }; }, [history]); // 周历:过去 7 天每天的条数 @@ -169,10 +177,24 @@ export function Overview({ onOpenHistory }: OverviewProps) { const llmProviderName = llmNameKey ? t(`settings.providers.presets.${llmNameKey}`) : llmProviderId; + const shareDisabled = historyError || metrics.charsToday <= 0; return ( <> - + setShareOpen(true)} + > + 分享 + + } + />
- + 0 ? t('overview.metricAvgTrend') : t('overview.metricNoData')} />
@@ -243,6 +265,14 @@ export function Overview({ onOpenHistory }: OverviewProps) { + + {shareOpen && ( + setShareOpen(false)} + /> + )} ); } @@ -316,6 +346,405 @@ function Metric({ icon, label, value, trend, accent }: MetricProps) { ); } +interface OverviewMetrics { + charsToday: number; + segmentsToday: number; + totalDurationMs: number; + avgLatencyMs: number; + savedMs: number; + speedRatio: number; +} + +function calculateStreak(history: DictationSession[]): number { + if (history.length === 0) return 0; + const dates = Array.from(new Set( + history.map(s => { + const d = new Date(s.createdAt); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + }) + )).sort((a, b) => b.localeCompare(a)); + + if (dates.length === 0) return 0; + + const todayStr = localDateKey(new Date()); + const yesterdayStr = localDateKey(addDays(new Date(), -1)); + + let currentIdx = dates.indexOf(todayStr); + if (currentIdx === -1) { + currentIdx = dates.indexOf(yesterdayStr); + } + if (currentIdx === -1) { + return 0; + } + + let streak = 1; + let checkDate = new Date(dates[currentIdx]); + + while (true) { + checkDate = addDays(checkDate, -1); + const prevDateStr = localDateKey(checkDate); + if (dates.includes(prevDateStr)) { + streak++; + } else { + break; + } + } + + return streak; +} + +function generatePortrait(sessions: DictationSession[]): { role: string; desc: string } { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todays = sessions.filter(s => new Date(s.createdAt) >= today); + const combinedText = todays.map(s => s.finalText).join(' '); + + const matches = { + dev: { + keywords: ['代码', '接口', '编译', '报错', '调试', '构建', '测试', 'bug', '运行', '部署', '数据库', '函数', '组件', '前端', '后端', '开发'], + role: '在调试系统逻辑的研发探索者', + desc: '今天你像一个在调试系统逻辑的研发探索者。常说“代码、调试、接口”,主要在做模块开发和排查,专注于逻辑的稳定流转。' + }, + pm: { + keywords: ['产品', '需求', '设计', '原型', '规划', '文档', '流程', '排期', '上线', '改动', '方案', '业务', '版本', '用户'], + role: '在拆解任务的产品技术协作者', + desc: '今天你像一个在拆解任务的产品技术协作者。常说“整理、结构、删除、同步”,主要在处理项目梳理和版本推进。' + }, + comm: { + keywords: ['沟通', '对接', '反馈', '确认', '同步', '跟进', '汇报', '对齐', '会议', '群里', '微信', '电话', '通知', '沟通'], + role: '追求高效对齐进度的团队联络员', + desc: '今天你像一个追求高效对齐进度的团队联络员。常说“反馈、确认、同步、跟进”,主要在处理跨团队沟通,消除信息不对称。' + }, + organizer: { + keywords: ['整理', '结构', '删除', '提取', '梳理', '要点', '记录', '文档', '大纲', '笔记', '写完', '分析', '研究', '内容'], + role: '在精炼核心观点的思考整理者', + desc: '今天你像一个在精炼核心观点的思考整理者。常说“整理、要点、梳理”,主要在做碎片想法的整理提炼,让思维更有结构。' + } + }; + + let bestMatch: keyof typeof matches = 'organizer'; + let maxCount = 0; + + for (const key of Object.keys(matches) as Array) { + let count = 0; + for (const kw of matches[key].keywords) { + const regex = new RegExp(kw, 'gi'); + count += (combinedText.match(regex) || []).length; + } + if (count > maxCount) { + maxCount = count; + bestMatch = key; + } + } + + if (maxCount === 0) { + const keys = Object.keys(matches) as Array; + const index = Math.abs(combinedText.length) % keys.length; + bestMatch = keys[index] || 'organizer'; + } + + return { + role: matches[bestMatch].role, + desc: matches[bestMatch].desc + }; +} + +function formatHeaderDate(): string { + const now = new Date(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const m = months[now.getMonth()]; + const d = String(now.getDate()).padStart(2, '0'); + const y = now.getFullYear(); + return `${m}/${d} ${y}`; +} + +function OverviewShareModal({ metrics, history, onClose }: { metrics: OverviewMetrics; history: DictationSession[]; onClose: () => void }) { + const [status, setStatus] = useState(null); + const [styleTheme, setStyleTheme] = useState<'dark' | 'light'>('dark'); + const [hoveredIdx, setHoveredIdx] = useState(null); + + const communitySvg = shareCommunitySvg(metrics, history, styleTheme); + const dateKey = localDateKey(new Date()); + + const chars = metrics.charsToday.toLocaleString(); + const timeParts = getSavedTimeParts(metrics.savedMs); + const speedRatioVal = metrics.speedRatio > 0 ? metrics.speedRatio.toFixed(1) : '1.0'; + + const savePng = async () => { + try { + setStatus('正在生成图片...'); + const blob = await svgToPngBlob(communitySvg, 1080, 1350); + downloadBlob(blob, `openless-${dateKey}-share.png`); + setStatus('图片下载已启动,请在浏览器下载记录中查看。'); + } catch (error) { + console.error('[overview-share] png export failed', error); + setStatus('PNG 生成失败,请重试。'); + } + }; + + const shareText = `今天用 OpenLess 少敲了 ${chars} 字,预计节省 ${timeParts.value} ${timeParts.unit},嘴比手快 ${speedRatioVal} 倍。#OpenLess`; + const shareTargets = [ + { label: '微信', icon: 'wechat', bg: '#07C160', shadow: 'rgba(7, 193, 96, 0.32)' }, + { label: 'X', icon: 'x', bg: '#0f1419', shadow: 'rgba(15, 20, 25, 0.35)' }, + { label: 'QQ', icon: 'qq', bg: '#12B7F5', shadow: 'rgba(18, 183, 245, 0.32)' }, + { label: '微博', icon: 'weibo', bg: '#E6162D', shadow: 'rgba(230, 22, 45, 0.32)' }, + ] as const; + + const copySharePack = async (platform: string) => { + try { + setStatus(`正在准备${platform}图片和文案...`); + const blob = await svgToPngBlob(communitySvg, 1080, 1350); + // ponytail: browser clipboard is enough for this demo; native share sheet comes later if users ask. + if (navigator.clipboard?.write && 'ClipboardItem' in window) { + await navigator.clipboard.write([ + new ClipboardItem({ + 'image/png': blob, + 'text/plain': new Blob([shareText], { type: 'text/plain' }), + }), + ]); + setStatus(`${platform}图片和文案已复制,打开${platform}后直接粘贴。`); + return; + } + await navigator.clipboard?.writeText(shareText); + setStatus(`${platform}文案已复制;浏览器暂不支持复制图片,请先保存图片再粘贴。`); + } catch (error) { + console.error('[overview-share] clipboard share failed', error); + try { + await navigator.clipboard?.writeText(shareText); + setStatus(`${platform}文案已复制;图片请用“保存本地”后手动上传。`); + } catch { + setStatus('剪贴板不可用,请先保存图片,再复制下方文案。'); + } + } + }; + + return ( + +
+
+
分享今日效率
+
支持两种风格,可直接下载或分享。
+
+ 关闭 +
+ + {/* 风格切换 */} +
+ + +
+ + {/* 预览卡片 */} +
+
+ +
+
+ +