From cd33c8908971ad39f7a41c9c02a5c23828c6b1c8 Mon Sep 17 00:00:00 2001 From: bigsong <35025755+bigsongeth@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:14:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(history):=20=E8=AE=B0=E5=BD=95=E6=8F=92?= =?UTF-8?q?=E5=85=A5=E7=9B=AE=E6=A0=87=E5=BA=94=E7=94=A8=EF=BC=9B=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E6=94=AF=E6=8C=81=E9=87=8D=E8=AF=95=E4=B8=8E?= =?UTF-8?q?=E6=8D=A2=E9=A3=8E=E6=A0=BC=E9=87=8D=E6=96=B0=E6=B6=A6=E8=89=B2?= =?UTF-8?q?=EF=BC=9B=E6=9D=A1=E7=9B=AE=E6=98=BE=E7=A4=BA=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E9=A3=8E=E6=A0=BC=E5=8C=85=E5=90=8D=20(#910)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(history): 记录听写落到了哪个应用 历史详情页的「插入」行早就会渲染 app_name(History.tsx),但听写写历史时这个 字段写死 None —— 实测本机 200 条历史全部 appName=null,那一行永远只有字数, 看不出这段话落到了哪个 app。 前台应用其实 begin_session 就采过(capture_frontmost_app,日志里能看到 `front_app captured: Claude (com.anthropic.claudefordesktop)`),只是一直 只喂给 polish prompt,没写进历史。补上写入即可,前端不用改。 顺带把成功、空转写、转录失败三条路径都补上:排查「在某个 app 里总是识别不到」 时,这一列本身就是线索。 macOS 采到的是 `名称 (bundle id)` 的显示串,新增 `split_front_app_label` 拆成 两个字段落库,详情页只显示人读得懂的应用名,bundle id 进它自己的字段。划词润色 与语音问答两条路径此前把整串塞在 app_name 里,一并改走同一个拆分,避免同一个 列表里两种写法。 拆分对 Windows 的窗口标题要保守:只有括号内容形如反向域名(含点、无空格)才认 作 bundle id,「未命名文档 (未保存)」「Inbox (12)」这类标题保持原样不截断。 Co-Authored-By: Claude Opus 5 * refactor(history): 详情页把「识别 / 润色 / 插入」明细移到正文卡片之上 这三行是这条记录的元信息(走了哪个模型、各步多久、落到哪个 app),读的时候 应该先看到它、再读正文;放在两张长文本卡片之后,长内容一多就要滚到底才看得见。 纯挪位:块内容不变,分隔线从上缘(marginTop/borderTop)改到下缘 (marginBottom/borderBottom)。这也给下一步在正文卡片下方增加「重新润色」 区域腾出了位置。 Co-Authored-By: Claude Opus 5 * feat(history): 历史详情页支持重试与换风格重新润色 两个入口,一条后端通道: - 「用原风格重试」:同样的原文用当前风格再跑一遍。用户说「AI 这次不太对」时, 真正想做的是这个对照实验 —— 判断上次结果是模型抖动还是稳定行为。 - 选风格 +「应用」:同一段话换个风格包看效果,结果作为新卡片出现在下方。 后端 `repolish` 命令本来就在(commands/dictation.rs),但写死用当前激活的风格包, 而且前端没有任何页面调它 —— 等于一条建好没接通的通道。这里给它加一个可选的 `style_pack_id`:省略 = 当前激活包(重试),给了 id = 用指定包试算一次。指定的包 不需要处于激活状态,也不会改变激活状态 —— 这只是一次性试算,不该有把用户当前 风格换掉的副作用。显式指定时按 id 精确取,不走 get_or_default_active 的兜底链: 静默回落到别的包会让结果无从解释。 结果只在本次查看时显示,不写回历史条目:历史的 finalText 是「当时真的插进去的 那段文字」,是一条事实记录,不该被事后试算覆盖。面板上的说明把这点直说了,结果 卡带复制按钮。切换到另一条记录时结果清空(key={item.id}),不会把上一条的结果 留在新条目下面。 范围说明:这里只重跑润色、不重跑识别。成功听写的录音在插入后就删了(隐私设计), 原文是唯一还留着的输入。真正的「重新转录」入口仍然只对留有录音的失败条目开放, 本次不动。 Co-Authored-By: Claude Opus 5 * fix(history): 流水线明细右列的耗时不再被从中间截断换行 「143 毫秒」会显示成「143 毫 / 秒」两行。原因是这一行用的是 `grid-template-columns: auto 1fr auto`:右侧 auto 列会缩到 min-content, 而中文可以在任意字符间断行,所以它的 min-content 只有一个字宽 —— 中间那个 贪心的 1fr 列就把它挤扁了。 三个右列(识别耗时 / 润色耗时 / 插入状态)都加 nowrap,min-content 变成整串 宽度。挤压转移到中间的 provider·model 列,那一列本来就带 overflowWrap:anywhere, 换行是它该做的事。 Co-Authored-By: Claude Opus 5 * fix(history): 重新润色超时时说人话,别把 "timeout" 原样甩给用户 后端的 LLMError::Timeout 过 IPC 边界后退化成裸字符串 "timeout",前端直接拼进 「重新润色失败:{err}」——用户看到的就是「重新润色失败:timeout」。这句话既没 说是谁超时了,也没说能怎么办,第一反应必然是「这个新功能坏了」。 实际原因通常是当前 LLM provider 30 秒内没回包。实测一次:某免费模型池 (openrouter free)13 次非流式请求里 6 次撞上 30s 客户端超时,成功的那 7 次 耗时从 2.3s 到 21.2s 不等。功能本身是通的,换个 provider 就好。 超时单独给一句能照着做的提示:说明是 provider 没响应、建议换更快的或稍后重试。 其余错误保持原样透传(那些通常是凭据/参数问题,原文更有诊断价值)。 Co-Authored-By: Claude Opus 5 * feat(history): 历史条目显示实际风格包名,与重新润色面板对齐 历史列表和详情页的 Pill 一直显示 `session.mode`,那是风格包的 baseMode —— 四个内置分类之一。所有自建包都会落进这四个桶,所以用「超级豆包」口述出来的 记录,Pill 上写的是「轻度润色」,看不出到底是哪个包产出的。 同页的「重新润色」面板用的却是包名(下拉选项、结果标题都是 pack.name), 一页两套命名:下拉里选「超级豆包」,上面的 Pill 却写「轻度润色」。 改成优先用 session.stylePackId 查真实包名(该字段后端早就在写),内置包仍走 i18n 的 MODE_LABEL —— 内置包名在后端是硬编码中文,直接显示会在英/日/韩界面 串语言。旧历史没有 stylePackId、或包已被删除时回落到原来的模式名。 Pill 的 tone 仍按 baseMode 走,颜色保留原有的粗分类信息。 顺带两处修复: - 风格包加载提到 History 顶层,与 RepolishPanel 共用。面板按 key={item.id} 重挂载,原先每切一条历史都要重发一次 list_style_packs。顶层存全部包(含已 禁用,历史可能出自后来被禁用的包),面板自己再 filter(enabled)。 - 长包名撑破布局:Pill 自带 nowrap + flexShrink: 0,会把同排的「复制」按钮挤 成文字竖排;grid 子项默认 min-width: auto,还会逼出横向滚动条。显示包名的 Pill 统一改成可收缩 + 省略号(全名挂 title),两个 grid 子项补 minWidth: 0。 Co-Authored-By: Claude Opus 5 * fix(history): 按审查意见修复重新润色与前台应用名记录 - 重试优先用原记录风格包(包已删除/旧历史回落当前激活包),结果标题显示真实包名 - split_front_app_label 增加平台开关:仅 macOS 拆分,Windows 窗口标题整串保留 - 重新润色面板排除 QA 记录 - 超时错误匹配收紧为整串形态,避免误判 * fix(history): 按 PR 910 审查意见修复重新润色面板与前台应用拆分 - 结果卡标题 Pill 截断 + minWidth:0,长包名不再撑破结果网格 - 下拉默认选中当前激活包(defaultPackId) - 面板下拉与结果标题的内置包名统一走 i18n mode 名(packDisplayName) - 重试回落显式落到当前激活包,结果标题与实际执行一致 - split_front_app 返回 FrontApp 结构体,6 个调用点同步更新 - repolish hint 补充回落说明(5 locale) --------- Co-authored-by: Claude Opus 5 Co-authored-by: Chris233 --- .../app/src-tauri/src/commands/dictation.rs | 10 +- openless-all/app/src-tauri/src/coordinator.rs | 31 +- .../src-tauri/src/coordinator/dictation.rs | 31 +- .../src-tauri/src/coordinator/qa_session.rs | 6 +- .../src/coordinator/selection_polish.rs | 13 +- openless-all/app/src-tauri/src/types.rs | 119 ++++++ openless-all/app/src/i18n/en.ts | 17 + openless-all/app/src/i18n/ja.ts | 17 + openless-all/app/src/i18n/ko.ts | 17 + openless-all/app/src/i18n/zh-CN.ts | 17 + openless-all/app/src/i18n/zh-TW.ts | 17 + .../app/src/lib/history-repolish.test.ts | 135 +++++++ openless-all/app/src/lib/history-repolish.ts | 53 +++ openless-all/app/src/lib/ipc/style-packs.ts | 15 +- openless-all/app/src/pages/History.tsx | 364 ++++++++++++++++-- 15 files changed, 800 insertions(+), 62 deletions(-) create mode 100644 openless-all/app/src/lib/history-repolish.test.ts create mode 100644 openless-all/app/src/lib/history-repolish.ts diff --git a/openless-all/app/src-tauri/src/commands/dictation.rs b/openless-all/app/src-tauri/src/commands/dictation.rs index db36b799..632b7dcd 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 785c320c..fd7a7481 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 a1bf889f..0ee30e53 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 2a7566ef..60f6cefb 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 699f4020..2303bcc9 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 37682ee4..b1a49a8e 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 d81cd64e..f06187d6 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 a11b8a59..ba817126 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 28e6a57e..f3020b21 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 ba5a78a9..e1237fa7 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 23bc9edc..de12e715 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 00000000..b50b9ce7 --- /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 00000000..1882f38b --- /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 e5fe1d04..1315caeb 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 28247916..71ce3d0d 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'