diff --git a/openless-all/app/src-tauri/src/commands/dictation.rs b/openless-all/app/src-tauri/src/commands/dictation.rs index db36b799f..632b7dcd3 100644 --- a/openless-all/app/src-tauri/src/commands/dictation.rs +++ b/openless-all/app/src-tauri/src/commands/dictation.rs @@ -34,16 +34,20 @@ pub async fn inject_hotkey_click_for_dev(coord: CoordinatorState<'_>) -> Result< coord.inject_hotkey_click_for_dev().await } +/// `style_pack_id` 省略 = 用当前激活风格包(历史页「重试」);给了 id = 用指定风格包 +/// 试算一次(历史页「换风格重润色」),不改变激活状态。 #[tauri::command] pub async fn repolish( coord: CoordinatorState<'_>, raw_text: String, mode: PolishMode, + style_pack_id: Option, ) -> Result { log::info!( - "[style-pack] command repolish requested legacy_mode={:?} raw_chars={}", + "[style-pack] command repolish requested legacy_mode={:?} raw_chars={} style_pack_id={:?}", mode, - raw_text.chars().count() + raw_text.chars().count(), + style_pack_id ); - coord.repolish(raw_text, mode).await + coord.repolish(raw_text, mode, style_pack_id).await } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 785c320c6..fd7a7481f 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2028,14 +2028,33 @@ impl Coordinator { Ok(()) } - pub async fn repolish(&self, raw_text: String, mode: PolishMode) -> Result { + /// 用某个风格包重新润色一段已有原文。 + /// + /// `style_pack_id`: + /// - `None` → 用当前激活的风格包。历史页的「重试」走这条:同样的输入再给模型看一遍, + /// 用来判断上一次的结果是模型抖动还是稳定行为。 + /// - `Some(id)` → 用指定的风格包。历史页的「换风格重润色」走这条。 + /// + /// 指定的包**不需要**处于激活状态,也不会改变激活状态:这只是一次一次性试算, + /// 不该有把用户当前风格换掉的副作用。 + pub async fn repolish( + &self, + raw_text: String, + mode: PolishMode, + style_pack_id: Option, + ) -> Result { let hotwords = enabled_phrases(&self.inner); let prefs = self.inner.prefs.get(); - let pack = self - .inner - .style_packs - .get_or_default_active(&prefs.active_style_pack_id) - .map_err(|e| e.to_string())?; + let pack = match style_pack_id.as_deref() { + // 显式指定时按 id 精确取,不走 get_or_default_active 的兜底链——用户点的是 + // 「用这个风格看看」,静默回落到别的包会让结果无从解释。 + Some(id) => self.inner.style_packs.get(id).map_err(|e| e.to_string())?, + None => self + .inner + .style_packs + .get_or_default_active(&prefs.active_style_pack_id) + .map_err(|e| e.to_string())?, + }; let style_system_prompt = crate::types::style_pack_prompt( &pack, crate::types::StylePromptKind::DictationAsr, diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index a1bf889fd..0ee30e53d 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -2633,7 +2633,10 @@ fn build_transcribe_failed_session( asr_ms: u64, mode: PolishMode, has_audio_recording: bool, + front_app: Option<&str>, ) -> DictationSession { + // 失败条目也记前台应用:排查「在某个 app 里总是转录失败」时这一列就是线索。 + let front = crate::types::split_front_app_opt(front_app); DictationSession { id: session_id.to_string(), created_at: Utc::now().to_rfc3339(), @@ -2644,8 +2647,8 @@ fn build_transcribe_failed_session( style_pack_id: None, translation_active: false, polish_source: None, - app_bundle_id: None, - app_name: None, + app_bundle_id: front.bundle_id, + app_name: front.name, insert_status: InsertStatus::Failed, error_code: Some("transcribeFailed".to_string()), duration_ms: Some(duration_ms), @@ -2668,12 +2671,14 @@ fn write_transcribe_failed_history( asr_call_label: Option<&AsrCallLabel>, ) { let prefs = inner.prefs.get(); + let front_app = inner.state.lock().front_app.clone(); let mut session = build_transcribe_failed_session( session_id, duration_ms, asr_ms, prefs.default_mode, inner.audio_archive_active.load(Ordering::Relaxed), + front_app.as_deref(), ); // 失败条目也记下是哪个 ASR 出的错——「哪个模型转不出来」正是模型对比要看的信息。 // 用 begin_session 的构建时快照,而不是此刻重读设置(PR #826 review)。 @@ -3468,6 +3473,10 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } if raw.text.trim().is_empty() { + // 失败条目同样记下当时的前台应用:排查「在某个 app 里总是识别不到」时,这一列 + // 就是线索本身。 + let empty_front = + crate::types::split_front_app_opt(inner.state.lock().front_app.as_deref()); let session = DictationSession { // session_id 与归档 wav 同名,empty 录音才能被 read_audio_recording / // retranscribe_recording 凭 id 找回(之前用 Uuid::new_v4,与 `.wav` @@ -3481,8 +3490,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { style_pack_id: None, translation_active: false, polish_source: None, - app_bundle_id: None, - app_name: None, + app_bundle_id: empty_front.bundle_id, + app_name: empty_front.name, insert_status: InsertStatus::Failed, error_code: Some("emptyTranscript".to_string()), duration_ms: Some(raw.duration_ms), @@ -3900,6 +3909,10 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { let history_session_id = current_session_id.to_string(); let history_created_at = Utc::now().to_rfc3339(); let prefs_snapshot = inner.prefs.get(); + // 落字目标应用:begin_session 就采过(capture_frontmost_app),此前只喂给了 polish + // prompt,没写进历史 —— 于是详情页的「插入」行永远只有字数,看不出这段话落到了哪。 + // 前端早就会渲染 app_name,缺的一直是这里的写入。 + let insert_front = crate::types::split_front_app_opt(front_app.as_deref()); let session = DictationSession { id: history_session_id.clone(), created_at: history_created_at.clone(), @@ -3910,8 +3923,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { style_pack_id: Some(pack.id.clone()), translation_active, polish_source, - app_bundle_id: None, - app_name: None, + app_bundle_id: insert_front.bundle_id, + app_name: insert_front.name, insert_status: status, error_code, duration_ms: Some(raw.duration_ms), @@ -4344,7 +4357,7 @@ mod tests { // 录音随 prune 丢失(用户报告「识别失败之前的语音也都丢失了」)。 let sid = Uuid::new_v4(); let session = - build_transcribe_failed_session(sid, 4200, 17_250, PolishMode::Structured, true); + build_transcribe_failed_session(sid, 4200, 17_250, PolishMode::Structured, true, None); assert_eq!(session.id, sid.to_string()); } @@ -4352,7 +4365,7 @@ mod tests { fn transcribe_failed_history_marks_failed_and_recoverable() { let sid = Uuid::new_v4(); let session = - build_transcribe_failed_session(sid, 1234, 17_250, PolishMode::Structured, true); + build_transcribe_failed_session(sid, 1234, 17_250, PolishMode::Structured, true, None); assert!(matches!(session.insert_status, InsertStatus::Failed)); assert_eq!(session.error_code.as_deref(), Some("transcribeFailed")); assert_eq!(session.duration_ms, Some(1234)); @@ -4366,7 +4379,7 @@ mod tests { // 录音归档失败(has_audio=false)→ 条目仍写(用户看得到这次失败),但不标可重转, // 避免前端渲染重转按钮而后端找不到 wav。 let sid = Uuid::new_v4(); - let session = build_transcribe_failed_session(sid, 1, 250, PolishMode::Structured, false); + let session = build_transcribe_failed_session(sid, 1, 250, PolishMode::Structured, false, None); assert_eq!(session.has_audio_recording, Some(false)); } diff --git a/openless-all/app/src-tauri/src/coordinator/qa_session.rs b/openless-all/app/src-tauri/src/coordinator/qa_session.rs index 2a7566ef6..60f6cefb7 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa_session.rs @@ -750,6 +750,8 @@ pub(super) async fn answer_qa_question_text( } if prefs.qa_save_history { + // 与听写路径同口径:应用名与 bundle id 分开存。 + let qa_front = crate::types::split_front_app_opt(front_app.as_deref()); let session = DictationSession { id: Uuid::new_v4().to_string(), created_at: Utc::now().to_rfc3339(), @@ -760,8 +762,8 @@ pub(super) async fn answer_qa_question_text( style_pack_id: None, translation_active: false, polish_source: None, - app_bundle_id: None, - app_name: front_app, + app_bundle_id: qa_front.bundle_id, + app_name: qa_front.name, insert_status: InsertStatus::CopiedFallback, error_code: Some("qaSession".to_string()), duration_ms: Some(duration_ms), diff --git a/openless-all/app/src-tauri/src/coordinator/selection_polish.rs b/openless-all/app/src-tauri/src/coordinator/selection_polish.rs index 699f4020f..2303bcc9d 100644 --- a/openless-all/app/src-tauri/src/coordinator/selection_polish.rs +++ b/openless-all/app/src-tauri/src/coordinator/selection_polish.rs @@ -317,6 +317,8 @@ pub(super) async fn run_selection_polish(inner: &Arc) -> Result<(), Strin None => (None, None), }; let raw_chars = raw_text.chars().count(); + // 与听写路径同口径:应用名与 bundle id 分开存。 + let source_front = crate::types::split_front_app_opt(source_app.as_deref()); let session = DictationSession { id: Uuid::new_v4().to_string(), created_at: Utc::now().to_rfc3339(), @@ -327,8 +329,8 @@ pub(super) async fn run_selection_polish(inner: &Arc) -> Result<(), Strin style_pack_id: Some(pack.id.clone()), translation_active: false, polish_source: None, - app_bundle_id: None, - app_name: source_app, + app_bundle_id: source_front.bundle_id, + app_name: source_front.name, insert_status: status, error_code: (status == InsertStatus::Failed) .then_some("selectionPolishInsertFailed".into()), @@ -438,6 +440,9 @@ impl Coordinator { log::error!("[selection-polish] record vocabulary hits failed: {error}"); Some(0) }); + // 与听写路径同口径:应用名与 bundle id 分开存,详情页才不会把一长串 bundle id + // 糊进正文。 + let preview_front = crate::types::split_front_app_opt(preview.source_app.as_deref()); let session = DictationSession { id: Uuid::new_v4().to_string(), created_at: Utc::now().to_rfc3339(), @@ -448,8 +453,8 @@ impl Coordinator { style_pack_id: Some(preview.style_pack_id), translation_active: false, polish_source: None, - app_bundle_id: None, - app_name: preview.source_app, + app_bundle_id: preview_front.bundle_id, + app_name: preview_front.name, insert_status: status, error_code: None, duration_ms: Some(preview.started_at.elapsed().as_millis() as u64), diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 37682ee4b..b1a49a8e8 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -145,6 +145,56 @@ pub enum SelectionPolishOutputMode { PreviewConfirm, } +/// 前台应用标签拆分结果:人读的应用名 +(macOS 的)bundle id。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FrontApp { + pub name: Option, + pub bundle_id: Option, +} + +/// 把 `capture_frontmost_app()` 的显示串拆成 `FrontApp { name, bundle_id }`。 +/// +/// macOS 那边拼的是 `"Claude (com.anthropic.claudefordesktop)"`;Windows 拿的是窗口 +/// 标题,没有 bundle id。历史条目有 `app_name` / `app_bundle_id` 两个字段,拆开存 +/// 才能让详情页只显示人读得懂的应用名,而不是把一长串 bundle id 也糊在正文里。 +/// +/// 只有 macOS 的标签才是 `"名称 (bundle.id)"` 格式;Windows 拿的是窗口标题,括号属于 +/// 标题正文。调用方必须按平台传入 `is_macos`(生产路径统一走 `split_front_app_opt`), +/// 非 macOS 一律整串当应用名。认不出括号结构也整串当应用名 —— 宁可显示得啰嗦, +/// 也不要把窗口标题里的普通括号误当成 bundle id。 +pub fn split_front_app_label(label: &str, is_macos: bool) -> FrontApp { + let trimmed = label.trim(); + if trimmed.is_empty() { + return FrontApp { name: None, bundle_id: None }; + } + if is_macos { + if let Some(open) = trimmed.rfind(" (") { + if trimmed.ends_with(')') { + let name = trimmed[..open].trim(); + let bundle = trimmed[open + 2..trimmed.len() - 1].trim(); + // bundle id 必然是点分的反向域名。没有点的括号内容("记事本 (未保存)" + // 这类窗口标题)不是 bundle id,不能拆。 + if !name.is_empty() && bundle.contains('.') && !bundle.contains(' ') { + return FrontApp { + name: Some(name.to_string()), + bundle_id: Some(bundle.to_string()), + }; + } + } + } + } + FrontApp { name: Some(trimmed.to_string()), bundle_id: None } +} + +/// `split_front_app_label` 的 `Option` 便捷版,平台开关收敛在这一处: +/// 只有 macOS 的显示串才是 `"名称 (bundle.id)"`,其它平台(Windows 窗口标题、Linux) +/// 整串当应用名,bundle id 留空。 +pub fn split_front_app_opt(label: Option<&str>) -> FrontApp { + label + .map(|l| split_front_app_label(l, cfg!(target_os = "macos"))) + .unwrap_or(FrontApp { name: None, bundle_id: None }) +} + /// 概览页活动统计的单日汇总(date = 本地日期 YYYY-MM-DD)。 /// /// 年度热力图只用 `count`;`chars` / `duration_ms` 供「近 7 天 / 近 30 天」的 @@ -3019,6 +3069,75 @@ pub struct QaChatMessage { pub selection_text: Option, } +#[cfg(test)] +mod split_front_app_label_tests { + use super::{split_front_app_label, split_front_app_opt, FrontApp}; + + #[test] + fn macos_label_splits_into_name_and_bundle() { + let split = split_front_app_label("Claude (com.anthropic.claudefordesktop)", true); + assert_eq!(split.name.as_deref(), Some("Claude")); + assert_eq!(split.bundle_id.as_deref(), Some("com.anthropic.claudefordesktop")); + } + + #[test] + fn app_names_containing_spaces_and_parens_still_split_on_the_last_group() { + let split = split_front_app_label("Visual Studio Code (com.microsoft.VSCode)", true); + assert_eq!(split.name.as_deref(), Some("Visual Studio Code")); + assert_eq!(split.bundle_id.as_deref(), Some("com.microsoft.VSCode")); + } + + /// Windows 拿的是窗口标题,里面的括号是正文的一部分,不是 bundle id。 + /// 平台开关关闭时整串保留——即使括号内容恰好形如反向域名、文件路径或版本号, + /// 也绝不拆。误拆会把标题截断,显示成半句话,还写入错误的 bundle id。 + #[test] + fn window_titles_are_never_split_outside_macos() { + for title in [ + "未命名文档 (未保存)", + "report.txt (~/Documents)", + "Inbox (12)", + "script.py (C:\\dir\\script.py)", + "会议 (meet.example.com)", + "卸载 (2.4.1)", + ] { + let split = split_front_app_label(title, false); + assert_eq!(split.name.as_deref(), Some(title), "{title} should stay intact"); + assert_eq!(split.bundle_id, None, "{title} has no bundle id"); + } + } + + #[test] + fn bare_names_pass_through() { + let split = split_front_app_label("Terminal", true); + assert_eq!(split.name.as_deref(), Some("Terminal")); + assert_eq!(split.bundle_id, None); + } + + #[test] + fn blank_input_yields_nothing() { + assert_eq!( + split_front_app_label("", true), + FrontApp { name: None, bundle_id: None } + ); + assert_eq!( + split_front_app_label(" ", true), + FrontApp { name: None, bundle_id: None } + ); + assert_eq!( + split_front_app_label("", false), + FrontApp { name: None, bundle_id: None } + ); + assert_eq!( + split_front_app_label(" ", false), + FrontApp { name: None, bundle_id: None } + ); + assert_eq!( + split_front_app_opt(None), + FrontApp { name: None, bundle_id: None } + ); + } +} + #[cfg(test)] mod translation_effective_tests { use super::translation_effective; diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index d81cd64e8..f06187d62 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -401,6 +401,23 @@ export const en: typeof zhCN = { insertFailed: 'Insert failed', confirmClear: 'Delete all {{count}} history entries? This cannot be undone.', backToList: 'Back to list', + repolish: { + title: 'Re-polish', + hint: 'Run polish again on the transcript above. Results are shown for this visit only and are not written back to the record. When the original style pack was deleted or the record predates style packs, retry uses the current style.', + retry: 'Retry with same style', + retrying: 'Retrying…', + apply: 'Apply', + applying: 'Polishing…', + pickStyle: 'Pick a style pack', + noPacks: 'No style packs available.', + packsLoadFailed: 'Failed to load style packs: {{err}}', + failed: 'Re-polish failed: {{err}}', + timeout: 'The current LLM provider did not respond within 30 seconds. Switch to a faster provider, or try again later — free model pools often queue.', + resultTitle: 'Result from {{name}}', + retryResultTitle: 'Retry result', + empty: '(the model returned an empty result)', + clear: 'Clear results', + }, }, vocab: { kicker: 'VOCABULARY', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index a11b8a590..ba8171265 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -403,6 +403,23 @@ export const ja: typeof zhCN = { insertFailed: '入力失敗', confirmClear: '全 {{count}} 件の記録を削除しますか?この操作は取り消せません。', backToList: '一覧に戻る', + repolish: { + title: '再整文', + hint: '上の原文でもう一度整文を実行します。結果は今回の表示のみで、この記録には書き戻しません。元のスタイルパックが削除されているか、古い記録の場合は、再試行では現在のスタイルを使用します。', + retry: '同じスタイルで再試行', + retrying: '再試行中…', + apply: '適用', + applying: '整文中…', + pickStyle: 'スタイルパックを選択', + noPacks: '利用できるスタイルパックがありません。', + packsLoadFailed: 'スタイルパックの読み込みに失敗:{{err}}', + failed: '再整文に失敗:{{err}}', + timeout: '現在の LLM プロバイダーが 30 秒以内に応答しませんでした。より速いプロバイダーに切り替えるか、後でもう一度お試しください(無料モデルプールは混雑しがちです)。', + resultTitle: '{{name}} の結果', + retryResultTitle: '再試行の結果', + empty: '(モデルが空の結果を返しました)', + clear: '結果を消去', + }, }, vocab: { kicker: 'VOCABULARY', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 28e6a57e3..f3020b21b 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -403,6 +403,23 @@ export const ko: typeof zhCN = { insertFailed: '입력 실패', confirmClear: '전체 {{count}}건의 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', backToList: '목록으로', + repolish: { + title: '다시 다듬기', + hint: '위 원문으로 다듬기를 다시 실행합니다. 결과는 이번 조회에만 표시되며 기록에 반영되지 않습니다. 원래 스타일 팩이 삭제되었거나 오래된 기록인 경우, 다시 시도 시 현재 스타일을 사용합니다.', + retry: '같은 스타일로 재시도', + retrying: '재시도 중…', + apply: '적용', + applying: '다듬는 중…', + pickStyle: '스타일 팩 선택', + noPacks: '사용할 수 있는 스타일 팩이 없습니다.', + packsLoadFailed: '스타일 팩 로드 실패: {{err}}', + failed: '다시 다듬기 실패: {{err}}', + timeout: '현재 LLM 제공자가 30초 안에 응답하지 않았습니다. 더 빠른 제공자로 바꾸거나 잠시 후 다시 시도하세요 — 무료 모델 풀은 대기가 잦습니다.', + resultTitle: '{{name}} 결과', + retryResultTitle: '재시도 결과', + empty: '(모델이 빈 결과를 반환했습니다)', + clear: '결과 지우기', + }, }, vocab: { kicker: 'VOCABULARY', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ba5a78a9d..e1237fa7d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -399,6 +399,23 @@ export const zhCN = { insertFailed: '插入失败', confirmClear: '确定清空全部 {{count}} 条记录?此操作不可恢复。', backToList: '返回列表', + repolish: { + title: '重新润色', + hint: '基于上面的原文再跑一次润色。结果只在本次查看时显示,不写回这条记录。原风格包已删除或旧记录时,重试将使用当前风格。', + retry: '用原风格重试', + retrying: '重试中…', + apply: '应用', + applying: '润色中…', + pickStyle: '选择风格包', + noPacks: '没有可用的风格包。', + packsLoadFailed: '读取风格包失败:{{err}}', + failed: '重新润色失败:{{err}}', + timeout: '当前 LLM 提供商 30 秒内没有返回结果。换个更快的提供商,或稍后重试 —— 免费模型池经常排队。', + resultTitle: '{{name}} 的结果', + retryResultTitle: '重试结果', + empty: '(模型返回了空结果)', + clear: '清除结果', + }, }, vocab: { kicker: 'VOCABULARY', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 23bc9edcd..de12e715a 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -401,6 +401,23 @@ export const zhTW: typeof zhCN = { insertFailed: '插入失敗', confirmClear: '確定清空全部 {{count}} 條記錄?此操作不可恢復。', backToList: '返回列表', + repolish: { + title: '重新潤色', + hint: '基於上面的原文再跑一次潤色。結果只在本次查看時顯示,不寫回這條記錄。原風格包已刪除或舊記錄時,重試將使用當前風格。', + retry: '用原風格重試', + retrying: '重試中…', + apply: '套用', + applying: '潤色中…', + pickStyle: '選擇風格包', + noPacks: '沒有可用的風格包。', + packsLoadFailed: '讀取風格包失敗:{{err}}', + failed: '重新潤色失敗:{{err}}', + timeout: '當前 LLM 提供商 30 秒內沒有返回結果。換個更快的提供商,或稍後重試 —— 免費模型池經常排隊。', + resultTitle: '{{name}} 的結果', + retryResultTitle: '重試結果', + empty: '(模型返回了空結果)', + clear: '清除結果', + }, }, vocab: { kicker: 'VOCABULARY', diff --git a/openless-all/app/src/lib/history-repolish.test.ts b/openless-all/app/src/lib/history-repolish.test.ts new file mode 100644 index 000000000..b50b9ce71 --- /dev/null +++ b/openless-all/app/src/lib/history-repolish.test.ts @@ -0,0 +1,135 @@ +import { + defaultPackId, + packDisplayName, + resolveRepolishRetryPackId, + resolveRepolishRetryPackIdWithFallback, +} from './history-repolish'; +import type { PolishMode, StylePack } from './types'; + +function assert(condition: boolean, message: string) { + if (!condition) throw new Error(message); +} + +function pack( + id: string, + enabled: boolean, + kind: StylePack['kind'] = 'imported', + baseMode: PolishMode = 'structured', +): StylePack { + return { + id, + name: `包 ${id}`, + description: '', + version: '1.0.0', + kind, + baseMode, + selectionPrompt: '', + prompt: '', + examples: [], + tags: [], + enabled, + active: false, + }; +} + +const allPacks: StylePack[] = [ + pack('builtin.structured', true), + pack('custom-alive', true), + pack('custom-disabled', false), +]; + +const modeLabel: Record = { + raw: 'Raw', + light: 'Light polish', + structured: 'Structured', + formal: 'Formal', +}; + +// 原风格包存在(启用)→ 返回该 id。 +assert( + resolveRepolishRetryPackId({ stylePackId: 'custom-alive' }, allPacks) === 'custom-alive', + 'retry should use the original pack id when the pack still exists', +); + +// 原风格包已被禁用 → 仍返回该 id(历史可能出自后来被禁用的包,只要包还在就能重试)。 +assert( + resolveRepolishRetryPackId({ stylePackId: 'custom-disabled' }, allPacks) === 'custom-disabled', + 'retry should use the original pack id even when the pack is disabled', +); + +// 内置包同样按原 id 重试。 +assert( + resolveRepolishRetryPackId({ stylePackId: 'builtin.structured' }, allPacks) === 'builtin.structured', + 'retry should use the builtin pack id as-is', +); + +// 包已被删除 → 回落(undefined,调用方走当前激活包)。 +assert( + resolveRepolishRetryPackId({ stylePackId: 'deleted-pack' }, allPacks) === undefined, + 'retry should fall back when the original pack was deleted', +); + +// 旧历史没有 stylePackId → 回落。 +assert( + resolveRepolishRetryPackId({ stylePackId: null }, allPacks) === undefined, + 'retry should fall back when the record has no stylePackId', +); + +// 顶层包列表尚未加载(null)→ 回落。 +assert( + resolveRepolishRetryPackId({ stylePackId: 'custom-alive' }, null) === undefined, + 'retry should fall back while style packs are still loading', +); + +// 内置包显示名走 i18n mode 名,自定义包用原名。 +assert( + packDisplayName(pack('builtin.light', true, 'builtin', 'light'), modeLabel) === 'Light polish', + 'builtin packs should display the i18n mode label', +); +assert( + packDisplayName(pack('custom-alive', true), modeLabel) === '包 custom-alive', + 'custom packs should display their own name', +); + +// 下拉默认:当前激活包优先,其次第一个包,空列表为 ''。 +assert( + defaultPackId([ + pack('a', true), + { ...pack('b', true), active: true }, + pack('c', true), + ]) === 'b', + 'default should prefer the active pack', +); +assert( + defaultPackId([pack('a', true), pack('b', true)]) === 'a', + 'default should fall back to the first pack when none is active', +); +assert(defaultPackId([]) === '', 'default should be empty for an empty list'); + +// 重试回落:原包删除/未加载时显式落到当前激活包(其次第一个),列表全不可用才不传。 +const enabledPacks: StylePack[] = [ + { ...pack('active-pack', true), active: true }, + pack('idle-pack', true), +]; +assert( + resolveRepolishRetryPackIdWithFallback({ stylePackId: 'custom-alive' }, allPacks, enabledPacks) + === 'custom-alive', + 'retry-with-fallback should keep the original pack when it still exists', +); +assert( + resolveRepolishRetryPackIdWithFallback({ stylePackId: 'deleted-pack' }, allPacks, enabledPacks) + === 'active-pack', + 'retry-with-fallback should use the active pack when the original was deleted', +); +assert( + resolveRepolishRetryPackIdWithFallback( + { stylePackId: null }, + allPacks, + [pack('only-pack', true)], + ) === 'only-pack', + 'retry-with-fallback should use the first enabled pack when none is active', +); +assert( + resolveRepolishRetryPackIdWithFallback({ stylePackId: 'custom-alive' }, null, []) === undefined, + 'retry-with-fallback should stay undefined when no pack list is available', +); diff --git a/openless-all/app/src/lib/history-repolish.ts b/openless-all/app/src/lib/history-repolish.ts new file mode 100644 index 000000000..1882f38b4 --- /dev/null +++ b/openless-all/app/src/lib/history-repolish.ts @@ -0,0 +1,53 @@ +import type { DictationSession, PolishMode, StylePack } from './types'; + +/** + * 「用原风格重试」要用的风格包 id。 + * + * 优先取产生这条记录的风格包(session.stylePackId)——重试的目的是跟上次结果做 + * A/B 对照,必须用同一套风格,否则判断不了是模型抖动还是风格差异。包已被删除、 + * 旧历史没有 stylePackId、或顶层包列表尚未加载(allPacks 为 null)时返回 undefined, + * 由调用方回落当前激活风格包(repolish 省略 stylePackId 的行为)。 + * + * 注意查的是 allPacks(含已禁用包):历史可能出自后来被禁用的包,只要包还在就能重试。 + */ +export function resolveRepolishRetryPackId( + session: Pick, + allPacks: StylePack[] | null, +): string | undefined { + if (!session.stylePackId || !allPacks) return undefined; + return allPacks.some(pack => pack.id === session.stylePackId) + ? session.stylePackId + : undefined; +} + +/** + * 风格包在界面上的显示名。 + * + * 内置包例外:后端内置包名是硬编码中文("轻度润色"…),直接显示会在英/日/韩界面 + * 串语言,所以内置包一律走 i18n 的 mode 名(与历史条目 Pill 同一原则)。自定义包 + * 显示用户起的原名。 + */ +export function packDisplayName( + pack: StylePack, + modeLabel: Record, +): string { + return pack.kind === 'builtin' ? modeLabel[pack.baseMode] : pack.name.trim(); +} + +/** 「换风格」下拉的默认选中项:当前激活包优先,其次第一个可用包,空列表返回 ''。 */ +export function defaultPackId(packs: StylePack[]): string { + return packs.find(pack => pack.active)?.id || packs[0]?.id || ''; +} + +/** + * 「用原风格重试」实际要用的风格包 id:优先产生这条记录的原包;原包已删除、旧历史 + * 没有 stylePackId、或包列表尚未加载时,显式落到当前激活包(其次第一个可用包)—— + * 显式传 id 让前端标注与实际执行一致,而不是让后端走 None 的兜底链。 + */ +export function resolveRepolishRetryPackIdWithFallback( + session: Pick, + allPacks: StylePack[] | null, + enabledPacks: StylePack[], +): string | undefined { + return (resolveRepolishRetryPackId(session, allPacks) ?? defaultPackId(enabledPacks)) || undefined; +} diff --git a/openless-all/app/src/lib/ipc/style-packs.ts b/openless-all/app/src/lib/ipc/style-packs.ts index e5fe1d04a..1315caeb6 100644 --- a/openless-all/app/src/lib/ipc/style-packs.ts +++ b/openless-all/app/src/lib/ipc/style-packs.ts @@ -105,6 +105,17 @@ export function exportStylePackToZip( ) } -export function repolish(rawText: string, mode: PolishMode): Promise { - return invokeOrMock("repolish", { rawText, mode }, () => rawText) +/** 用某个风格包重新润色一段已有原文。 + * `stylePackId` 省略 = 用当前激活风格包(历史页「重试」:同样输入再跑一遍); + * 给了 id = 用指定风格包试算一次(历史页「换风格重润色」),不改变激活状态。 */ +export function repolish( + rawText: string, + mode: PolishMode, + stylePackId?: string, +): Promise { + return invokeOrMock( + "repolish", + { rawText, mode, stylePackId }, + () => `${rawText}(mock:${stylePackId ?? "当前风格"} 重新润色)`, + ) } diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 282479161..71ce3d0d9 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -7,9 +7,10 @@ import { Icon } from '../components/Icon'; import { Tooltip } from '../components/Tooltip'; import { detectOS } from '../components/WindowChrome'; import { formatComboLabel } from '../lib/hotkey'; -import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording, retranscribeRecording, isTauri } from '../lib/ipc'; +import { clearHistory, deleteHistoryEntry, listHistory, listStylePacks, readAudioRecording, repolish, retranscribeRecording, isTauri } from '../lib/ipc'; +import { defaultPackId, packDisplayName, resolveRepolishRetryPackIdWithFallback } from '../lib/history-repolish'; import { useMobileLayout } from '../lib/useMobileLayout'; -import type { DictationSession, PolishMode } from '../lib/types'; +import type { DictationSession, PolishMode, StylePack } from '../lib/types'; import { countCodePoints } from '../lib/unicode'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; @@ -36,6 +37,33 @@ function useModeLabel(): Record { }; } +// Pill 默认 nowrap + flexShrink: 0,遇上长包名会把同一排的按钮挤变形(「复制」文字竖排)。 +// 显示包名的地方一律改成可收缩 + 省略号,全名挂在外层容器的 title 上悬停查看。 +const TRUNCATED_PILL_STYLE = { + minWidth: 0, + maxWidth: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', + display: 'block', + flexShrink: 1, +} as const; + +// 历史条目上显示「哪个风格包产出的这段文本」。session.mode 只是风格包的 baseMode +// (四个内置分类之一),自建包全都会落进这四个桶,光看 mode 分不出是哪个包—— +// 所以优先用 stylePackId 查真实包名,跟本页「重新润色」面板里的风格命名对齐。 +// 内置包例外与命名规则统一走 packDisplayName;旧历史没有 stylePackId、或包已被删除 +// 时同样回落到 mode 名。 +function styleLabelFor( + session: DictationSession, + allPacks: StylePack[] | null, + modeLabel: Record, +): string { + const pack = session.stylePackId + ? allPacks?.find(candidate => candidate.id === session.stylePackId) + : undefined; + return pack ? packDisplayName(pack, modeLabel) : modeLabel[session.mode]; +} + export function History() { const { t } = useTranslation(); const os = detectOS(); @@ -70,6 +98,12 @@ export function History() { const { prefs } = useHotkeySettings(); const mobile = useMobileLayout(); const [mobileDetailOpen, setMobileDetailOpen] = useState(false); + // 风格包在本页有两个用途:给历史条目显示包名、给「重新润色」面板选风格。加载提到这里 + // 一次拿全,两处共用,省掉切换条目时 RepolishPanel 重挂载带来的重复 IPC。 + // 注意这里存的是**全部**包(含已禁用):历史条目可能出自后来被禁用的包,显示名字要能查到; + // RepolishPanel 自己再 filter(enabled),禁用的包不该出现在可选风格里。 + const [allPacks, setAllPacks] = useState(null); + const [packsError, setPacksError] = useState(null); const refresh = useCallback(async () => { setLoading(true); @@ -91,6 +125,24 @@ export function History() { void refresh(); }, [refresh]); + useEffect(() => { + let cancelled = false; + listStylePacks() + .then(packs => { + if (!cancelled) setAllPacks(packs); + }) + .catch(err => { + if (cancelled) return; + console.error('[history] failed to load style packs', err); + setPacksError(errorMessage(err)); + }); + return () => { cancelled = true; }; + }, []); + + // 不缓存:MODE_LABEL 每次渲染都是新对象,用 useCallback 反而会把旧语言的标签闭包 + // 留在缓存里,切换界面语言后 Pill 不跟着变。只在渲染里调用,重建成本可忽略。 + const styleLabel = (session: DictationSession) => styleLabelFor(session, allPacks, MODE_LABEL); + const searchInputRef = useRef(null); const searchShortcut = os === 'mac' ? '⌘K' : 'Ctrl+K'; @@ -366,7 +418,12 @@ export function History() {
{s.finalText.split('\n')[0]}
-
{MODE_LABEL[s.mode]}
+ {/* tone 仍按 baseMode 走:颜色保留原来的粗分类信息,文字换成实际风格包名。 */} +
+ + {styleLabel(s)} + +
))} @@ -385,9 +442,11 @@ export function History() { )}
-
- {formatTime(item.createdAt)} - {MODE_LABEL[item.mode]} +
+ {formatTime(item.createdAt)} + + {styleLabel(item)} + {/* 「录音」前缀:与下方识别/润色耗时区分——录音时长发生在松键前, 不该与流水线各步耗时加总(用户反馈"时间对不上")。 */} {t('history.recorded', { duration: formatDuration(item.durationMs, t) })} @@ -413,36 +472,10 @@ export function History() { key={item.id} /> )} -
-
-
- {t('history.rawLabel')} - {item.rawTranscript && ( - void onCopyRaw()}> - {justCopiedRaw ? t('common.copied') : t('common.copy')} - - )} -
-

