diff --git a/docs/images/pr-716/cumulative-dark.png b/docs/images/pr-716/cumulative-dark.png new file mode 100644 index 00000000..50c14b89 Binary files /dev/null and b/docs/images/pr-716/cumulative-dark.png differ diff --git a/docs/images/pr-716/heatmap-dark.png b/docs/images/pr-716/heatmap-dark.png new file mode 100644 index 00000000..04c1ceb8 Binary files /dev/null and b/docs/images/pr-716/heatmap-dark.png differ diff --git a/docs/images/pr-716/heatmap-light.png b/docs/images/pr-716/heatmap-light.png new file mode 100644 index 00000000..6c22de23 Binary files /dev/null and b/docs/images/pr-716/heatmap-light.png differ diff --git a/docs/images/pr-716/hover-dark.png b/docs/images/pr-716/hover-dark.png new file mode 100644 index 00000000..424a277b Binary files /dev/null and b/docs/images/pr-716/hover-dark.png differ diff --git a/docs/images/pr-716/settings-toggle.png b/docs/images/pr-716/settings-toggle.png new file mode 100644 index 00000000..f03fcd28 Binary files /dev/null and b/docs/images/pr-716/settings-toggle.png differ diff --git a/docs/images/pr-716/weekly-dark.png b/docs/images/pr-716/weekly-dark.png new file mode 100644 index 00000000..ae19a181 Binary files /dev/null and b/docs/images/pr-716/weekly-dark.png differ diff --git a/openless-all/app/src-tauri/src/persistence/history.rs b/openless-all/app/src-tauri/src/persistence/history.rs index 0e50792f..2778a9c5 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) } @@ -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); + } +} 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 3348ee0e..54ef48cf 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -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 分钟。 @@ -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 即开始落字)。 /// @@ -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, /// 是否为每次会话保留原始麦克风音频文件(wav)到 `recordings/` 目录, @@ -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 { @@ -946,6 +953,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)] @@ -953,6 +962,8 @@ struct UserPreferencesWire { #[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, @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index fcf71ca3..29106a91 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,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.', @@ -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)', @@ -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', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 257bfb7f..f240d8a5 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,9 +281,25 @@ export const ja: typeof zhCN = { metricNoData: 'データなし', historyLoadError: '履歴の読み込みに失敗', metricTotal: '累計記録', - metricTotalTrend: 'ローカル保存(上限 200)', + metricTotalTrend: 'ローカル保存', weekTitle: '直近 7 日', weekUnit: '件 / 日', + activityTitle: '年間アクティビティ', + activityModeDaily: '毎日', + activityMode: { + daily: '毎日', + weekly: '毎週', + cumulative: '累計', + }, + activityUnit: '過去 1 年 · {{days}} 日に記録', + activityEmpty: '過去 1 年の履歴はまだありません。', + activityTooltip: '{{date}} · {{count}} 件 · {{chars}} 文字', + activitySummaryDaily: '{{date}} に {{count}} 件 / {{chars}} 文字 / {{duration}}', + activitySummaryWeekly: '{{start}} の週に {{count}} 件 / {{chars}} 文字 / {{duration}}', + activitySummaryCumulative: '{{start}} の週まで累計 {{count}} 件 / {{chars}} 文字 / {{duration}}', + activityLegendLow: '少', + activityLegendHigh: '多', + monthNames: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'], recentTitle: '最近の認識', recentAll: 'すべて表示 →', recentEmpty: '記録がありません。{{trigger}} を押して最初の録音を始めましょう。', @@ -688,9 +706,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: '元の録音を保持(デバッグ)', @@ -934,6 +952,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 395f92e1..b7dd9408 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,9 +281,25 @@ export const ko: typeof zhCN = { metricNoData: '데이터 없음', historyLoadError: '기록 로드 실패', metricTotal: '누적 기록', - metricTotalTrend: '로컬 보관(상한 200)', + metricTotalTrend: '로컬 보관', weekTitle: '최근 7일', weekUnit: '건/일', + activityTitle: '연간 활동', + activityModeDaily: '매일', + activityMode: { + daily: '매일', + weekly: '매주', + cumulative: '누적', + }, + activityUnit: '지난 1년 · {{days}}일 기록 있음', + activityEmpty: '지난 1년 동안 기록이 없습니다.', + activityTooltip: '{{date}} · {{count}}건 · {{chars}}자', + activitySummaryDaily: '{{date}} 사용 {{count}}건 / {{chars}}자 / {{duration}}', + activitySummaryWeekly: '{{start}} 주간 사용 {{count}}건 / {{chars}}자 / {{duration}}', + activitySummaryCumulative: '{{start}} 주까지 누적 사용 {{count}}건 / {{chars}}자 / {{duration}}', + activityLegendLow: '적음', + activityLegendHigh: '많음', + monthNames: ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'], recentTitle: '최근 인식', recentAll: '전체 보기 →', recentEmpty: '아직 기록이 없습니다. {{trigger}} 를 눌러 첫 녹음을 시작하세요.', @@ -688,9 +706,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: '원본 녹음 보관(디버그)', @@ -934,6 +952,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 3147b416..7ecb1abd 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,9 +277,25 @@ export const zhCN = { metricNoData: '暂无数据', historyLoadError: '历史读取失败', metricTotal: '累计记录', - metricTotalTrend: '本机存档 (上限 200)', + metricTotalTrend: '本机存档', weekTitle: '近 7 天', weekUnit: '条数 / 天', + activityTitle: '年度活动', + activityModeDaily: '每日', + activityMode: { + daily: '每日', + weekly: '每周', + cumulative: '累计', + }, + activityUnit: '过去一年 · {{days}} 天有记录', + activityEmpty: '过去一年还没有历史记录。', + activityTooltip: '{{date}} · {{count}} 条 · {{chars}} 字', + activitySummaryDaily: '{{date}} 使用了 {{count}} 条 / {{chars}} 字 / {{duration}}', + activitySummaryWeekly: '{{start}} 当周使用了 {{count}} 条 / {{chars}} 字 / {{duration}}', + activitySummaryCumulative: '截至 {{start}} 当周累计使用了 {{count}} 条 / {{chars}} 字 / {{duration}}', + activityLegendLow: '少', + activityLegendHigh: '多', + monthNames: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'], recentTitle: '最近识别', recentAll: '全部记录 →', recentEmpty: '还没有记录。按 {{trigger}} 开始第一次录音。', @@ -684,9 +702,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: '保留原始录音(调试)', @@ -964,6 +982,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 2fe58825..ca44924a 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,9 +279,25 @@ export const zhTW: typeof zhCN = { metricNoData: '暫無數據', historyLoadError: '歷史讀取失敗', metricTotal: '累計記錄', - metricTotalTrend: '本機存檔 (上限 200)', + metricTotalTrend: '本機存檔', weekTitle: '近 7 天', weekUnit: '條數 / 天', + activityTitle: '年度活動', + activityModeDaily: '每日', + activityMode: { + daily: '每日', + weekly: '每週', + cumulative: '累計', + }, + activityUnit: '過去一年 · {{days}} 天有記錄', + activityEmpty: '過去一年還沒有歷史記錄。', + activityTooltip: '{{date}} · {{count}} 條 · {{chars}} 字', + activitySummaryDaily: '{{date}} 使用了 {{count}} 條 / {{chars}} 字 / {{duration}}', + activitySummaryWeekly: '{{start}} 當週使用了 {{count}} 條 / {{chars}} 字 / {{duration}}', + activitySummaryCumulative: '截至 {{start}} 當週累計使用了 {{count}} 條 / {{chars}} 字 / {{duration}}', + activityLegendLow: '少', + activityLegendHigh: '多', + monthNames: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'], recentTitle: '最近識別', recentAll: '全部記錄 →', recentEmpty: '還沒有記錄。按 {{trigger}} 開始第一次錄音。', @@ -686,9 +704,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: '保留原始錄音(除錯)', @@ -932,6 +950,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 402f8cc8..1e057398 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -80,10 +80,12 @@ export let mockSettings: UserPreferences = { sherpaOnnxModel: "sense-voice-small-zh", sherpaOnnxLanguageHint: "", sherpaOnnxKeepLoadedSecs: 300, - historyRetentionDays: 7, + historyRetentionDays: 0, + historyRetentionDefaultMigrated: true, polishContextWindowMinutes: 5, startMinimized: false, themeMode: "system", + showOverviewActivityHeatmap: true, updateChannel: "stable", streamingInsert: true, streamingInsertDefaultMigrated: true, @@ -431,23 +433,40 @@ export const mockMicrophoneDevices: MicrophoneDevice[] = [ { name: "USB Microphone", isDefault: false }, ] -export const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ - id: `mock-${i}`, - createdAt: new Date().toISOString(), - rawTranscript: h.preview, - finalText: h.preview, - mode: "structured", - stylePackId: "builtin.structured", - translationActive: false, - polishSource: null, - appBundleId: null, - appName: "VS Code", - insertStatus: "inserted", - errorCode: null, - durationMs: 600, - dictionaryEntryCount: 28, - hasAudioRecording: null, -})) +const mockHistoryOffsets = [0, 0, 0, 1, 1, 2, 3, 3, 4, 5, 6, 15, 32, 54, 91, 130, 178, 230, 292, 340] +const mockHistoryModes: PolishMode[] = ["structured", "light", "raw", "formal"] +const mockHistoryApps = ["VS Code", "Obsidian", "Chrome", "微信", "Word"] + +function mockCreatedAt(dayOffset: number, index: number): string { + const source = OL_DATA.history[index % OL_DATA.history.length] + const [hours = 12, minutes = 0] = source.time.split(":").map(Number) + const date = new Date() + date.setDate(date.getDate() - dayOffset) + date.setHours(hours, minutes, 0, 0) + return date.toISOString() +} + +export const mockHistory: DictationSession[] = mockHistoryOffsets.map((dayOffset, i) => { + const h = OL_DATA.history[i % OL_DATA.history.length] + const mode = mockHistoryModes[i % mockHistoryModes.length] + return { + id: `mock-${dayOffset}-${i}`, + createdAt: mockCreatedAt(dayOffset, i), + rawTranscript: h.preview, + finalText: h.preview, + mode, + stylePackId: `builtin.${mode}`, + translationActive: false, + polishSource: null, + appBundleId: null, + appName: mockHistoryApps[i % mockHistoryApps.length], + insertStatus: "inserted", + errorCode: null, + durationMs: 9000 + (i % 6) * 1800, + dictionaryEntryCount: 12 + (i % 5) * 4, + hasAudioRecording: null, + } +}) export const mockVocab: DictionaryEntry[] = OL_DATA.vocab.map((v, i) => ({ id: `vocab-${i}`, diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index d049a4a5..b6d91643 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -67,10 +67,12 @@ const previousPrefs: UserPreferences = { sherpaOnnxModel: 'sense-voice-small-zh', sherpaOnnxLanguageHint: '', sherpaOnnxKeepLoadedSecs: 300, - historyRetentionDays: 7, + historyRetentionDays: 0, + historyRetentionDefaultMigrated: true, polishContextWindowMinutes: 5, startMinimized: false, themeMode: 'system', + showOverviewActivityHeatmap: true, updateChannel: 'stable', streamingInsert: true, streamingInsertDefaultMigrated: true, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index fe8c8baa..e53deb74 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -325,8 +325,10 @@ export interface UserPreferences { sherpaOnnxLanguageHint: string; /** Windows sherpa-onnx 模型在 runtime 中保持加载的秒数。 */ sherpaOnnxKeepLoadedSecs: number; - /** 历史记录保留天数。0 = 不按时间清理(仍受 200 条上限)。默认 7。 */ + /** 历史记录保留天数。0 = 不按时间清理。默认 0(不限天数)。 */ historyRetentionDays: number; + /** 旧默认 7 天是否已迁移到不限。 */ + historyRetentionDefaultMigrated: boolean; /** 对话感知 polish 上下文窗口(分钟)。0 = 关闭。默认 5。详见 PR-A。 */ polishContextWindowMinutes: number; /** 启动时静默运行(不弹主窗口)。Windows 开机自启场景常用——只想要后台 + 托盘, @@ -334,6 +336,8 @@ export interface UserPreferences { startMinimized: boolean; /** UI theme preference: follow OS, light, or dark. */ themeMode: ThemeMode; + /** Show the annual activity heatmap on the Overview page. Default true. */ + showOverviewActivityHeatmap: boolean; /** 自动更新渠道。'stable'(默认)= plugin-updater 仅检查正式版; * 'beta' = Settings → About 出现手动下载 Beta 的入口。 */ updateChannel: UpdateChannel; @@ -350,7 +354,7 @@ export interface UserPreferences { /** 主窗口启动 + 后台每 60 分钟自动检查云端新版本。默认 true。 * 关闭后仅 Settings → 关于 的「检查更新」手动按钮可用。 */ autoUpdateCheck: boolean; - /** 历史记录上限(条数)。null = 走默认 200;5..=200 之间为用户自定义。 */ + /** 历史记录上限(条数)。null = 不按条数清理。 */ historyMaxEntries: number | null; /** 是否为每次会话保留原始麦克风音频文件(wav),用于排查 ASR 误识别 / 麦克风灵敏度。 * 默认 false。开启后会占磁盘空间,受 historyRetentionDays 同样的清理策略约束。 */ diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 6e9d77ff..ff8e1c63 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -1,6 +1,6 @@ // Overview.tsx — 真实指标,从 listHistory + getCredentials 派生。 -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { formatComboLabel } from '../lib/hotkey'; @@ -52,6 +52,10 @@ const LLM_NAME_KEY_BY_ID: Record = { custom: 'custom', }; +type ActivityMode = 'daily' | 'weekly' | 'cumulative'; + +const ACTIVITY_MODES: ActivityMode[] = ['daily', 'weekly', 'cumulative']; + 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 [activityMode, setActivityMode] = useState('daily'); const { prefs } = useHotkeySettings(); const credentialsRequestSeq = useRef(0); @@ -159,6 +164,17 @@ export function Overview({ onOpenHistory }: OverviewProps) { return buckets; }, [history]); + const monthNames = useMemo( + () => t('overview.monthNames', { returnObjects: true }) as string[], + [t], + ); + + const yearlyActivity = useMemo( + () => buildYearlyActivity(history, monthNames, activityMode), + [history, monthNames, activityMode], + ); + const showActivityHeatmap = prefs?.showOverviewActivityHeatmap !== false; + const asrProviderId = creds.activeAsrProvider || 'volcengine'; const llmProviderId = creds.activeLlmProvider || 'ark'; const asrNameKey = ASR_NAME_KEY_BY_ID[asrProviderId]; @@ -189,18 +205,16 @@ export function Overview({ onOpenHistory }: OverviewProps) { /> -
+
0 ? t('overview.metricAvgTrend') : t('overview.metricNoData')} />
- {/* 底部一行 = flex:1 撑满剩余高度(父 wrapper 是 display:flex/column)。 - 只有「最近识别」内部允许滚动;其他卡片按内容自然高度,不破裂底部圆角。 - issue #243 follow-up:去掉外层 overflow 后底部圆角被裁的视觉问题。 */} -
- + {/* 近 7 天和最近识别固定一个可读高度;最近识别内部滚动,避免和年度活动互相遮挡。 */} +
+
{t('overview.weekTitle')} {t('overview.weekUnit')} @@ -212,12 +226,15 @@ export function Overview({ onOpenHistory }: OverviewProps) { ) : ( )} -
- {weekDayLabels(t('overview.weekDays', { returnObjects: true }) as string[]).map((d, i) => {d})} +
+
- +
{t('overview.recentTitle')} {t('overview.recentAll')} @@ -243,6 +260,15 @@ export function Overview({ onOpenHistory }: OverviewProps) {
+ + {showActivityHeatmap && ( + + )} ); } @@ -318,27 +344,427 @@ function Metric({ icon, label, value, trend, accent }: MetricProps) { function WeekChart({ data }: { data: number[] }) { const max = Math.max(...data, 1); + const mid = Math.ceil(max / 2); + return ( +
+
+ {max} + {mid} + 0 +
+
+
+ ); +} + +interface DayActivity { + key: string; + date: Date; + rawCount: number; + rawChars: number; + rawDurationMs: number; + count: number; + chars: number; + durationMs: number; + level: number; + inRange: boolean; +} + +interface WeekActivity { + key: string; + startDate: Date; + endDate: Date; + count: number; + chars: number; + durationMs: number; + cumulativeCount: number; + cumulativeChars: number; + cumulativeDurationMs: number; + value: number; + valueChars: number; + level: number; + inRange: boolean; +} + +interface MonthLabel { + weekIndex: number; + label: string; +} + +interface YearlyActivity { + cells: DayActivity[]; + weeks: number; + weekColumns: number; + activeDays: number; + maxCount: number; + maxWeekValue: number; + weekBars: WeekActivity[]; + monthLabels: MonthLabel[]; + weekMonthLabels: MonthLabel[]; +} + +function ActivityHeatmapCard({ + activity, + mode, + onModeChange, + historyError, +}: { + activity: YearlyActivity; + mode: ActivityMode; + onModeChange: (mode: ActivityMode) => void; + historyError: boolean; +}) { + const { t } = useTranslation(); + const mobile = useMobileLayout(); + const cardRef = useRef(null); + const [hoveredKey, setHoveredKey] = useState(null); + const [hoverPoint, setHoverPoint] = useState<{ x: number; y: number; align: 'left' | 'center' | 'right' } | null>(null); + const cellSize = 12; + const cellGap = 4; + const dayGridWidth = activity.weeks * cellSize + Math.max(0, activity.weeks - 1) * cellGap; + const weekGridWidth = activity.weekColumns * cellSize + Math.max(0, activity.weekColumns - 1) * cellGap; + const hoveredDay = mode === 'daily' && hoveredKey + ? activity.cells.find(cell => cell.key === hoveredKey) ?? null + : null; + const hoveredWeek = mode !== 'daily' && hoveredKey + ? activity.weekBars.find(week => week.key === hoveredKey) ?? null + : null; + const summaryArgs = hoveredDay + ? activityDaySummaryArgs(hoveredDay, t) + : hoveredWeek && mode !== 'daily' + ? activityWeekSummaryArgs(mode, hoveredWeek, t) + : null; + const summaryText = summaryArgs ? t(summaryArgs.key, summaryArgs.options) : null; + const showTooltip = Boolean(summaryText && hoverPoint); + const handleHover = useCallback((key: string, event: PointerEvent) => { + const cardRect = cardRef.current?.getBoundingClientRect(); + const targetRect = event.currentTarget.getBoundingClientRect(); + if (!cardRect) return; + const x = targetRect.left + targetRect.width / 2 - cardRect.left; + const y = targetRect.top - cardRect.top; + const align = x < 140 ? 'left' : x > cardRect.width - 140 ? 'right' : 'center'; + setHoveredKey(key); + setHoverPoint({ x, y, align }); + }, []); + const clearHover = useCallback(() => { + setHoveredKey(null); + setHoverPoint(null); + }, []); + + return ( + +
+
+
+ {t('overview.activityTitle')} +
+
+ {ACTIVITY_MODES.map(activityMode => { + const selected = activityMode === mode; + return ( + + ); + })} +
+
+ + {historyError ? ( +
{t('overview.historyLoadError')}
+ ) : activity.activeDays === 0 ? ( +
{t('overview.activityEmpty')}
+ ) : mode === 'daily' ? ( + + ) : ( + + )} + {showTooltip && hoverPoint && ( +
+ {summaryText} +
+ )} + + ); +} + +function ActivityDailyGrid({ + mode, + activity, + cellSize, + cellGap, + minGridWidth, + hoveredKey, + onHover, + onLeave, +}: { + mode: ActivityMode; + activity: YearlyActivity; + cellSize: number; + cellGap: number; + minGridWidth: number; + hoveredKey: string | null; + onHover: (key: string, event: PointerEvent) => void; + onLeave: () => void; +}) { + const { t } = useTranslation(); return ( -
- {data.map((v, i) => { - const isToday = i === 6; - return ( -
-
{v}
-
+
+
+
+
+ {activity.cells.map(cell => { + const selected = hoveredKey === cell.key; + const cellSummary = activityDaySummaryArgs(cell, t); + const weekIndex = Math.floor(differenceInDays(cell.date, activity.cells[0]?.date ?? cell.date) / 7); + return ( +
{ + if (cell.inRange) onHover(cell.key, event); + }} + onPointerMove={(event) => { + if (cell.inRange) onHover(cell.key, event); + }} + style={{ + width: cellSize, + height: cellSize, + borderRadius: 4, + background: activityColor(cell.level), + opacity: cell.inRange ? 1 : 0, + boxShadow: cell.inRange + ? selected + ? '0 0 0 1.5px color-mix(in srgb, var(--ol-ink) 42%, transparent) inset' + : '0 0 0 0.5px color-mix(in srgb, var(--ol-ink) 10%, transparent) inset' + : 'none', + cursor: cell.inRange ? 'default' : 'auto', + transform: 'scale(1)', + animation: cell.inRange ? `ol-activity-cell-in 0.22s var(--ol-motion-soft) ${Math.min(Math.max(0, weekIndex) * 5, 260)}ms both` : undefined, + transition: 'background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick), box-shadow 0.12s var(--ol-motion-quick)', + }} + /> + ); + })}
- ); - })} + +
+
+
+ ); +} + +function ActivityWeekGrid({ + activity, + mode, + cellSize, + cellGap, + minGridWidth, + hoveredKey, + onHover, + onLeave, +}: { + activity: YearlyActivity; + mode: Exclude; + cellSize: number; + cellGap: number; + minGridWidth: number; + hoveredKey: string | null; + onHover: (key: string, event: PointerEvent) => void; + onLeave: () => void; +}) { + const { t } = useTranslation(); + + return ( +
+
+
+
+ {activity.weekBars.flatMap((week, weekIndex) => { + const summary = activityWeekSummaryArgs(mode, week, t); + const filledCells = activityDiscreteCells(week.value, activity.maxWeekValue); + return Array.from({ length: 7 }).map((_, rowIndex) => { + const selected = hoveredKey === week.key; + const filled = rowIndex >= 7 - filledCells; + return ( +
{ + if (week.inRange) onHover(week.key, event); + }} + onPointerMove={(event) => { + if (week.inRange) onHover(week.key, event); + }} + style={{ + width: cellSize, + height: cellSize, + borderRadius: 4, + background: filled ? activityColor(week.level) : activityColor(0), + opacity: week.inRange ? 1 : 0, + boxShadow: week.inRange + ? selected + ? '0 0 0 1.5px color-mix(in srgb, var(--ol-ink) 42%, transparent) inset' + : '0 0 0 0.5px color-mix(in srgb, var(--ol-ink) 10%, transparent) inset' + : 'none', + cursor: week.inRange ? 'default' : 'auto', + transform: 'scale(1)', + animation: week.inRange ? `ol-activity-cell-in 0.22s var(--ol-motion-soft) ${Math.min(weekIndex * 5, 260)}ms both` : undefined, + transition: 'background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick), box-shadow 0.12s var(--ol-motion-quick)', + }} + /> + ); + }); + })} +
+ +
+
+
+ ); +} + +function ActivityMonthLabels({ + labels, + weeks, + cellSize, + cellGap, +}: { + labels: MonthLabel[]; + weeks: number; + cellSize: number; + cellGap: number; +}) { + return ( +
+ {labels.map(label => ( + + {label.label} + + ))}
); } @@ -363,6 +789,249 @@ function RecentRow({ session, modeLabel }: { session: DictationSession; modeLabe ); } +function buildYearlyActivity(history: DictationSession[], monthNames: string[], mode: ActivityMode): YearlyActivity { + const today = startOfLocalDay(new Date()); + const rangeStart = addDays(today, -364); + const gridStart = rangeStart; + const weeks = Math.ceil((differenceInDays(today, gridStart) + 1) / 7); + const weekGridStart = startOfWeek(rangeStart); + const weekColumns = Math.ceil((differenceInDays(today, weekGridStart) + 1) / 7); + const byDay = new Map(); + + history.forEach(session => { + const date = startOfLocalDay(new Date(session.createdAt)); + if (isNaN(date.getTime()) || date < rangeStart || date > today) return; + const key = localDateKey(date); + const current = byDay.get(key) ?? { count: 0, chars: 0, durationMs: 0 }; + current.count += 1; + current.chars += session.finalText.length; + current.durationMs += session.durationMs ?? 0; + byDay.set(key, current); + }); + + const rawCells: Array> = []; + for (let i = 0; i < weeks * 7; i += 1) { + const date = addDays(gridStart, i); + const inRange = date >= rangeStart && date <= today; + const key = localDateKey(date); + const stats = inRange ? byDay.get(key) : undefined; + rawCells.push({ + key, + date, + rawCount: stats?.count ?? 0, + rawChars: stats?.chars ?? 0, + rawDurationMs: stats?.durationMs ?? 0, + inRange, + }); + } + + let cumulativeCount = 0; + let cumulativeChars = 0; + let cumulativeDurationMs = 0; + const metricCells = rawCells.map(cell => ({ + ...cell, + count: cell.inRange ? cell.rawCount : 0, + chars: cell.inRange ? cell.rawChars : 0, + durationMs: cell.inRange ? cell.rawDurationMs : 0, + })); + + const maxCount = Math.max(...metricCells.filter(cell => cell.inRange).map(cell => cell.count), 0); + const cells = metricCells.map(cell => ({ + ...cell, + level: cell.inRange ? activityLevel(cell.count, maxCount) : 0, + })); + + const rawWeeks: Array> = []; + for (let weekIndex = 0; weekIndex < weekColumns; weekIndex += 1) { + const startDate = addDays(weekGridStart, weekIndex * 7); + const endDate = addDays(startDate, 6); + const inRange = endDate >= rangeStart && startDate <= today; + let stats = { count: 0, chars: 0, durationMs: 0 }; + for (let offset = 0; offset < 7; offset += 1) { + const date = addDays(startDate, offset); + if (date < rangeStart || date > today) continue; + const dayStats = byDay.get(localDateKey(date)); + if (!dayStats) continue; + stats = { + count: stats.count + dayStats.count, + chars: stats.chars + dayStats.chars, + durationMs: stats.durationMs + dayStats.durationMs, + }; + } + if (inRange) { + cumulativeCount += stats.count; + cumulativeChars += stats.chars; + cumulativeDurationMs += stats.durationMs; + } + const value = mode === 'cumulative' ? cumulativeCount : stats.count; + const valueChars = mode === 'cumulative' ? cumulativeChars : stats.chars; + rawWeeks.push({ + key: localDateKey(startDate), + startDate, + endDate: endDate > today ? today : endDate, + count: inRange ? stats.count : 0, + chars: inRange ? stats.chars : 0, + durationMs: inRange ? stats.durationMs : 0, + cumulativeCount: inRange ? cumulativeCount : 0, + cumulativeChars: inRange ? cumulativeChars : 0, + cumulativeDurationMs: inRange ? cumulativeDurationMs : 0, + value: inRange ? value : 0, + valueChars: inRange ? valueChars : 0, + inRange, + }); + } + + const maxWeekValue = Math.max(...rawWeeks.filter(week => week.inRange).map(week => week.value), 0); + const weekBars = rawWeeks.map(week => ({ + ...week, + level: week.inRange ? activityLevel(week.value, maxWeekValue) : 0, + })); + + const monthLabels: MonthLabel[] = []; + let lastMonth = -1; + for (let weekIndex = 0; weekIndex < weeks; weekIndex += 1) { + const weekCells = cells.slice(weekIndex * 7, weekIndex * 7 + 7).filter(cell => cell.inRange); + const candidate = weekCells.find(cell => cell.date.getDate() === 1); + if (!candidate) continue; + const month = candidate.date.getMonth(); + if (month === lastMonth) continue; + lastMonth = month; + monthLabels.push({ + weekIndex, + label: monthNames[month] ?? String(month + 1), + }); + } + const weekMonthLabels: MonthLabel[] = []; + let lastWeekMonth = -1; + for (let weekIndex = 0; weekIndex < weekColumns; weekIndex += 1) { + let candidate: Date | null = null; + const weekStart = addDays(weekGridStart, weekIndex * 7); + for (let offset = 0; offset < 7; offset += 1) { + const date = addDays(weekStart, offset); + if (date >= rangeStart && date <= today && date.getDate() === 1) { + candidate = date; + break; + } + } + if (!candidate) continue; + const month = candidate.getMonth(); + if (month === lastWeekMonth) continue; + lastWeekMonth = month; + weekMonthLabels.push({ + weekIndex, + label: monthNames[month] ?? String(month + 1), + }); + } + + return { + cells, + weeks, + weekColumns, + activeDays: rawCells.filter(cell => cell.inRange && cell.rawCount > 0).length, + maxCount, + maxWeekValue, + weekBars, + monthLabels, + weekMonthLabels, + }; +} + +function activityLevel(count: number, maxCount: number): number { + if (count <= 0 || maxCount <= 0) return 0; + const ratio = count / maxCount; + if (ratio <= 0.25) return 1; + if (ratio <= 0.5) return 2; + if (ratio <= 0.75) return 3; + return 4; +} + +function activityDiscreteCells(value: number, maxValue: number): number { + if (value <= 0 || maxValue <= 0) return 0; + return Math.min(7, Math.max(1, Math.ceil((value / maxValue) * 7))); +} + +function activityColor(level: number): string { + const base = 'var(--ol-blue)'; + switch (level) { + case 1: + return `color-mix(in srgb, ${base} 26%, var(--ol-surface))`; + case 2: + return `color-mix(in srgb, ${base} 44%, var(--ol-surface))`; + case 3: + return `color-mix(in srgb, ${base} 68%, var(--ol-surface))`; + case 4: + return base; + default: + return 'color-mix(in srgb, var(--ol-ink) 8%, var(--ol-surface))'; + } +} + +function activityDaySummaryArgs(cell: DayActivity, t: ReturnType['t']): { key: string; options: Record } { + const date = formatHeatmapMonthDay(cell.date); + return { + key: 'overview.activitySummaryDaily', + options: { + date, + count: cell.rawCount.toLocaleString(), + chars: cell.rawChars.toLocaleString(), + duration: formatDuration(cell.rawDurationMs, t), + }, + }; +} + +function activityWeekSummaryArgs(mode: Exclude, week: WeekActivity, t: ReturnType['t']): { key: string; options: Record } { + const start = formatHeatmapDisplayDate(week.startDate); + if (mode === 'weekly') { + return { + key: 'overview.activitySummaryWeekly', + options: { + start, + count: week.count.toLocaleString(), + chars: week.chars.toLocaleString(), + duration: formatDuration(week.durationMs, t), + }, + }; + } + return { + key: 'overview.activitySummaryCumulative', + options: { + start, + count: week.cumulativeCount.toLocaleString(), + chars: week.cumulativeChars.toLocaleString(), + duration: formatDuration(week.cumulativeDurationMs, t), + }, + }; +} + +function startOfLocalDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function startOfWeek(date: Date): Date { + return addDays(startOfLocalDay(date), -((date.getDay() + 6) % 7)); +} + +function addDays(date: Date, days: number): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days); +} + +function differenceInDays(a: Date, b: Date): number { + return Math.round((startOfLocalDay(a).getTime() - startOfLocalDay(b).getTime()) / 86400000); +} + +function localDateKey(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +function formatHeatmapDisplayDate(date: Date): string { + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); +} + +function formatHeatmapMonthDay(date: Date): string { + return date.toLocaleDateString(undefined, { month: 'long', day: 'numeric' }); +} + function formatTime(iso: string): string { const d = new Date(iso); if (isNaN(d.getTime())) return iso; @@ -374,10 +1043,11 @@ function formatTime(iso: string): string { } function formatDuration(ms: number, t: ReturnType['t']): string { - if (ms <= 0) return '—'; + if (ms <= 0) return t('common.durationSeconds', { value: '0' }); const sec = ms / 1000; if (sec < 60) return t('common.durationSeconds', { value: sec.toFixed(1) }); - return `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`; + if (sec < 3600) return t('common.durationMinutes', { value: (sec / 60).toFixed(1) }); + return t('common.durationHours', { value: (sec / 3600).toFixed(1) }); } function weekDayLabels(names: string[]): string[] { diff --git a/openless-all/app/src/pages/settings/DataStorageSection.tsx b/openless-all/app/src/pages/settings/DataStorageSection.tsx index 5727a747..c2dcc217 100644 --- a/openless-all/app/src/pages/settings/DataStorageSection.tsx +++ b/openless-all/app/src/pages/settings/DataStorageSection.tsx @@ -6,7 +6,7 @@ import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { Card } from '../_atoms'; import { SettingRow, SectionTitle, inputStyle } from './shared'; -// 范围限制:retention 0-365 天,context window 0-60 分钟(再大对实际对话场景没意义且白烧 token)。 +// 范围限制:context window 0-60 分钟(再大对实际对话场景没意义且白烧 token)。 const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); export function DataStorageSection() { @@ -21,28 +21,27 @@ export function DataStorageSection() { ); } - // 空字符串时回滚到默认值。 + // 空字符串 / 0 = 不限;输入框用文字占位,不把 0 展示成一个普通天数。 const onHistoryRetentionChange = (raw: string) => { const parsed = raw === '' ? 0 : Number.parseInt(raw, 10); if (Number.isNaN(parsed)) return; - void savePrefs({ ...prefs, historyRetentionDays: clamp(parsed, 0, 365) }); + void savePrefs({ ...prefs, historyRetentionDays: Math.max(0, parsed) }); }; const onPolishContextWindowChange = (raw: string) => { const parsed = raw === '' ? 0 : Number.parseInt(raw, 10); if (Number.isNaN(parsed)) return; void savePrefs({ ...prefs, polishContextWindowMinutes: clamp(parsed, 0, 60) }); }; - // 历史条数 200 是当前 HISTORY_CAP(persistence.rs:32),下限 5 是避免用户填 0 导致 - // 写一条就立刻被清光;空字符串视为不限制,落回 null → 后端走 200 默认。 + // 空字符串 / 0 = 不限。 const onHistoryMaxEntriesChange = (raw: string) => { const trimmed = raw.trim(); - if (trimmed === '') { + if (trimmed === '' || trimmed === '0') { 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) }); + void savePrefs({ ...prefs, historyMaxEntries: Math.max(1, parsed) }); }; return ( @@ -52,8 +51,8 @@ export function DataStorageSection() { onHistoryRetentionChange(e.target.value)} style={{ ...inputStyle, width: 80, textAlign: 'right' }} /> @@ -61,9 +60,8 @@ export function DataStorageSection() { onHistoryMaxEntriesChange(e.target.value)} style={{ ...inputStyle, width: 80, textAlign: 'right' }} diff --git a/openless-all/app/src/pages/settings/ThemeSection.tsx b/openless-all/app/src/pages/settings/ThemeSection.tsx index f27075a0..2fd9a6ea 100644 --- a/openless-all/app/src/pages/settings/ThemeSection.tsx +++ b/openless-all/app/src/pages/settings/ThemeSection.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { SelectLite } from '../../components/ui/SelectLite'; import { Card } from '../_atoms'; -import { SettingRow } from './shared'; +import { SettingRow, Toggle } from './shared'; import { readThemePreference, setThemePreference, @@ -53,6 +53,19 @@ export function ThemeSection() { style={{ maxWidth: 220, minWidth: 200 }} /> + {prefs && ( + +
+ void updatePrefs({ ...prefs, showOverviewActivityHeatmap: next })} + /> + + {t('settings.theme.overviewActivityDesc')} + +
+
+ )} ); } diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index dc08e742..319f0814 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -122,6 +122,11 @@ a { color: inherit; text-decoration: none; } to { transform: rotate(360deg); } } +@keyframes ol-activity-cell-in { + from { opacity: 0; } + to { opacity: 1; } +} + @keyframes ol-modal-drawer-in { from { opacity: 0;