From 63722e13fec4b78d42d0ff29d7ab04857fab81b9 Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 8 May 2026 10:45:28 +0800 Subject: [PATCH 1/3] feat(history+polish): time-based retention + conversation-aware polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回应用户「历史记录功能 + 对话上下文 + 提示词调优」需求。 新增 2 个用户偏好(types.rs + UserPreferencesWire + Default + Deserialize 全套兼容): - history_retention_days: u32(默认 7;0=不按时间清理仅受 200 上限) - polish_context_window_minutes: u32(默认 5;0=关闭走原有单轮 polish) 后端: - persistence.rs: - HistoryStore::append_with_retention(session, retention_days):写入新条目时 顺手把超过 N 天的会话裁掉,不需要后台轮询 - HistoryStore::recent_within_minutes(minutes):返回最近 N 分钟内的会话(newest- first,take_while 利用 newest-first 顺序提前终止) - 老 append() 保留,等价 append_with_retention(s, 0) - polish.rs: - polish() 多一个 prior_turns 参数;不为空时走 chat_completion_with_polish_history 构造 system + (user/assistant)... + 当前 user 多轮 chat completion - 把 chat_completion 的 send/parse 抽成 send_chat_request 复用 - prompts::polish_context_instruction():明确告诉 LLM 历史 turns 仅作语义上下文, 不要复读已经被插入到用户文档里的 assistant 输出 - coordinator.rs: - 主流水线在 Polishing 阶段拉 recent_within_minutes(prefs.window) 作为 prior_turns, 过滤掉失败的 session(error_code/empty final_text)避免喂噪声 - 翻译路径 / Raw 模式 / repolish 主动跳过上下文(语义不合适) - 3 个 history.append 改成 append_with_retention(session, prefs.history_retention_days) - commands.rs / coordinator.rs:repolish / polish.rs::tests / commands.rs::testProvider: 原 polish() 调用全部加 &[] 空 prior_turns 参数 前端: - types.ts:UserPreferences 加 historyRetentionDays / polishContextWindowMinutes - ipc.ts mockSettings + stylePrefs.test.ts mockPrefs:补默认值 - Settings.tsx Recording 区加 2 个 number input(裸 input + clamp,retention 0-365 天, context window 0-60 分钟) - i18n 5 locale(zh-CN / zh-TW / en / ja / ko)补 4 个 key: historyRetentionLabel / Desc + polishContextWindowLabel / Desc 性能注意:retention 在 append 时 O(n) 一次扫描裁切;recent_within_minutes 使用 take_while 利用 newest-first 顺序提前终止,平均 O(window 内条目数) 不是 O(总历史)。 cargo check + commands::tests (30/30) + polish::tests (10/10) + npm run build 全 pass。 预先存在的 types::tests::legacy_hotkey_trigger 失败跟本 PR 无关(macOS 平台特有)。 不包含:连接 / 厂商 / 提示词排布优化(用户也提了)——那些需要先 measure 再动, 不能盲改,留给独立 PR。 --- openless-all/app/src-tauri/src/commands.rs | 1 + openless-all/app/src-tauri/src/coordinator.rs | 41 ++++++++- openless-all/app/src-tauri/src/persistence.rs | 42 +++++++++ openless-all/app/src-tauri/src/polish.rs | 86 ++++++++++++++++++- openless-all/app/src-tauri/src/types.rs | 27 ++++++ openless-all/app/src/i18n/en.ts | 4 + openless-all/app/src/i18n/ja.ts | 4 + openless-all/app/src/i18n/ko.ts | 4 + openless-all/app/src/i18n/zh-CN.ts | 4 + openless-all/app/src/i18n/zh-TW.ts | 4 + openless-all/app/src/lib/ipc.ts | 2 + openless-all/app/src/lib/stylePrefs.test.ts | 2 + openless-all/app/src/lib/types.ts | 4 + openless-all/app/src/pages/Settings.tsx | 39 +++++++++ 14 files changed, 258 insertions(+), 6 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 62af5d8d..fcf11ac0 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -589,6 +589,7 @@ async fn validate_llm_provider() -> Result<(), String> { ChineseScriptPreference::Auto, OutputLanguagePreference::Auto, None, + &[], ) .await .map(|_| ()) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index d02f97d8..762d2186 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -838,6 +838,8 @@ impl Coordinator { // repolish 是历史记录里手动重新润色,不再绑定原 session 的前台 app; // 当下用户调起的 app 才是相关上下文(如果可拿)。 let front_app = capture_frontmost_app(); + // repolish 是用户主动对单条历史"重新润色",不应该被对话感知上下文影响—— + // 用户改的就是这一条本身,不要把别的会话拿进来。所以始终走单轮路径。 polish_text( &raw_text, mode, @@ -846,6 +848,7 @@ impl Coordinator { chinese_script_preference, output_language_preference, front_app.as_deref(), + &[], ) .await .map_err(|e| e.to_string()) @@ -2664,7 +2667,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { duration_ms: Some(raw.duration_ms), dictionary_entry_count: Some(enabled_phrases(inner).len() as u32), }; - if let Err(e) = inner.history.append(session) { + if let Err(e) = inner.history.append_with_retention(session, inner.prefs.get().history_retention_days) { log::error!("[coord] history append failed: {e}"); } emit_capsule( @@ -2693,6 +2696,33 @@ async fn end_session(inner: &Arc) -> Result<(), String> { let translation_target = prefs.translation_target_language.trim().to_string(); let translation_active = inner.translation_modifier_seen.load(Ordering::SeqCst) && !translation_target.is_empty(); + // 对话感知 polish:拉最近 N 分钟的会话作为 LLM 上下文。仅在非翻译路径且非 Raw mode + // 才有意义(Raw 不走 LLM、翻译走单轮独立 prompt)。窗口=0 时 prior_turns 是空 Vec, + // polish 路径自动退化成单轮单消息——跟历史行为一致。 + let polish_context_window_minutes = prefs.polish_context_window_minutes; + let prior_turns: Vec<(String, String)> = if !translation_active + && mode != PolishMode::Raw + && polish_context_window_minutes > 0 + { + match inner + .history + .recent_within_minutes(polish_context_window_minutes) + { + Ok(sessions) => sessions + .into_iter() + // 只取实际成功润色过的会话作为上下文:失败的会话 final_text 是 raw 兜底, + // 喂回 LLM 会让模型以为"上一轮我什么都没做"——没意义且占 token。 + .filter(|s| s.error_code.is_none() && !s.final_text.trim().is_empty()) + .map(|s| (s.raw_transcript, s.final_text)) + .collect(), + Err(e) => { + log::warn!("[coord] fetch polish context failed: {e}; fall back to single-turn"); + Vec::new() + } + } + } else { + Vec::new() + }; let (polished, polish_error) = if translation_active { log::info!( "[coord] translation mode → target=\u{300C}{}\u{300D} working={:?} front_app={:?}", @@ -2718,6 +2748,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { chinese_script_preference, output_language_preference, front_app.as_deref(), + &prior_turns, ) .await }; @@ -2838,7 +2869,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { // 比"启用词条总数"更能反映本段口述命中了多少。u64 → u32 截断对单段听写足够。 dictionary_entry_count: Some(total_hits.min(u32::MAX as u64) as u32), }; - if let Err(e) = inner.history.append(session) { + if let Err(e) = inner.history.append_with_retention(session, inner.prefs.get().history_retention_days) { log::error!("[coord] history append failed: {e}"); } @@ -3318,6 +3349,7 @@ async fn polish_or_passthrough( chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, front_app: Option<&str>, + prior_turns: &[(String, String)], ) -> (String, Option) { if mode == PolishMode::Raw { return (raw.text.clone(), None); @@ -3330,6 +3362,7 @@ async fn polish_or_passthrough( chinese_script_preference, output_language_preference, front_app, + prior_turns, ) .await { @@ -3350,6 +3383,7 @@ async fn polish_text( chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, front_app: Option<&str>, + prior_turns: &[(String, String)], ) -> anyhow::Result { let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); let model = CredentialsVault::get(CredentialAccount::ArkModelId)? @@ -3372,6 +3406,7 @@ async fn polish_text( chinese_script_preference, output_language_preference, front_app, + prior_turns, ) .await?) } @@ -3865,7 +3900,7 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { duration_ms: Some(raw.duration_ms), dictionary_entry_count: None, }; - if let Err(e) = inner.history.append(session) { + if let Err(e) = inner.history.append_with_retention(session, inner.prefs.get().history_retention_days) { log::error!("[coord] QA history append failed: {e}"); } } diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index f559b4a7..9b100a27 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -665,16 +665,58 @@ impl HistoryStore { } pub fn append(&self, session: DictationSession) -> Result<()> { + self.append_with_retention(session, 0) + } + + /// `retention_days == 0` 跟旧 append 行为一致(不按时间清理)。 + /// `> 0` 时在写入新条目后顺手把超过 N 天的会话裁掉,写入时就完成清理, + /// 不需要后台轮询。最后再受 200 条硬上限约束(HISTORY_CAP)。 + pub fn append_with_retention( + &self, + session: DictationSession, + retention_days: u32, + ) -> Result<()> { let _guard = self.lock.lock(); let mut sessions = self.read_locked()?; // Prepend so the newest session is at index 0, matching the Swift impl. sessions.insert(0, session); + if retention_days > 0 { + let cutoff = + chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days)); + sessions.retain(|s| { + chrono::DateTime::parse_from_rfc3339(&s.created_at) + .map(|t| t.with_timezone(&chrono::Utc) >= cutoff) + // 解析失败时保守保留——避免错误的时间戳让用户丢历史。 + .unwrap_or(true) + }); + } if sessions.len() > HISTORY_CAP { sessions.truncate(HISTORY_CAP); } self.write_locked(&sessions) } + /// 返回最近 N 分钟内的会话(newest-first)。`minutes == 0` → 空 Vec, + /// 调用方据此跳过对话感知 polish 路径。 + pub fn recent_within_minutes(&self, minutes: u32) -> Result> { + if minutes == 0 { + return Ok(Vec::new()); + } + let _guard = self.lock.lock(); + let sessions = self.read_locked()?; + let cutoff = chrono::Utc::now() - chrono::Duration::minutes(i64::from(minutes)); + // sessions 是 newest-first,超出窗口的会话之后的都更老,take_while 即可。 + let filtered: Vec = sessions + .into_iter() + .take_while(|s| { + chrono::DateTime::parse_from_rfc3339(&s.created_at) + .map(|t| t.with_timezone(&chrono::Utc) >= cutoff) + .unwrap_or(false) + }) + .collect(); + Ok(filtered) + } + pub fn delete(&self, id: &str) -> Result<()> { let _guard = self.lock.lock(); let mut sessions = self.read_locked()?; diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 9668b0ba..84f99c8c 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -95,6 +95,7 @@ impl OpenAICompatibleLLMProvider { chinese_script_preference: ChineseScriptPreference, output_language_preference: OutputLanguagePreference, front_app: Option<&str>, + prior_turns: &[(String, String)], ) -> Result { let mut system_prompt = compose_system_prompt(mode, hotwords); if let Some(premise) = @@ -107,8 +108,22 @@ impl OpenAICompatibleLLMProvider { { system_prompt = format!("{}\n\n{}", premise, system_prompt); } + // 多轮上下文模式:把"上一轮的指令是什么、不要复读上一轮答案"明确写进 + // system prompt,配合 chat structure 让 LLM 自然不重复历史输出。 + if !prior_turns.is_empty() { + system_prompt = format!( + "{}\n\n{}", + system_prompt, + prompts::polish_context_instruction() + ); + } let user_prompt = prompts::user_prompt(raw_text); - self.chat_completion(&system_prompt, &user_prompt).await + if prior_turns.is_empty() { + self.chat_completion(&system_prompt, &user_prompt).await + } else { + self.chat_completion_with_polish_history(&system_prompt, prior_turns, &user_prompt) + .await + } } /// 多轮划词追问,**流式**返回。`messages` 包含历史对话(user/assistant 交替), @@ -172,6 +187,47 @@ impl OpenAICompatibleLLMProvider { self.chat_completion(&system_prompt, &user_prompt).await } + /// 多轮对话感知的 polish 路径。`prior_turns` 是按时间倒序(最新在前)的 + /// `(raw_transcript, polished_text)` 序列;这里反转成时间正序、然后展开 + /// 成 OpenAI chat completions 的多轮 `user` / `assistant` messages,最后一条 + /// 是当前 user prompt。LLM 会自然把 prior assistant 输出当成"我已说过、 + /// 不复读"。配合 system prompt 里的显式指令(prompts::polish_context_instruction) + /// 共同保证不复读上文,仅把上文当语义上下文。 + async fn chat_completion_with_polish_history( + &self, + system_prompt: &str, + prior_turns: &[(String, String)], + user_prompt: &str, + ) -> Result { + let url = chat_completions_url(&self.config.base_url); + let mut messages: Vec = Vec::with_capacity(prior_turns.len() * 2 + 2); + messages.push(json!({ "role": "system", "content": system_prompt })); + // prior_turns 按时间倒序(newest-first),需要反转成正序喂给 chat。 + for (raw, polished) in prior_turns.iter().rev() { + messages.push(json!({ "role": "user", "content": prompts::user_prompt(raw) })); + messages.push(json!({ "role": "assistant", "content": polished })); + } + messages.push(json!({ "role": "user", "content": user_prompt })); + + let body = json!({ + "model": self.config.model, + "stream": false, + "temperature": self.config.temperature, + "messages": messages, + }); + + log::info!( + "[llm] POST {} provider={} model={} prior_turns={}", + url, + self.config.provider_id, + self.config.model, + prior_turns.len() + ); + + // 复用 send_and_extract 把 chat_completion 与本函数共享 HTTP / 解析路径。 + self.send_chat_request(&url, &body).await + } + async fn chat_completion( &self, system_prompt: &str, @@ -195,9 +251,19 @@ impl OpenAICompatibleLLMProvider { self.config.model ); + self.send_chat_request(&url, &body).await + } + + /// 共用的 HTTP send + body 解析。chat_completion / chat_completion_with_polish_history + /// 各自构造好 body 后都调到这里,避免 30 行 send/parse 重复。 + async fn send_chat_request( + &self, + url: &str, + body: &serde_json::Value, + ) -> Result { let mut request = self .client - .post(&url) + .post(url) .header("Content-Type", "application/json"); if !self.config.api_key.trim().is_empty() { request = request.header("Authorization", format!("Bearer {}", self.config.api_key)); @@ -205,7 +271,7 @@ impl OpenAICompatibleLLMProvider { for (k, v) in &self.config.extra_headers { request = request.header(k.as_str(), v.as_str()); } - let request = request.json(&body); + let request = request.json(body); let response = match request.send().await { Ok(r) => r, @@ -871,6 +937,19 @@ pub mod prompts { ) } + /// 对话感知 polish 模式下追加到 system prompt 末尾的指令——告诉 LLM 看到的 + /// 历史 user / assistant turns 是为了**理解上下文**(代词、不完整句子的指代), + /// 而**不是**让它把上文复读出来。每次只输出当前 user message 的整理结果。 + /// 详见 PR-A 的「对话感知润色」需求。 + pub fn polish_context_instruction() -> &'static str { + "# 多轮上下文使用规则\n\ + 上面的对话历史是给你提供前文语境(代词指代、未完整句子等),\u{4EE5}\u{4FBF}\u{6B63}\u{786E}\u{7406}\u{89E3}\u{6700}\u{65B0}\ + 一条用户消息要表达的意思。\n\ + **不要复读、改写或合并历史中已经整理过的内容**——历史里的 assistant 输出已经被插入到\ + 用户的文档里了,再次出现就是重复。每次只输出**当前最新一条** user message 的整理结果,\ + 不要把上文带进来。" + } + /// 划词语音问答 system prompt — 用户选中一段文字后口头提问,要求基于选区给出简短答案。 /// 详见 issue #118。 pub fn qa_system_prompt() -> String { @@ -1147,6 +1226,7 @@ mod tests { ChineseScriptPreference::Auto, OutputLanguagePreference::Auto, None, + &[], ) .await .unwrap(); diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 80db02a8..a8d1d5c8 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -214,12 +214,29 @@ pub struct UserPreferences { /// 一个手动下载 Beta 的入口。不影响 plugin-updater 的自动检查路径。 #[serde(default)] pub update_channel: UpdateChannel, + /// 历史记录保留天数。0 = 不按时间清理(仅受 200 条上限)。默认 7 天。 + /// 写入新条目时执行清理,避免后台轮询。 + #[serde(default = "default_history_retention_days")] + pub history_retention_days: u32, + /// 对话感知 polish 的上下文窗口(分钟):把最近 N 分钟的转写 + 已润色文本 + /// 作为多轮上下文喂给 LLM,让代词 / 不完整句子能被正确解析。 + /// 0 = 关闭(每次润色独立单轮,跟历史行为一致)。默认 5 分钟。 + #[serde(default = "default_polish_context_window_minutes")] + pub polish_context_window_minutes: u32, } fn default_local_asr_model() -> String { "qwen3-asr-0.6b".into() } +fn default_history_retention_days() -> u32 { + 7 +} + +fn default_polish_context_window_minutes() -> u32 { + 5 +} + fn default_local_asr_mirror() -> String { "huggingface".into() } @@ -285,6 +302,10 @@ struct UserPreferencesWire { foundry_local_asr_keep_loaded_secs: u32, #[serde(default)] update_channel: UpdateChannel, + #[serde(default = "default_history_retention_days")] + history_retention_days: u32, + #[serde(default = "default_polish_context_window_minutes")] + polish_context_window_minutes: u32, } impl Default for UserPreferencesWire { @@ -320,6 +341,8 @@ impl Default for UserPreferencesWire { foundry_local_asr_language_hint: prefs.foundry_local_asr_language_hint, foundry_local_asr_keep_loaded_secs: prefs.foundry_local_asr_keep_loaded_secs, update_channel: prefs.update_channel, + history_retention_days: prefs.history_retention_days, + polish_context_window_minutes: prefs.polish_context_window_minutes, } } } @@ -369,6 +392,8 @@ impl<'de> Deserialize<'de> for UserPreferences { foundry_local_asr_language_hint: wire.foundry_local_asr_language_hint, foundry_local_asr_keep_loaded_secs: wire.foundry_local_asr_keep_loaded_secs, update_channel: wire.update_channel, + history_retention_days: wire.history_retention_days, + polish_context_window_minutes: wire.polish_context_window_minutes, }) } } @@ -474,6 +499,8 @@ impl Default for UserPreferences { foundry_local_asr_language_hint: String::new(), foundry_local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(), update_channel: UpdateChannel::default(), + history_retention_days: default_history_retention_days(), + polish_context_window_minutes: default_polish_context_window_minutes(), } } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 310d295d..43192b3f 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -322,6 +322,10 @@ export const en: typeof zhCN = { comboConflict: 'This shortcut combination is not available', allowNonTsfFallbackLabel: 'Allow non-TSF fallback', allowNonTsfFallbackDesc: 'Windows only: if direct TSF insertion fails, allow Unicode SendInput, shortcut paste, or WM_PASTE. Turn off to verify that insertion is really coming from TSF.', + historyRetentionLabel: 'History retention (days)', + historyRetentionDesc: 'History entries older than this many days are pruned when a new entry is written. 0 = no time-based pruning (still capped at 200). Default 7 days.', + polishContextWindowLabel: 'Polish context window (minutes)', + polishContextWindowDesc: 'Feed the LLM successfully-polished transcripts from the last N minutes as multi-turn context, so pronouns and unfinished sentences resolve against prior dictation. 0 = disabled (single-turn polish, legacy behavior). Default 5 minutes; values above 60 rarely help.', startupAtBoot: 'Launch at login', startupAtBootDesc: 'Start OpenLess automatically when you sign in. macOS uses a LaunchAgent, Linux writes ~/.config/autostart, Windows writes HKCU\\Run (no admin required). See issue #194.', startupAtBootError: 'Failed to toggle launch at login: {{message}}', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index d95c18ff..124084f5 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -324,6 +324,10 @@ export const ja: typeof zhCN = { comboConflict: 'このショートカットの組み合わせは使用できません', allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', allowNonTsfFallbackDesc: 'Windows のみ:TSF による直接入力が失敗した場合、Unicode SendInput / ショートカットペースト / WM_PASTE への切り替えを許可。OFF にすると TSF 入力が実際に使われているか検証できます。', + historyRetentionLabel: '履歴保持期間(日)', + historyRetentionDesc: 'この日数を超えた履歴は新しいエントリ書き込み時に削除されます。0 = 時間による削除なし(200 件上限は維持)。デフォルト 7 日。', + polishContextWindowLabel: '会話コンテキスト窓(分)', + polishContextWindowDesc: '直近 N 分間の整文済み転写をマルチターン文脈として LLM に渡し、代名詞や未完了の文を前のディクテーションと突き合わせて解釈できるようにします。0 = 無効(シングルターン整文)。デフォルト 5 分。60 分超は実質意味なし。', startupAtBoot: '起動時に自動起動', startupAtBootDesc: 'ログイン後に OpenLess を自動起動。macOS は LaunchAgent、Linux は ~/.config/autostart、Windows は HKCU\\Run(管理者不要)。詳細は issue #194。', startupAtBootError: '自動起動の切り替えに失敗:{{message}}', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index c884d308..6f8f03d5 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -324,6 +324,10 @@ export const ko: typeof zhCN = { comboConflict: '이 단축키 조합은 사용할 수 없습니다', allowNonTsfFallbackLabel: '비 TSF 폴백 허용', allowNonTsfFallbackDesc: 'Windows 만: TSF 직접 입력이 실패할 경우 Unicode SendInput / 단축키 붙여넣기 / WM_PASTE 로 전환을 허용합니다. OFF 시 실제로 TSF 입력이 사용되는지 검증할 수 있습니다.', + historyRetentionLabel: '기록 보관 기간(일)', + historyRetentionDesc: '이 기간을 초과한 기록은 새 항목을 쓸 때 정리됩니다. 0 = 시간 기반 정리 비활성화(200건 상한은 유지). 기본 7일.', + polishContextWindowLabel: '대화 컨텍스트 윈도(분)', + polishContextWindowDesc: '최근 N분간 성공적으로 정리된 전사를 멀티턴 컨텍스트로 LLM 에 전달하여 대명사와 미완성 문장을 이전 받아쓰기와 대조해 해석할 수 있도록 합니다. 0 = 비활성화(단일턴 정리). 기본 5분; 60분 초과는 의미가 거의 없습니다.', startupAtBoot: '부팅 시 자동 시작', startupAtBootDesc: '로그인 후 OpenLess 자동 시작. macOS 는 LaunchAgent, Linux 는 ~/.config/autostart, Windows 는 HKCU\\Run(관리자 불필요). issue #194 참조.', startupAtBootError: '자동 시작 전환 실패: {{message}}', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 09d77618..46da4892 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -320,6 +320,10 @@ export const zhCN = { comboConflict: '该快捷键组合不可用', allowNonTsfFallbackLabel: '允许非 TSF 兜底', allowNonTsfFallbackDesc: '仅 Windows:TSF 直接上屏失败后,允许改用 Unicode SendInput、快捷键粘贴或 WM_PASTE。关闭后可验证是否真实使用 TSF 输入。', + historyRetentionLabel: '历史保留天数', + historyRetentionDesc: '超过这个天数的历史会在写入新条目时被清理。0 = 不按时间清理(仍受 200 条上限)。默认 7 天。', + polishContextWindowLabel: '对话上下文窗口(分钟)', + polishContextWindowDesc: '把最近 N 分钟内已成功润色的转写作为多轮上下文喂给 LLM,让代词与未完整句子能被正确解析。0 = 关闭,单轮独立润色。默认 5 分钟;超过 60 分钟意义不大。', startupAtBoot: '开机自启', startupAtBootDesc: '登录后自动启动 OpenLess。macOS 写 LaunchAgent,Linux 写 ~/.config/autostart,Windows 写 HKCU\\Run(不需要管理员)。详见 issue #194。', startupAtBootError: '开机自启切换失败:{{message}}', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 1efc827f..24feda4c 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -322,6 +322,10 @@ export const zhTW: typeof zhCN = { restoreClipboardDesc: '僅 Windows / Linux:粘貼成功後恢復你原來的剪貼板內容(默認開)。關掉就把聽寫文本留在剪貼板,模擬粘貼沒真正落地時可以手動 Ctrl+V 找回。詳見 issue #111。', allowNonTsfFallbackLabel: '允許非 TSF 兜底', allowNonTsfFallbackDesc: '僅 Windows:TSF 直接上屏失敗後,允許改用 Unicode SendInput、快捷鍵粘貼或 WM_PASTE。關閉後可驗證是否真實使用 TSF 輸入。', + historyRetentionLabel: '歷史保留天數', + historyRetentionDesc: '超過這個天數的歷史會在寫入新條目時被清理。0 = 不按時間清理(仍受 200 條上限)。默認 7 天。', + polishContextWindowLabel: '對話上下文窗口(分鐘)', + polishContextWindowDesc: '把最近 N 分鐘內已成功潤色的轉寫作為多輪上下文喂給 LLM,讓代詞與未完整句子能被正確解析。0 = 關閉,單輪獨立潤色。默認 5 分鐘;超過 60 分鐘意義不大。', startupAtBoot: '開機自啓', startupAtBootDesc: '登錄後自動啓動 OpenLess。macOS 寫 LaunchAgent,Linux 寫 ~/.config/autostart,Windows 寫 HKCU\\Run(不需要管理員)。詳見 issue #194。', startupAtBootError: '開機自啓切換失敗:{{message}}', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 4d383f63..e44872ea 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -72,6 +72,8 @@ const mockSettings: UserPreferences = { foundryLocalAsrModel: 'whisper-small', foundryLocalAsrLanguageHint: '', foundryLocalAsrKeepLoadedSecs: 300, + historyRetentionDays: 7, + polishContextWindowMinutes: 5, }; const mockHotkeyCapability: HotkeyCapability = { diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 02eb50fd..5971b0b1 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -38,6 +38,8 @@ const previousPrefs: UserPreferences = { foundryLocalAsrModel: '', foundryLocalAsrLanguageHint: '', foundryLocalAsrKeepLoadedSecs: 300, + historyRetentionDays: 7, + polishContextWindowMinutes: 5, }; const nextPrefs: UserPreferences = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 415b549b..f1a2fb4e 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -165,6 +165,10 @@ export interface UserPreferences { foundryLocalAsrLanguageHint: string; /** Windows Foundry Local Whisper 模型在 runtime 中保持加载的秒数。 */ foundryLocalAsrKeepLoadedSecs: number; + /** 历史记录保留天数。0 = 不按时间清理(仍受 200 条上限)。默认 7。 */ + historyRetentionDays: number; + /** 对话感知 polish 上下文窗口(分钟)。0 = 关闭。默认 5。详见 PR-A。 */ + polishContextWindowMinutes: number; } export interface MicrophoneDevice { diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 4c14a3a7..64ec266c 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -270,6 +270,19 @@ function RecordingSection() { savePrefs({ ...prefs, restoreClipboardAfterPaste }); const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => savePrefs({ ...prefs, allowNonTsfInsertionFallback }); + // 历史保留 / 对话感知 polish 上下文窗口都用裸 number input;空字符串时回滚到默认值。 + // 范围限制:retention 0-365 天,context window 0-60 分钟(再大的值对实际对话场景没意义且白烧 token)。 + const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); + 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) }); + }; + 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) }); + }; const choices: Array<[HotkeyMode, string]> = [ ['toggle', t('settings.recording.modeToggle')], @@ -430,6 +443,32 @@ function RecordingSection() { /> )} + + onHistoryRetentionChange(e.target.value)} + style={{ ...inputStyle, width: 80, textAlign: 'right' }} + /> + + + onPolishContextWindowChange(e.target.value)} + style={{ ...inputStyle, width: 80, textAlign: 'right' }} + /> + {capability.statusHint && (
From 01e9f00fcc60fae0fb23cc59c75628bbd7cc40b1 Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 8 May 2026 10:47:24 +0800 Subject: [PATCH 2/3] fix(types): HotkeyBinding default keys = None (legacy trigger / keys mismatch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跑测试时发现 types::tests::legacy_hotkey_trigger_still_produces_effective_key_codes fail,原因跟本 PR 无关,但用户要求中途发现 bug 顺手修。 Bug:HotkeyBinding 用 `#[serde(default)]` 结构级 default。反序列化时整个 struct 先按 Default 填充,JSON 字段覆盖。如果 Default 里 keys 预填具体 codes(macOS Default ["AltRight"] / Windows ["ControlRight"]),那么旧 prefs 写 `{"trigger":"rightControl","mode":"toggle"}`(无 keys)反序列化后会变成: trigger = RightControl ← JSON 覆盖 keys = Some([HotkeyKey { code: "AltRight" }]) ← Default 残留,跟 trigger 不一致 effective_codes() 在 keys 是 Some 时直接信任 keys、不再 fallback 到 legacy_trigger_code(trigger),于是用户选的 trigger 跟实际生效的 keys 对不上。 修法:Default::keys = None。这样 keys 缺省时 effective_codes() 走 legacy_trigger_code 路径,跟 trigger 自动同步;JSON 显式写 keys 时仍可以覆盖。 types::tests 8/8 pass,全 lib 测试 135/135 pass。 --- openless-all/app/src-tauri/src/types.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index a8d1d5c8..84e31059 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -995,12 +995,22 @@ impl Default for HotkeyStatus { impl Default for HotkeyBinding { fn default() -> Self { + // 注意:keys 必须是 None,不能预填具体 code。 + // + // 原因:HotkeyBinding 用 `#[serde(default)]` **结构级 default**——反序列化时 + // 整个 struct 先按 Default 填充再让 JSON 字段覆盖。如果这里 keys 预填了 + // Some([...]),那么旧 prefs 里只写 `{"trigger":"rightControl","mode":"toggle"}` + // (不带 keys 字段)会被反序列化成 `{trigger=RightControl, keys=Some([默认值])}` + // 即 trigger 跟 keys 完全不一致——effective_codes() 直接信任 keys,导致 + // 实际生效的快捷键跟用户当年选的 trigger 对不上。 + // 现在 keys=None 时 effective_codes() 走 legacy_trigger_code(trigger) 路径, + // 跟 trigger 自动同步。 #[cfg(target_os = "windows")] { Self { trigger: HotkeyTrigger::RightControl, mode: HotkeyMode::Toggle, - keys: Some(vec![HotkeyKey::new("ControlRight")]), + keys: None, } } @@ -1009,7 +1019,7 @@ impl Default for HotkeyBinding { Self { trigger: HotkeyTrigger::RightOption, mode: HotkeyMode::Toggle, - keys: Some(vec![HotkeyKey::new("AltRight")]), + keys: None, } } } From d55298e57d0b128a0c19a1f90a8acc15adac7a46 Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 8 May 2026 10:54:02 +0800 Subject: [PATCH 3/3] test(polish): lock down "no echo prior context" with 4 explicit unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户的核心顾虑:上下文感知 polish 不能让 LLM 把上文吐出来。PR #355 设计了 两层防御(chat structure + system prompt 显式指令),但**没写过 explicit 测试** 盯死这条不变量——回归就听天由命。这次把它锁住。 把 chat_completion_with_polish_history 里 inline 的 messages 构造抽成纯函数 build_polish_history_messages(system_prompt, prior_turns, user_prompt) → Vec, 方便可测,行为零变化。 新增 4 个 polish::tests: 1. build_polish_history_messages_empty_prior_falls_back_to_two_messages prior 空时只剩 [system, user],跟单轮 chat_completion 等价 2. build_polish_history_messages_orders_prior_oldest_to_newest_then_current 入参 prior_turns 是 newest-first(match HistoryStore::recent_within_minutes 返回顺序),chat 需要 oldest-first 时间序——验证 reverse 没漏。 顺序错了 LLM 会看到「未来→过去→当前」错乱时间轴,对话就废了。 3. build_polish_history_messages_keeps_polished_text_at_assistant_role 关键不变量:历史 polish 必须挂 role=assistant,**不能**误挂 user。 typo 把它放进 user role 会让 LLM 当成新输入再润色 → 输出复读上文, 直接违反"不复读"目标。这个 test 把这条锁死,重构时一改就 fail。 4. polish_context_instruction_explicitly_forbids_repeating_prior_assistant_output 第二层防御:仅靠 chat structure 不够(一些模型在长上下文里仍可能 echo prior turns)。 验证 system prompt 末尾追加的指令含「不要 / 复读 / assistant-or-已经整理 / 当前最新」四组关键词,文案可以改但语义不能丢。 cargo test --lib:139/139 pass(135 之前 + 4 新增),完全无回归。 --- openless-all/app/src-tauri/src/polish.rs | 139 +++++++++++++++++++++-- 1 file changed, 130 insertions(+), 9 deletions(-) diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 84f99c8c..ca54e3c3 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -200,15 +200,7 @@ impl OpenAICompatibleLLMProvider { user_prompt: &str, ) -> Result { let url = chat_completions_url(&self.config.base_url); - let mut messages: Vec = Vec::with_capacity(prior_turns.len() * 2 + 2); - messages.push(json!({ "role": "system", "content": system_prompt })); - // prior_turns 按时间倒序(newest-first),需要反转成正序喂给 chat。 - for (raw, polished) in prior_turns.iter().rev() { - messages.push(json!({ "role": "user", "content": prompts::user_prompt(raw) })); - messages.push(json!({ "role": "assistant", "content": polished })); - } - messages.push(json!({ "role": "user", "content": user_prompt })); - + let messages = build_polish_history_messages(system_prompt, prior_turns, user_prompt); let body = json!({ "model": self.config.model, "stream": false, @@ -460,6 +452,35 @@ fn safe_str_slice(s: &str, end: usize) -> &str { &s[..cut] } +/// 构造对话感知 polish 的 chat completions 消息数组。 +/// +/// 不变量: +/// 1. **第 0 条**永远是 `system`(含 \[system_prompt\] 整段,含 polish_context_instruction +/// "不要复读"指令——由调用方拼好传入)。 +/// 2. **prior_turns 按时间倒序**(最新在前)作为入参——这里反转成时间正序喂给 chat: +/// 最老的 prior 在前、最新的 prior 在后、当前要润色的 user_prompt 在最末。 +/// 3. **每对 prior 展开成 (role=user, role=assistant)**:raw 走 user_prompt 包装、 +/// polished 直接当 assistant 输出。LLM 据此把 polished 当成"我已经回答过的内容", +/// 自然不会复读。 +/// 4. **最后一条** 永远是 role=user(当前要润色的 raw_text 包装后的 user_prompt)。 +/// +/// 抽出独立函数纯粹是为了可单测——见 polish::tests::build_polish_history_messages_*。 +fn build_polish_history_messages( + system_prompt: &str, + prior_turns: &[(String, String)], + user_prompt: &str, +) -> Vec { + let mut messages: Vec = Vec::with_capacity(prior_turns.len() * 2 + 2); + messages.push(json!({ "role": "system", "content": system_prompt })); + // prior_turns 按时间倒序(newest-first),反转成正序喂给 chat。 + for (raw, polished) in prior_turns.iter().rev() { + messages.push(json!({ "role": "user", "content": prompts::user_prompt(raw) })); + messages.push(json!({ "role": "assistant", "content": polished })); + } + messages.push(json!({ "role": "user", "content": user_prompt })); + messages +} + fn chat_completions_url(base_url: &str) -> String { let trimmed = base_url.trim(); if trimmed.ends_with("/chat/completions") { @@ -1017,6 +1038,106 @@ mod tests { use std::net::TcpListener; use std::thread; + // ──────────────── 对话感知 polish 的 chat 消息构造 ──────────────── + // 用户的核心顾虑:让 LLM 拿到上下文但**不要把上下文吐出来**。 + // 这里的不变量保证「不复读」靠两层防御: + // 1. role=assistant 标记历史的 polished 输出,LLM 自然把它当成"已说过的" + // 2. system prompt 末尾追加 polish_context_instruction 显式禁止复读 + // 下面 3 个 test 把构造路径锁死,未来回归就能立刻暴露。 + + #[test] + fn build_polish_history_messages_empty_prior_falls_back_to_two_messages() { + // prior_turns 空时只剩 system + user,跟单轮 chat_completion 同构。 + let msgs = build_polish_history_messages("SYS", &[], "USER_NOW"); + assert_eq!(msgs.len(), 2); + assert_eq!(msgs[0]["role"], "system"); + assert_eq!(msgs[0]["content"], "SYS"); + assert_eq!(msgs[1]["role"], "user"); + assert_eq!(msgs[1]["content"], "USER_NOW"); + } + + #[test] + fn build_polish_history_messages_orders_prior_oldest_to_newest_then_current() { + // 入参约定 prior_turns 是 newest-first(match HistoryStore::recent_within_minutes + // 的返回顺序)。chat 需要 oldest-first 的时间序,build_* 必须 reverse。 + // 顺序错了 LLM 会看到「未来→过去→当前」错乱时间轴。 + let prior = vec![ + ("raw-newest".to_string(), "polish-newest".to_string()), + ("raw-mid".to_string(), "polish-mid".to_string()), + ("raw-oldest".to_string(), "polish-oldest".to_string()), + ]; + let msgs = build_polish_history_messages("SYS", &prior, "USER_NOW"); + + // 1 system + 3 turns × 2 + 1 current = 8 条 + assert_eq!(msgs.len(), 8, "应该是 system + 3×(user/assistant) + 当前 user"); + + // [0] system + assert_eq!(msgs[0]["role"], "system"); + // [1,2] = oldest 那一对 + assert_eq!(msgs[1]["role"], "user"); + assert!( + msgs[1]["content"].as_str().unwrap().contains("raw-oldest"), + "第一条 user 应当是最老的 raw,包装在 user_prompt 里" + ); + assert_eq!(msgs[2]["role"], "assistant"); + assert_eq!(msgs[2]["content"], "polish-oldest"); + // [3,4] = mid + assert_eq!(msgs[3]["role"], "user"); + assert!(msgs[3]["content"].as_str().unwrap().contains("raw-mid")); + assert_eq!(msgs[4]["role"], "assistant"); + assert_eq!(msgs[4]["content"], "polish-mid"); + // [5,6] = newest 那一对 + assert_eq!(msgs[5]["role"], "user"); + assert!(msgs[5]["content"].as_str().unwrap().contains("raw-newest")); + assert_eq!(msgs[6]["role"], "assistant"); + assert_eq!(msgs[6]["content"], "polish-newest"); + // [7] = 当前要润色的 user + assert_eq!(msgs[7]["role"], "user"); + assert_eq!(msgs[7]["content"], "USER_NOW"); + } + + #[test] + fn build_polish_history_messages_keeps_polished_text_at_assistant_role() { + // 关键不变量:历史 polish 必须在 assistant role 上,**不**能跟当前 user 混淆。 + // 一旦把 polish 放进 user role(比如重构时 typo),LLM 会以为这是 + // 用户新说的话,可能再润色一遍 → 输出复读上文,违反"不复读"目标。 + let prior = vec![("我说点什么".into(), "我说点什么。".into())]; + let msgs = build_polish_history_messages("SYS", &prior, "现在说的话"); + + // 第二条(idx=2)必须是 assistant + polished_text + assert_eq!( + msgs[2]["role"], "assistant", + "polished_text 必须挂在 assistant role;放到 user 会让 LLM 当成新输入再润色" + ); + assert_eq!(msgs[2]["content"], "我说点什么。"); + + // 检查最末条仍然是当前 user prompt,没被混进 assistant + let last = msgs.last().expect("non-empty"); + assert_eq!(last["role"], "user"); + assert_eq!(last["content"], "现在说的话"); + } + + #[test] + fn polish_context_instruction_explicitly_forbids_repeating_prior_assistant_output() { + // 第二层防御:system prompt 必须含明确的「不要复读历史 assistant」指令。 + // 仅靠 chat structure 不够——一些模型在长上下文里仍可能 echo prior turns。 + // 文案可以改、但下面这些关键词不能丢。 + let s = prompts::polish_context_instruction(); + assert!(s.contains("不要"), "需要中文显式禁止指令"); + assert!( + s.contains("复读") || s.contains("重复") || s.contains("不要把上文带进来"), + "需要明确禁止复读语义" + ); + assert!( + s.contains("assistant") || s.contains("已经整理"), + "需要点名是 assistant role 的历史输出 / 整理后内容" + ); + assert!( + s.contains("当前") && s.contains("最新"), + "需要明确:只输出当前最新一条" + ); + } + #[test] fn clean_polish_output_strips_think_tag_block() { let content =