- {item.rawTranscript || t('history.rawEmpty')} -

-
-
-
- {MODE_LABEL[item.mode]} - void onCopy()}> - {justCopied ? t('common.copied') : t('common.copy')} - -
-

- {item.finalText} -

-
-
{/* 流水线明细:识别 / 润色 / 插入 三步各占一行 —— 左列步骤名、中列 provider·model(或插入目标),右列该步耗时/状态。旧历史没有模型与 耗时字段时对应行自动隐藏,只剩插入行 = 改版前的信息量。 */} -
+
{(item.asrProvider || item.asrMs != null) && ( <> @@ -455,7 +488,7 @@ export function History() { {[item.asrProvider, item.asrModel].filter(Boolean).join(' · ')} - + {item.asrMs != null ? formatStepDuration(item.asrMs, t) : ''} @@ -466,7 +499,7 @@ export function History() { {[item.llmProvider, item.llmModel].filter(Boolean).join(' · ')} - + {item.polishMs != null ? formatStepDuration(item.polishMs, t) : ''} @@ -481,7 +514,7 @@ export function History() { <>{' · '}{t('history.vocabHits', { count: item.dictionaryEntryCount })} )} - { + { item.insertStatus === 'inserted' ? t('history.inserted') : item.insertStatus === 'pasteSent' @@ -491,6 +524,52 @@ export function History() { : t('history.insertFailed') }
+ {/* minWidth: 0 —— grid 子项默认 min-width: auto,任何不换行的内容(这里是风格包名 + Pill)都会把整列撑出卡片、逼出横向滚动条。两栏都要加,否则一栏撑宽另一栏跟着宽。 */} +
+
+
+ {t('history.rawLabel')} + {item.rawTranscript && ( + void onCopyRaw()}> + {justCopiedRaw ? t('common.copied') : t('common.copy')} + + )} +
+

+ {item.rawTranscript || t('history.rawEmpty')} +

+
+
+
+ + {styleLabel(item)} + + {/* 「复制」不能被长包名压缩:压窄后按钮文字会竖排。 */} + + void onCopy()}> + {justCopied ? t('common.copied') : t('common.copy')} + + +
+

+ {item.finalText} +

+
+
+ {/* 重新润色:拿这条的原文再跑一次 LLM。没有原文就没得润色(转录失败条目), + 此时整块不渲染;QA 记录的原文是问题而不是待润色文本,同样不渲染。 + key={item.id} 让切换记录时结果与状态一起重置, + 避免把上一条的结果留在新条目下面。 */} + {item.rawTranscript.trim() && item.errorCode !== 'qaSession' && ( + + )} ) : (
@@ -504,12 +583,225 @@ export function History() { ); } +/** 后端超时错误在 IPC 边界退化成裸字符串(LLMError::Timeout → "timeout")。 + * 只匹配整串的常见超时形态,避免其它含 "timeout" 字样的错误被误判成超时。 */ +function isTimeout(message: string): boolean { + const trimmed = message.trim(); + return /^(timeout|timed out|request timed out)$/i.test(trimmed) || trimmed.includes('超时'); +} + function errorMessage(error: unknown): string { if (typeof error === 'string') return error; if (error instanceof Error) return error.message; return String(error); } +interface RepolishResult { + /** 结果卡片的 key。同一个风格重复应用会覆盖上一次,不无限堆卡片。 */ + key: string; + title: string; + text: string; +} + +/** + * 「重新润色」面板:拿这条历史的**原文**再跑一次 LLM。 + * + * 两个入口共用一条后端通道(`repolish`,stylePackId 可选): + * - 「用原风格重试」→ 优先传产生这条记录的风格包 id(包已删除/旧历史/未加载时回落当前 + * 激活风格)。用同一套风格再跑一遍,才能判断上次的结果是模型抖动还是稳定行为 —— + * 这是用户说「AI 识别得不对」时真正想做的对照实验。 + * - 「应用」→ 传选中的 pack id,看同一段话换个风格是什么样。 + * + * 结果只在本次查看时显示,不写回历史条目:历史的 finalText 是「当时真的插进去的那段 + * 文字」,是一条事实记录,不该被事后试算覆盖。面板顶部的说明也把这点直说了。 + * + * 注意这里只重跑润色,不重跑识别 —— 成功听写的录音在插入后就删了(隐私设计), + * 原文是唯一还在的输入。真正的「重新转录」入口仍只对留有录音的失败条目开放。 + */ +function RepolishPanel({ session, mobile, allPacks, packsError }: { + session: DictationSession; + mobile: boolean; + /** History 顶层加载的**全部**风格包(含已禁用);null 表示还在加载。 */ + allPacks: StylePack[] | null; + packsError: string | null; +}) { + const { t } = useTranslation(); + const MODE_LABEL = useModeLabel(); + const [selectedPackId, setSelectedPackId] = useState(''); + const [running, setRunning] = useState<'retry' | 'apply' | null>(null); + const [error, setError] = useState(null); + const [results, setResults] = useState([]); + + // 只列启用的包:禁用的包在别处也不参与润色,这里列出来会让「应用」得到 + // 一个用户以为已经关掉的风格。 + const packs = useMemo( + () => (allPacks ? allPacks.filter(p => p.enabled) : null), + [allPacks], + ); + + useEffect(() => { + if (!packs) return; + setSelectedPackId(current => current || defaultPackId(packs)); + }, [packs]); + + const run = async (kind: 'retry' | 'apply') => { + // 重试优先用产生这条记录的原包;原包已删除/旧历史/未加载时显式落到当前激活包 + // (其次第一个可用包)——前端标注与实际执行一致,而不是让后端走 None 兜底链。 + const packId = kind === 'apply' + ? selectedPackId + : resolveRepolishRetryPackIdWithFallback(session, allPacks, packs ?? []); + if (kind === 'apply' && !packId) return; + setRunning(kind); + setError(null); + try { + const text = await repolish(session.rawTranscript, session.mode, packId); + // 用 allPacks 而非 packs 找包名:按已禁用原包重试时标题仍显示真实包名。 + const pack = packId ? allPacks?.find(p => p.id === packId) : undefined; + const result: RepolishResult = { + key: packId ?? '__retry__', + title: pack + ? t('history.repolish.resultTitle', { name: packDisplayName(pack, MODE_LABEL) }) + : t('history.repolish.retryResultTitle'), + text, + }; + // 同一个 key 覆盖旧结果,新 key 追加到最前面 —— 最新的试算结果离操作区最近。 + setResults(prev => [result, ...prev.filter(r => r.key !== result.key)]); + } catch (err) { + console.error('[history] repolish failed', err); + const msg = errorMessage(err); + // 后端把 LLMError::Timeout 原样透成字符串 "timeout",直接显示等于没说 —— + // 用户看到「重新润色失败:timeout」只会以为是这个功能坏了,而实际是当前 LLM + // provider 没在 30 秒内回包(免费模型池尤其常见)。换一句能照着做的提示。 + setError( + isTimeout(msg) + ? t('history.repolish.timeout') + : t('history.repolish.failed', { err: msg }), + ); + } finally { + setRunning(null); + } + }; + + return ( +
+
+ + {t('history.repolish.title')} + + {results.length > 0 && ( + setResults([])}> + {t('history.repolish.clear')} + + )} +
+
+ {t('history.repolish.hint')} +
+ +
0 ? 14 : 0 }}> + void run('retry')} + > + {running === 'retry' ? t('history.repolish.retrying') : t('history.repolish.retry')} + + + {packsError ? ( + + {t('history.repolish.packsLoadFailed', { err: packsError })} + + ) : packs && packs.length === 0 ? ( + {t('history.repolish.noPacks')} + ) : ( + <> + + void run('apply')} + > + {running === 'apply' ? t('history.repolish.applying') : t('history.repolish.apply')} + + + )} +
+ + {error && ( +
+ {error} +
+ )} + + {results.length > 0 && ( +
+ {results.map(result => ( + + ))} +
+ )} +
+ ); +} + +function RepolishResultCard({ title, text }: { title: string; text: string }) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + + const onCopy = async () => { + try { + if (!navigator.clipboard?.writeText) throw new Error('clipboard unavailable'); + await navigator.clipboard.writeText(text); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch (error) { + console.error('[history] failed to copy repolish result', error); + } + }; + + return ( + // minWidth: 0 —— grid 子项默认 min-width: auto,标题 Pill 不换行时会把卡片 + // 撑出结果网格(与详情页两栏文本卡片同一类问题)。 +
+
+ + {title} + + {text.trim() && ( + void onCopy()}> + {copied ? t('common.copied') : t('common.copy')} + + )} +
+

+ {text.trim() || t('history.repolish.empty')} +

+
+ ); +} + function isUserCancelled(message: string): boolean { const normalized = message.trim().toLowerCase(); return normalized === 'cancelled'