diff --git a/openless-all/app/src-tauri/src/commands/history.rs b/openless-all/app/src-tauri/src/commands/history.rs index 1f6a84ab..642d4cf2 100644 --- a/openless-all/app/src-tauri/src/commands/history.rs +++ b/openless-all/app/src-tauri/src/commands/history.rs @@ -92,10 +92,12 @@ pub async fn retranscribe_recording( } let pcm = wav[44..].to_vec(); - let text = coord.retranscribe_pcm(pcm).await?; + let retranscribe_started = std::time::Instant::now(); + let (text, asr_call_label) = coord.retranscribe_pcm(pcm).await?; if text.trim().is_empty() { return Err("重新转录仍未识别到语音".into()); } + let retranscribe_ms = retranscribe_started.elapsed().as_millis() as u64; // 找到原条目,保留其它字段,只更新转写结果 + 清错误码。 let mut entry = coord @@ -105,11 +107,7 @@ pub async fn retranscribe_recording( .into_iter() .find(|s| s.id == session_id) .ok_or_else(|| "history entry not found".to_string())?; - // 只更新转写结果并清除失败标记。insert_status 保持原值(重新转录不向光标落字, - // 没有可表达「已转写未落字」的状态,清掉 error_code 即足以标记不再是失败条目)。 - entry.raw_transcript = text.clone(); - entry.final_text = text; - entry.error_code = None; + apply_retranscription(&mut entry, text, &asr_call_label, retranscribe_ms); let updated = coord .history() @@ -120,3 +118,85 @@ pub async fn retranscribe_recording( } Ok(entry) } + + +/// 把一次重转录的结果落到既有历史条目上(纯函数,供单测覆盖契约): +/// - 只更新转写结果并清除失败标记。insert_status 保持原值——重新转录不向光标落字, +/// 没有可表达「已转写未落字」的状态,清掉 error_code 即足以标记不再是失败条目。 +/// - ASR 归因换成本次重转实际构建的 (provider, model) 快照 + 实测耗时。 +/// - 重转没有润色环节:清掉 llm_* / polish_ms,避免详情页把旧润色信息错挂在新转写上。 +fn apply_retranscription( + entry: &mut DictationSession, + text: String, + asr_call_label: &crate::coordinator::AsrCallLabel, + asr_ms: u64, +) { + entry.raw_transcript = text.clone(); + entry.final_text = text; + entry.error_code = None; + entry.asr_provider = Some(asr_call_label.provider.clone()); + entry.asr_model = asr_call_label.model.clone(); + entry.asr_ms = Some(asr_ms); + entry.llm_provider = None; + entry.llm_model = None; + entry.polish_ms = None; +} + +#[cfg(test)] +mod retranscribe_tests { + use super::apply_retranscription; + use crate::coordinator::AsrCallLabel; + use crate::types::{DictationSession, InsertStatus, PolishMode}; + + fn failed_entry() -> DictationSession { + DictationSession { + id: "s1".into(), + created_at: "2026-07-15T00:00:00Z".into(), + raw_transcript: String::new(), + final_text: String::new(), + mode: PolishMode::Light, + style_pack_id: None, + translation_active: false, + polish_source: None, + app_bundle_id: None, + app_name: None, + insert_status: InsertStatus::Failed, + error_code: Some("transcribeFailed".into()), + duration_ms: Some(3200), + dictionary_entry_count: None, + has_audio_recording: Some(true), + asr_provider: Some("volcengine".into()), + asr_model: Some("volc.seedasr.sauc.duration".into()), + llm_provider: Some("ark".into()), + llm_model: Some("deepseek-v3-2".into()), + asr_ms: Some(15000), + polish_ms: Some(1200), + } + } + + #[test] + fn retranscription_overwrites_asr_attribution_and_clears_polish_fields() { + let mut entry = failed_entry(); + let label = AsrCallLabel { + provider: "bailian-qwen3-realtime".into(), + model: Some("qwen3-asr-flash-realtime".into()), + }; + apply_retranscription(&mut entry, "重转出来的文本".into(), &label, 480); + + assert_eq!(entry.raw_transcript, "重转出来的文本"); + assert_eq!(entry.final_text, "重转出来的文本"); + assert_eq!(entry.error_code, None, "重转成功应清除失败标记"); + // ASR 归因换成本次重转的构建时快照。 + assert_eq!(entry.asr_provider.as_deref(), Some("bailian-qwen3-realtime")); + assert_eq!(entry.asr_model.as_deref(), Some("qwen3-asr-flash-realtime")); + assert_eq!(entry.asr_ms, Some(480)); + // 重转没有润色环节:旧 LLM 元数据不得残留在新转写结果上。 + assert_eq!(entry.llm_provider, None); + assert_eq!(entry.llm_model, None); + assert_eq!(entry.polish_ms, None); + // 其余字段保持原值。 + assert_eq!(entry.insert_status, InsertStatus::Failed); + assert_eq!(entry.duration_ms, Some(3200)); + assert_eq!(entry.has_audio_recording, Some(true)); + } +} diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 4af648c0..3ce763dd 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -428,6 +428,10 @@ struct Inner { prepared_windows_ime_session: Arc>>, state: Mutex, asr: Mutex>>, + /// 与 `asr` 同生命周期的构建时快照:本次会话实际构建的 (provider, model)。 + /// store_asr_for_session 一并写入,end_session 取走落 history——比事后重读 + /// 全局设置可靠:会话中途切 provider/model 不会污染归因(PR #826 review)。 + asr_label: Mutex>>, /// 本地 Qwen3-ASR 引擎缓存。跨会话复用,避免每次重加载 1.2GB+ 模型。 /// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。 local_asr_cache: Arc, @@ -637,6 +641,7 @@ impl Coordinator { inserter: TextInserter::new(), state: Mutex::new(SessionState::default()), asr: Mutex::new(None), + asr_label: Mutex::new(None), recorder: Mutex::new(None), audio_archive_active: AtomicBool::new(false), recording_mute: Mutex::new(SharedRecordingMuteState::new()), @@ -738,6 +743,7 @@ impl Coordinator { prepared_windows_ime_session: Arc::new(Mutex::new(Vec::new())), state: Mutex::new(SessionState::default()), asr: Mutex::new(None), + asr_label: Mutex::new(None), recorder: Mutex::new(None), audio_archive_active: AtomicBool::new(false), recording_mute: Mutex::new(SharedRecordingMuteState::new()), @@ -840,6 +846,7 @@ impl Coordinator { self.inner.local_asr_cache.loaded_model_id() } + /// 主动把当前本地 ASR 引擎状态推给前端(keepLoadedSecs 变更等命令侧调用)。 pub fn emit_local_asr_engine_status(&self) { emit_local_asr_engine_status(&self.inner); @@ -1788,30 +1795,50 @@ impl Coordinator { llm_thinking_enabled, front_app.as_deref(), &[], + // repolish 不回写历史的模型/耗时字段,调用快照就地丢弃。 + &mut None, + &mut None, ) .await .map_err(|e| e.to_string()) } - pub async fn retranscribe_pcm(&self, pcm: Vec) -> Result { - self.retranscribe_pcm_inner(pcm, false).await + /// 返回 (转写文本, 本次实际构建的 ASR (provider, model) 快照)。快照供命令层把 + /// 「重转用了哪个模型」写回历史(构建时归因,PR #826 review)。 + pub async fn retranscribe_pcm( + &self, + pcm: Vec, + ) -> Result<(String, AsrCallLabel), String> { + self.retranscribe_pcm_inner(pcm, false, None).await } pub(super) async fn retranscribe_pcm_until_cancelled( &self, pcm: Vec, - ) -> Result { - self.retranscribe_pcm_inner(pcm, true).await + ) -> (Result, Option) { + // 自动静默重试会重新读取当前设置并构建一条全新的 ASR 会话,因此必须把这次 + // 实际构建的标签交还给调用方。即使请求最终失败,也保留“本次尝试了谁”,让 + // 彻底失败的历史不会退回首次会话的旧归因。 + let mut attempted_label = None; + let result = self + .retranscribe_pcm_inner(pcm, true, Some(&mut attempted_label)) + .await + .map(|(text, _)| text); + (result, attempted_label) } async fn retranscribe_pcm_inner( &self, pcm: Vec, cancel_on_drop: bool, - ) -> Result { + attempted_label: Option<&mut Option>, + ) -> Result<(String, AsrCallLabel), String> { let inner = &self.inner; let active_asr = CredentialsVault::get_active_asr(); - let start = build_qa_asr_start(inner, &active_asr).await?; + let (start, asr_call_label) = build_qa_asr_start(inner, &active_asr).await?; + if let Some(label_slot) = attempted_label { + *label_slot = Some(asr_call_label.clone()); + } let retry_guard = if cancel_on_drop { Some(CancellableRetranscribeGuard::new( Arc::clone(inner), @@ -1910,7 +1937,7 @@ impl Coordinator { if let Some(guard) = retry_guard { guard.disarm(); } - Ok(raw.text) + Ok((raw.text, asr_call_label)) } pub fn preview_style_pack_runtime( @@ -2447,6 +2474,39 @@ fn read_gemini_credentials() -> anyhow::Result<(String, String, String)> { Ok((api_key, model, base_url)) } +/// 构建 ASR 客户端那一刻捕获的 (provider, model) 快照。随会话资源一起存放 +/// (store_asr_for_session),end_session 取走写 history。provider 是实际构建用的 +/// 具体协议 id(统一百炼入口会先经 resolve_effective_asr_provider 重定向);model +/// 是构建时实际传给客户端的值(含 alias 归一化与默认回退)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AsrCallLabel { + pub provider: String, + pub model: Option, +} + +impl AsrCallLabel { + pub(crate) fn new(provider: impl Into, model: Option) -> Self { + Self { + provider: provider.into(), + model: model.filter(|m| !m.trim().is_empty()), + } + } +} + +/// Volcengine resource id 进历史前的 allowlist:只放行 `volc.` 命名空间的产品标识 +/// (如 volc.seedasr.sauc.duration / volc.bigasr.sauc.duration),字符集限 ASCII +/// 字母数字与 `._-`。自定义/异常值可能携带租户信息,一律不落盘(PR #826 review / +/// issue #373 的可观测性诉求)。 +pub(crate) fn volc_resource_history_label(resource_id: &str) -> Option { + let id = resource_id.trim(); + let allowed = id.starts_with("volc.") + && id.len() <= 64 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')); + allowed.then(|| id.to_string()) +} + fn build_active_llm_provider(llm_thinking_enabled: bool) -> anyhow::Result { let active = CredentialsVault::get_active_llm(); let model = @@ -2491,6 +2551,31 @@ fn resolve_ark_endpoint_with_policy( #[cfg(test)] mod tests { + #[test] + fn volc_resource_history_label_allows_volc_namespace_ids() { + // issue #373 场景的两个真实 resource id 必须放行。 + assert_eq!( + super::volc_resource_history_label("volc.seedasr.sauc.duration").as_deref(), + Some("volc.seedasr.sauc.duration") + ); + assert_eq!( + super::volc_resource_history_label(" volc.bigasr.sauc.duration ").as_deref(), + Some("volc.bigasr.sauc.duration"), + "首尾空白应被 trim" + ); + } + + #[test] + fn volc_resource_history_label_rejects_non_allowlisted_values() { + // 非 volc. 命名空间 / 含异常字符 / 超长的值可能携带租户信息,一律不落历史。 + assert_eq!(super::volc_resource_history_label(""), None); + assert_eq!(super::volc_resource_history_label("my-secret-tenant"), None); + assert_eq!(super::volc_resource_history_label("volc.a b"), None, "空格不在字符集"); + assert_eq!(super::volc_resource_history_label("volc.引擎"), None, "非 ASCII 拒绝"); + let too_long = format!("volc.{}", "x".repeat(64)); + assert_eq!(super::volc_resource_history_label(&too_long), None); + } + use super::dictation::abort_recording_with_error; use super::dictation::{handle_pressed_edge, handle_released_edge}; use super::*; diff --git a/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs b/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs index 3da15bea..b11387db 100644 --- a/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs +++ b/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs @@ -277,9 +277,11 @@ pub(super) fn schedule_sherpa_onnx_release(inner: &Arc, session: AsrRelea } #[cfg(target_os = "macos")] +/// 返回 (provider, 实际加载的模型 id)。模型 id 是 ModelId 校验归一后的值,调用方 +/// 直接用它做历史归因(构建时快照,PR #826 review)。 pub(super) async fn build_local_qwen3( inner: &Arc, -) -> anyhow::Result> { +) -> anyhow::Result<(Arc, String)> { let prefs = inner.prefs.get(); let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) .ok_or_else(|| anyhow::anyhow!("未知本地模型 id: {}", prefs.local_asr_active_model))?; @@ -298,7 +300,11 @@ pub(super) async fn build_local_qwen3( .map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??; // 加载完成(含缓存命中刷新 last_used)后推一次状态,前端零轮询更新「已加载」。 emit_local_asr_engine_status(inner); - Ok(Arc::new(crate::asr::local::LocalQwenAsr::new(app, engine))) + let model_label = model_id.as_str().to_string(); + Ok(( + Arc::new(crate::asr::local::LocalQwenAsr::new(app, engine)), + model_label, + )) } #[cfg(target_os = "macos")] @@ -461,7 +467,12 @@ impl QaAsrStart { } } -pub(super) async fn build_qa_asr_start(inner: &Arc, active_asr: &str) -> Result { +/// 返回 (启动器, 构建时 (provider, model) 快照)。快照供 QA / 重转录把「实际用了哪个 +/// 模型」写回历史(PR #826 review:归因必须来自构建现场,不能事后重读设置)。 +pub(super) async fn build_qa_asr_start( + inner: &Arc, + active_asr: &str, +) -> Result<(QaAsrStart, AsrCallLabel), String> { #[cfg(target_os = "windows")] if foundry::is_foundry_local_whisper(active_asr) { let prefs = inner.prefs.get(); @@ -478,13 +489,14 @@ pub(super) async fn build_qa_asr_start(inner: &Arc, active_asr: &str) -> }; let local = Arc::new(FoundryLocalWhisperAsr::new( Arc::clone(&inner.foundry_local_runtime), - model_alias, + model_alias.clone(), prefs.foundry_local_runtime_source.clone(), language_hint, )); let active = ActiveAsr::FoundryLocalWhisper(Arc::clone(&local)); let consumer: Arc = local; - return Ok(QaAsrStart::Ready { active, consumer }); + let label = AsrCallLabel::new(foundry::PROVIDER_ID, Some(model_alias)); + return Ok((QaAsrStart::Ready { active, consumer }, label)); } #[cfg(target_os = "windows")] @@ -510,7 +522,7 @@ pub(super) async fn build_qa_asr_start(inner: &Arc, active_asr: &str) -> }); let local = SherpaOnnxAsr::new_for_model( Arc::clone(&inner.sherpa_onnx_runtime), - model_alias, + model_alias.clone(), language_hint, token_handler, ) @@ -519,17 +531,19 @@ pub(super) async fn build_qa_asr_start(inner: &Arc, active_asr: &str) -> let local = Arc::new(local); let active = ActiveAsr::SherpaOnnxLocal(Arc::clone(&local)); let consumer: Arc = local; - return Ok(QaAsrStart::Ready { active, consumer }); + let label = AsrCallLabel::new(sherpa::PROVIDER_ID, Some(model_alias)); + return Ok((QaAsrStart::Ready { active, consumer }, label)); } #[cfg(target_os = "macos")] if crate::asr::local::is_local_qwen3(active_asr) { - let local = build_local_qwen3(inner) + let (local, model) = build_local_qwen3(inner) .await .map_err(|e| format!("local ASR init failed: {e}"))?; let active = ActiveAsr::Local(Arc::clone(&local)); let consumer: Arc = local; - return Ok(QaAsrStart::Ready { active, consumer }); + let label = AsrCallLabel::new(crate::asr::local::PROVIDER_ID, Some(model)); + return Ok((QaAsrStart::Ready { active, consumer }, label)); } #[cfg(target_os = "macos")] @@ -537,7 +551,8 @@ pub(super) async fn build_qa_asr_start(inner: &Arc, active_asr: &str) -> let local = build_apple_speech(&inner.prefs.get()); let active = ActiveAsr::AppleSpeech(Arc::clone(&local)); let consumer: Arc = local; - return Ok(QaAsrStart::Ready { active, consumer }); + let label = AsrCallLabel::new(crate::asr::local::APPLE_SPEECH_PROVIDER_ID, None); + return Ok((QaAsrStart::Ready { active, consumer }, label)); } // 统一百炼:按所选模型把 build 分发重定向到具体协议(凭据仍读真实 active @@ -548,37 +563,55 @@ pub(super) async fn build_qa_asr_start(inner: &Arc, active_asr: &str) -> .unwrap_or_default(); let effective_asr = resolve_effective_asr_provider(active_asr, &asr_model)?; match active_asr_provider_kind(&effective_asr) { - ActiveAsrProviderKind::Bailian => Ok(QaAsrStart::Bailian { - asr: Arc::new(BailianRealtimeASR::new(read_bailian_credentials())), - bridge: Arc::new(DeferredAsrBridge::new()), - }), - ActiveAsrProviderKind::Qwen3Realtime => Ok(QaAsrStart::Qwen3Realtime { - asr: Arc::new(Qwen3RealtimeASR::new(read_qwen3_realtime_credentials())), - bridge: Arc::new(DeferredAsrBridge::new()), - }), + ActiveAsrProviderKind::Bailian => { + let creds = read_bailian_credentials(); + let label = AsrCallLabel::new(effective_asr.clone(), Some(creds.model.clone())); + Ok(( + QaAsrStart::Bailian { + asr: Arc::new(BailianRealtimeASR::new(creds)), + bridge: Arc::new(DeferredAsrBridge::new()), + }, + label, + )) + } + ActiveAsrProviderKind::Qwen3Realtime => { + let creds = read_qwen3_realtime_credentials(); + let label = AsrCallLabel::new(effective_asr.clone(), Some(creds.model.clone())); + Ok(( + QaAsrStart::Qwen3Realtime { + asr: Arc::new(Qwen3RealtimeASR::new(creds)), + bridge: Arc::new(DeferredAsrBridge::new()), + }, + label, + )) + } ActiveAsrProviderKind::Mimo => { let (api_key, base_url, model) = read_mimo_credentials(); + let label = AsrCallLabel::new(effective_asr.clone(), Some(model.clone())); let mimo = Arc::new(MimoBatchASR::new(api_key, base_url, model)); let active = ActiveAsr::Mimo(Arc::clone(&mimo)); let consumer: Arc = mimo; - Ok(QaAsrStart::Ready { active, consumer }) + Ok((QaAsrStart::Ready { active, consumer }, label)) } ActiveAsrProviderKind::DashScopeMultimodal => { let (api_key, base_url, model) = read_dashscope_multimodal_credentials(); + let label = AsrCallLabel::new(effective_asr.clone(), Some(model.clone())); let asr = Arc::new(DashScopeMultimodalASR::new(api_key, base_url, model)); let active = ActiveAsr::DashScopeMultimodal(Arc::clone(&asr)); let consumer: Arc = asr; - Ok(QaAsrStart::Ready { active, consumer }) + Ok((QaAsrStart::Ready { active, consumer }, label)) } ActiveAsrProviderKind::ElevenLabs => { let (api_key, base_url, model) = read_elevenlabs_credentials(); + let label = AsrCallLabel::new(effective_asr.clone(), Some(model.clone())); let asr = Arc::new(ElevenLabsBatchASR::new(api_key, base_url, model)); let active = ActiveAsr::ElevenLabs(Arc::clone(&asr)); let consumer: Arc = asr; - Ok(QaAsrStart::Ready { active, consumer }) + Ok((QaAsrStart::Ready { active, consumer }, label)) } ActiveAsrProviderKind::WhisperCompatible => { let (api_key, base_url, model) = read_whisper_credentials(); + let label = AsrCallLabel::new(effective_asr.clone(), Some(model.clone())); let whisper_prompt = crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner)); let whisper = Arc::new( @@ -594,14 +627,21 @@ pub(super) async fn build_qa_asr_start(inner: &Arc, active_asr: &str) -> ); let active = ActiveAsr::Whisper(Arc::clone(&whisper)); let consumer: Arc = whisper; - Ok(QaAsrStart::Ready { active, consumer }) + Ok((QaAsrStart::Ready { active, consumer }, label)) + } + ActiveAsrProviderKind::Volcengine => { + let creds = read_volc_credentials(); + let label = AsrCallLabel::new( + effective_asr.clone(), + volc_resource_history_label(&creds.resource_id), + ); + Ok(( + QaAsrStart::Volcengine { + asr: Arc::new(VolcengineStreamingASR::new(creds, enabled_hotwords(inner))), + bridge: Arc::new(DeferredAsrBridge::new()), + }, + label, + )) } - ActiveAsrProviderKind::Volcengine => Ok(QaAsrStart::Volcengine { - asr: Arc::new(VolcengineStreamingASR::new( - read_volc_credentials(), - enabled_hotwords(inner), - )), - bridge: Arc::new(DeferredAsrBridge::new()), - }), } } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index ac0483b6..94cf53e6 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -248,6 +248,8 @@ async fn run_streaming_polish( llm_thinking_enabled: bool, front_app: Option<&str>, prior_turns: &[(String, String)], + llm_call: &mut Option, + llm_elapsed_ms: &mut Option, ) -> (String, Option, bool) { log::info!( "[coord] streaming_insert path ENTER (raw_chars={})", @@ -268,6 +270,8 @@ async fn run_streaming_polish( llm_thinking_enabled, front_app, prior_turns, + llm_call, + llm_elapsed_ms, ) .await; return (p, e, false); @@ -298,6 +302,8 @@ async fn run_streaming_polish( llm_thinking_enabled, front_app, prior_turns, + llm_call, + llm_elapsed_ms, ) .await; return (p, err, false); @@ -353,6 +359,8 @@ async fn run_streaming_polish( llm_thinking_enabled, front_app, prior_turns, + llm_call, + llm_elapsed_ms, move |delta: &str| { let converted = match delta_converter.as_ref() { Some(converter) => converter.convert(delta), @@ -452,6 +460,8 @@ async fn run_streaming_polish( llm_thinking_enabled, front_app, prior_turns, + llm_call, + llm_elapsed_ms, ) .await; (p, e, false) @@ -1515,7 +1525,7 @@ pub(super) async fn begin_session_as( }; let local = Arc::new(FoundryLocalWhisperAsr::new( Arc::clone(&inner.foundry_local_runtime), - model_alias, + model_alias.clone(), prefs.foundry_local_runtime_source.clone(), language_hint, )); @@ -1523,6 +1533,7 @@ pub(super) async fn begin_session_as( inner, current_session_id, ActiveAsr::FoundryLocalWhisper(Arc::clone(&local)), + AsrCallLabel::new(foundry::PROVIDER_ID, Some(model_alias)), ); let consumer: Arc = local; start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) @@ -1557,7 +1568,7 @@ pub(super) async fn begin_session_as( }); let local = match SherpaOnnxAsr::new_for_model( Arc::clone(&inner.sherpa_onnx_runtime), - model_alias, + model_alias.clone(), language_hint, token_handler, ) @@ -1584,6 +1595,7 @@ pub(super) async fn begin_session_as( inner, current_session_id, ActiveAsr::SherpaOnnxLocal(Arc::clone(&local)), + AsrCallLabel::new(sherpa::PROVIDER_ID, Some(model_alias)), ); let consumer: Arc = local; start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) @@ -1595,7 +1607,7 @@ pub(super) async fn begin_session_as( if let Some(provider) = macos_keyless_dictation_provider(&active_asr) { match provider { MacosKeylessDictationProvider::LocalQwen3 => { - let local = match build_local_qwen3(inner).await { + let (local, local_model) = match build_local_qwen3(inner).await { Ok(l) => l, Err(e) => { log::error!("[coord] 本地 Qwen3-ASR 初始化失败: {e:#}"); @@ -1617,6 +1629,7 @@ pub(super) async fn begin_session_as( inner, current_session_id, ActiveAsr::Local(Arc::clone(&local)), + AsrCallLabel::new(crate::asr::local::PROVIDER_ID, Some(local_model)), ); let consumer: Arc = local; start_recorder_and_enter_listening( @@ -1633,6 +1646,8 @@ pub(super) async fn begin_session_as( inner, current_session_id, ActiveAsr::AppleSpeech(Arc::clone(&local)), + // 系统语音识别没有用户可见的模型 id。 + AsrCallLabel::new(crate::asr::local::APPLE_SPEECH_PROVIDER_ID, None), ); let consumer: Arc = local; start_recorder_and_enter_listening( @@ -1665,13 +1680,16 @@ pub(super) async fn begin_session_as( } if is_bailian_provider(&effective_asr) { - let asr = Arc::new(BailianRealtimeASR::new(read_bailian_credentials())); + let creds = read_bailian_credentials(); + let asr_call_label = AsrCallLabel::new(effective_asr.clone(), Some(creds.model.clone())); + let asr = Arc::new(BailianRealtimeASR::new(creds)); let bridge = Arc::new(DeferredAsrBridge::new()); let consumer: Arc = bridge.clone(); store_asr_for_session( inner, current_session_id, ActiveAsr::Bailian(Arc::clone(&asr)), + asr_call_label, ); start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; @@ -1738,13 +1756,16 @@ pub(super) async fn begin_session_as( finish_starting_session(inner, current_session_id).await; } else if is_qwen3_realtime_provider(&effective_asr) { // 与 Bailian 分支同构:流式 WS 会话 + DeferredAsrBridge 缓冲开链前音频。 - let asr = Arc::new(Qwen3RealtimeASR::new(read_qwen3_realtime_credentials())); + let creds = read_qwen3_realtime_credentials(); + let asr_call_label = AsrCallLabel::new(effective_asr.clone(), Some(creds.model.clone())); + let asr = Arc::new(Qwen3RealtimeASR::new(creds)); let bridge = Arc::new(DeferredAsrBridge::new()); let consumer: Arc = bridge.clone(); store_asr_for_session( inner, current_session_id, ActiveAsr::Qwen3Realtime(Arc::clone(&asr)), + asr_call_label, ); start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; @@ -1815,33 +1836,39 @@ pub(super) async fn begin_session_as( finish_starting_session(inner, current_session_id).await; } else if is_mimo_provider(&effective_asr) { let (api_key, base_url, model) = read_mimo_credentials(); + let asr_call_label = AsrCallLabel::new(effective_asr.clone(), Some(model.clone())); let mimo = Arc::new(MimoBatchASR::new(api_key, base_url, model)); store_asr_for_session( inner, current_session_id, ActiveAsr::Mimo(Arc::clone(&mimo)), + asr_call_label, ); let consumer: Arc = mimo; start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) .await?; } else if is_dashscope_multimodal_provider(&effective_asr) { let (api_key, base_url, model) = read_dashscope_multimodal_credentials(); + let asr_call_label = AsrCallLabel::new(effective_asr.clone(), Some(model.clone())); let asr = Arc::new(DashScopeMultimodalASR::new(api_key, base_url, model)); store_asr_for_session( inner, current_session_id, ActiveAsr::DashScopeMultimodal(Arc::clone(&asr)), + asr_call_label, ); let consumer: Arc = asr; start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) .await?; } else if is_elevenlabs_provider(&effective_asr) { let (api_key, base_url, model) = read_elevenlabs_credentials(); + let asr_call_label = AsrCallLabel::new(effective_asr.clone(), Some(model.clone())); let asr = Arc::new(ElevenLabsBatchASR::new(api_key, base_url, model)); store_asr_for_session( inner, current_session_id, ActiveAsr::ElevenLabs(Arc::clone(&asr)), + asr_call_label, ); let consumer: Arc = asr; start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) @@ -1857,6 +1884,7 @@ pub(super) async fn begin_session_as( // 互換プロバイダにも揃えるのが筋。 let whisper_prompt = crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner)); + let asr_call_label = AsrCallLabel::new(effective_asr.clone(), Some(model.clone())); let whisper = Arc::new( WhisperBatchASR::new( api_key, @@ -1872,6 +1900,7 @@ pub(super) async fn begin_session_as( inner, current_session_id, ActiveAsr::Whisper(Arc::clone(&whisper)), + asr_call_label, ); let consumer: Arc = whisper; start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) @@ -1879,6 +1908,12 @@ pub(super) async fn begin_session_as( } else { let hotwords = enabled_hotwords(inner); let creds = read_volc_credentials(); + // Volcengine 没有模型 id,但 resource id(volc.seedasr.* / volc.bigasr.*)承担同样的 + // 「用的哪个引擎」角色;经 allowlist 脱敏后当 model 落历史(issue #373 排障场景)。 + let asr_call_label = AsrCallLabel::new( + effective_asr.clone(), + volc_resource_history_label(&creds.resource_id), + ); let asr = Arc::new(VolcengineStreamingASR::new(creds, hotwords)); let bridge = Arc::new(DeferredAsrBridge::new()); let consumer: Arc = bridge.clone(); @@ -1886,6 +1921,7 @@ pub(super) async fn begin_session_as( inner, current_session_id, ActiveAsr::Volcengine(Arc::clone(&asr)), + asr_call_label, ); start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; @@ -2185,6 +2221,7 @@ pub(super) async fn finish_starting_session(inner: &Arc, session_id: Sess fn build_transcribe_failed_session( session_id: SessionId, duration_ms: u64, + asr_ms: u64, mode: PolishMode, has_audio_recording: bool, ) -> DictationSession { @@ -2204,17 +2241,36 @@ fn build_transcribe_failed_session( duration_ms: Some(duration_ms), dictionary_entry_count: None, has_audio_recording: Some(has_audio_recording), + asr_provider: None, + asr_model: None, + llm_provider: None, + llm_model: None, + asr_ms: Some(asr_ms), + polish_ms: None, } } -fn write_transcribe_failed_history(inner: &Arc, session_id: SessionId, duration_ms: u64) { +fn write_transcribe_failed_history( + inner: &Arc, + session_id: SessionId, + duration_ms: u64, + asr_ms: u64, + asr_call_label: Option<&AsrCallLabel>, +) { let prefs = inner.prefs.get(); - let session = build_transcribe_failed_session( + let mut session = build_transcribe_failed_session( session_id, duration_ms, + asr_ms, prefs.default_mode, inner.audio_archive_active.load(Ordering::Relaxed), ); + // 失败条目也记下是哪个 ASR 出的错——「哪个模型转不出来」正是模型对比要看的信息。 + // 用 begin_session 的构建时快照,而不是此刻重读设置(PR #826 review)。 + if let Some(label) = asr_call_label { + session.asr_provider = Some(label.provider.clone()); + session.asr_model = label.model.clone(); + } if let Err(e) = inner.history.append_with_retention( session, prefs.history_retention_days, @@ -2232,11 +2288,20 @@ fn fail_dictation( inner: &Arc, session_id: SessionId, elapsed: u64, + asr_ms: u64, user_msg: String, err: String, + asr_call_label: Option<&AsrCallLabel>, ) -> Result<(), String> { - write_transcribe_failed_history(inner, session_id, elapsed); - emit_capsule(inner, CapsuleState::Error, 0.0, elapsed, Some(user_msg), None); + write_transcribe_failed_history(inner, session_id, elapsed, asr_ms, asr_call_label); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(user_msg), + None, + ); restore_prepared_windows_ime_session(inner, session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); @@ -2264,11 +2329,23 @@ const SILENT_RETRY_MAX: u32 = 2; const SILENT_RETRY_BACKOFF_MS: u64 = 500; enum SilentRetryOutcome { - Transcript(RawTranscript), - Exhausted, + Transcript { + raw: RawTranscript, + asr_call_label: AsrCallLabel, + }, + Exhausted(Option), Cancelled, } +fn accept_silent_retry_transcript( + raw: RawTranscript, + retry_label: AsrCallLabel, + asr_call_label: &mut Option, +) -> RawTranscript { + *asr_call_label = Some(retry_label); + raw +} + /// 归档 wav 是 16k/mono/16-bit、固定 44 字节标准头(asr::wav::encode_wav_16k_mono);取出 /// PCM 负载。长度 <= 44(空/损坏)返回 None。 fn pcm_from_wav_bytes(wav: &[u8]) -> Option> { @@ -2287,7 +2364,10 @@ fn pcm_duration_ms(pcm_len: usize) -> u64 { /// 用「当前」provider 把一段 PCM 重新转录(建一条全新 ASR 会话——原会话失败/断开后不可 /// 复用)。复用 Coordinator::retranscribe_pcm(历史「重新转录」同款逻辑);Coordinator 只持有 /// `inner`,这里用 inner 重建一个轻量句柄,零副作用。 -async fn retranscribe_pcm_via_inner(inner: &Arc, pcm: Vec) -> Result { +async fn retranscribe_pcm_via_inner( + inner: &Arc, + pcm: Vec, +) -> (Result, Option) { Coordinator { inner: Arc::clone(inner), } @@ -2305,24 +2385,25 @@ async fn try_silent_retranscribe(inner: &Arc, session_id: SessionId) -> S return SilentRetryOutcome::Cancelled; } if !inner.audio_archive_active.load(Ordering::Relaxed) { - return SilentRetryOutcome::Exhausted; // 没归档音频,无从重试 + return SilentRetryOutcome::Exhausted(None); // 没归档音频,无从重试 } let Some(path) = crate::persistence::recording_path_for_session(&session_id.to_string()).ok() else { - return SilentRetryOutcome::Exhausted; + return SilentRetryOutcome::Exhausted(None); }; let wav = tokio::select! { biased; _ = wait_for_processing_cancel(inner) => return SilentRetryOutcome::Cancelled, result = tokio::fs::read(&path) => match result { Ok(wav) => wav, - Err(_) => return SilentRetryOutcome::Exhausted, + Err(_) => return SilentRetryOutcome::Exhausted(None), }, }; let Some(pcm) = pcm_from_wav_bytes(&wav) else { - return SilentRetryOutcome::Exhausted; + return SilentRetryOutcome::Exhausted(None); }; let duration_ms = pcm_duration_ms(pcm.len()); + let mut last_attempted_label = None; for attempt in 1..=SILENT_RETRY_MAX { tokio::select! { biased; @@ -2331,30 +2412,37 @@ async fn try_silent_retranscribe(inner: &Arc, session_id: SessionId) -> S SILENT_RETRY_BACKOFF_MS * attempt as u64, )) => {} } - let result = tokio::select! { + let (result, attempted_label) = tokio::select! { biased; _ = wait_for_processing_cancel(inner) => return SilentRetryOutcome::Cancelled, result = retranscribe_pcm_via_inner(inner, pcm.clone()) => result, }; + if attempted_label.is_some() { + last_attempted_label = attempted_label.clone(); + } match result { Ok(text) if !text.trim().is_empty() => { log::info!( "[coord] 自动静默重试第 {attempt}/{SILENT_RETRY_MAX} 次成功({} 字)", text.chars().count() ); - return SilentRetryOutcome::Transcript(RawTranscript { text, duration_ms }); + return SilentRetryOutcome::Transcript { + raw: RawTranscript { text, duration_ms }, + asr_call_label: attempted_label + .expect("successful retranscription must have a build-time ASR label"), + }; } Ok(_) => { // 重试得到空转写——多半真没说话,再重试无意义,省流量直接放弃。 log::info!("[coord] 自动静默重试得到空转写,停止重试"); - return SilentRetryOutcome::Exhausted; + return SilentRetryOutcome::Exhausted(last_attempted_label); } Err(e) => { log::warn!("[coord] 自动静默重试第 {attempt}/{SILENT_RETRY_MAX} 次失败: {e}"); } } } - SilentRetryOutcome::Exhausted + SilentRetryOutcome::Exhausted(last_attempted_label) } fn finish_cancelled_processing(inner: &Arc, session_id: SessionId) -> bool { @@ -2434,6 +2522,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } let asr_opt = take_asr_for_session(inner, current_session_id); + // 构建时快照(begin_session 存入)。会话中途改设置不影响这份归因。 + let mut asr_call_label = take_asr_label_for_session(inner, current_session_id); let asr = match asr_opt { Some(a) => a, None => { @@ -2450,6 +2540,9 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // `asr` move 进去,命中取消时那个 future 会被 drop(连同它持有的 Arc),我们再用这份 // clone 显式 cancel,促使流式 WebSocket 立刻关闭、不残留后台 worker。 let asr_for_cancel = asr.clone(); + // 「等待转写结果」实测起点:流式 ASR 量的是收尾延迟,批式量完整转写。写进 + // history.asr_ms 供历史详情页展示(含下方的自动静默重试时间——那也是用户等的时间)。 + let transcribe_started = std::time::Instant::now(); // 每个引擎分支产出 Ok(RawTranscript) 或 Err(TranscribeFail);失败/超时不再就地 return, // 而是把失败值交给 match 之后统一处理:先自动静默重试(从归档音频重转,应对网络/服务端 // 瞬时抖动),重试拿回文本就当正常转写继续;彻底失败才 fail_dictation 保留录音 + 报错。 @@ -2843,14 +2936,20 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { let raw = match transcribe_outcome { Ok(raw) => raw, Err(fail) => match try_silent_retranscribe(inner, current_session_id).await { - SilentRetryOutcome::Transcript(raw) => raw, + SilentRetryOutcome::Transcript { + raw, + asr_call_label: retry_label, + } => accept_silent_retry_transcript(raw, retry_label, &mut asr_call_label), SilentRetryOutcome::Cancelled => { log::info!("[coord] cancel during silent ASR retry — discarding transcript"); restore_prepared_windows_ime_session(inner, current_session_id); finish_cancelled_processing(inner, current_session_id); return Ok(()); } - SilentRetryOutcome::Exhausted => { + SilentRetryOutcome::Exhausted(retry_label) => { + if retry_label.is_some() { + asr_call_label = retry_label; + } // 处理最后一次重试结果时也复查一次取消标志,覆盖「重试刚返回 // Exhausted 与用户同时按 Esc」的窄竞态,避免误走失败提示。 if inner.state.lock().cancelled { @@ -2861,10 +2960,23 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { finish_cancelled_processing(inner, current_session_id); return Ok(()); } - return fail_dictation(inner, current_session_id, elapsed, fail.user_msg, fail.err) + return fail_dictation( + inner, + current_session_id, + elapsed, + transcribe_started.elapsed().as_millis() as u64, + fail.user_msg, + fail.err, + asr_call_label.as_ref(), + ); } }, }; + let asr_ms = transcribe_started.elapsed().as_millis() as u64; + let (asr_provider, asr_model) = match &asr_call_label { + Some(label) => (Some(label.provider.clone()), label.model.clone()), + None => (None, None), + }; // ASR 返回空转写护栏(来自 PR #66):写一条 emptyTranscript 失败历史 + 错误胶囊, // 与 main 上其它 error 路径保持一致(带 schedule_capsule_idle 让胶囊自动消失)。 @@ -2904,6 +3016,13 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // 通过原始录音定位"是不是麦克风太小声 / ASR 模型问题"的场景。修 pr_agent // "Missing Audio" 反馈。 has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)), + // 空转写也记下是哪个 ASR 模型给出的空结果 + 等了多久,供模型对比排查。 + asr_provider: asr_provider.clone(), + asr_model: asr_model.clone(), + llm_provider: None, + llm_model: None, + asr_ms: Some(asr_ms), + polish_ms: None, }; let prefs_snapshot = inner.prefs.get(); if let Err(e) = inner.history.append_with_retention( @@ -3054,6 +3173,13 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // 翻译会话润色后的源语言文本(译文前的中间产物),仅翻译路径解析成功时有值, // 写进 history 供后续普通润色轮复用(剔除译文、避免外语污染)。 let mut polish_source: Option = None; + // 一次 LLM 调用的构建时快照:polish 链路在成功构建 provider、即将发起真实调用时 + // 填充(见 polish_flow.rs)。Raw 直通、凭据缺失等 preflight 失败都保持 None—— + // 此时不落 llm_* / polish_ms,避免"没调用却记了模型/耗时"的伪数据(PR #826 review)。 + let mut llm_call: Option = None; + // 只累计 provider 请求本身的耗时。流式路径的输入法切换、逐字上屏和队列排空 + // 属于插入阶段,不能混入用于模型对比的 polish_ms。 + let mut llm_elapsed_ms: Option = None; let (polished, polish_error, already_streamed) = if translation_active { log::info!( "[coord] translation mode → target=\u{300C}{}\u{300D} working={:?} front_app={:?}", @@ -3072,6 +3198,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { llm_thinking_enabled, front_app.as_deref(), &prior_turns, + &mut llm_call, + &mut llm_elapsed_ms, ) .await; polish_source = src; @@ -3089,6 +3217,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { llm_thinking_enabled, front_app.as_deref(), &prior_turns, + &mut llm_call, + &mut llm_elapsed_ms, ) .await } else { @@ -3103,10 +3233,18 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { llm_thinking_enabled, front_app.as_deref(), &prior_turns, + &mut llm_call, + &mut llm_elapsed_ms, ) .await; (p, e, false) }; + // 耗时与标签都以「真的发起了 provider 调用」为准;preflight 失败和 Raw 直通均为 None。 + let polish_ms = llm_elapsed_ms; + let (llm_provider, llm_model) = match &llm_call { + Some(label) => (Some(label.provider.clone()), Some(label.model.clone())), + None => (None, None), + }; let polished = finalize_polished_text( polished, @@ -3293,6 +3431,12 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // 用 begin_session 时 Recorder::start 返回的实际写盘状态,而不是 prefs 开关—— // 开关打开但路径创建失败时这里是 false,避免前端渲染播放按钮后端 404。 has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)), + asr_provider, + asr_model, + llm_provider, + llm_model, + asr_ms: Some(asr_ms), + polish_ms, }; if let Err(e) = inner.history.append_with_retention( session, @@ -3462,10 +3606,10 @@ fn eligible_polish_context_turns( #[cfg(test)] mod tests { use super::{ - append_typed_prefix, batch_asr_chunk_limit_ms, build_transcribe_failed_session, - default_done_message, drain_streaming_insert_deltas_with, eligible_polish_context_turns, - finalize_polished_text, flush_streaming_insert_buffer_with, pcm_duration_ms, - pcm_from_wav_bytes, streaming_insert_eligible, + accept_silent_retry_transcript, append_typed_prefix, batch_asr_chunk_limit_ms, + build_transcribe_failed_session, default_done_message, drain_streaming_insert_deltas_with, + eligible_polish_context_turns, finalize_polished_text, flush_streaming_insert_buffer_with, + pcm_duration_ms, pcm_from_wav_bytes, streaming_insert_eligible, }; #[cfg(target_os = "macos")] use super::{macos_keyless_dictation_provider, MacosKeylessDictationProvider}; @@ -3474,6 +3618,27 @@ mod tests { }; use uuid::Uuid; + #[test] + fn silent_retry_replaces_initial_asr_attribution() { + let mut label = Some(super::AsrCallLabel::new( + "volcengine", + Some("volc.seedasr.sauc.duration".into()), + )); + let retry_label = super::AsrCallLabel::new( + "bailian-qwen3-realtime", + Some("qwen3-asr-flash-realtime".into()), + ); + let raw = super::RawTranscript { + text: "重试成功".into(), + duration_ms: 900, + }; + + let accepted = accept_silent_retry_transcript(raw, retry_label.clone(), &mut label); + + assert_eq!(accepted.text, "重试成功"); + assert_eq!(label, Some(retry_label)); + } + fn correction_rule(pattern: &str, replacement: &str) -> CorrectionRule { CorrectionRule { id: "test".into(), @@ -3509,6 +3674,12 @@ mod tests { style_pack_id: style_pack_id.map(str::to_string), translation_active, polish_source: polish_source.map(str::to_string), + asr_provider: None, + asr_model: None, + llm_provider: None, + llm_model: None, + asr_ms: None, + polish_ms: None, } } @@ -3541,17 +3712,20 @@ mod tests { // 凭 id 找回。之前 empty 分支用 Uuid::new_v4(),与 wav 文件名对不上 → 前端永远 404、 // 录音随 prune 丢失(用户报告「识别失败之前的语音也都丢失了」)。 let sid = Uuid::new_v4(); - let session = build_transcribe_failed_session(sid, 4200, PolishMode::Structured, true); + let session = + build_transcribe_failed_session(sid, 4200, 17_250, PolishMode::Structured, true); assert_eq!(session.id, sid.to_string()); } #[test] fn transcribe_failed_history_marks_failed_and_recoverable() { let sid = Uuid::new_v4(); - let session = build_transcribe_failed_session(sid, 1234, PolishMode::Structured, true); + let session = + build_transcribe_failed_session(sid, 1234, 17_250, PolishMode::Structured, true); assert!(matches!(session.insert_status, InsertStatus::Failed)); assert_eq!(session.error_code.as_deref(), Some("transcribeFailed")); assert_eq!(session.duration_ms, Some(1234)); + assert_eq!(session.asr_ms, Some(17_250)); // 归档成功 → 标 has_audio_recording=true,前端据此渲染「重新转录」入口。 assert_eq!(session.has_audio_recording, Some(true)); } @@ -3561,7 +3735,7 @@ mod tests { // 录音归档失败(has_audio=false)→ 条目仍写(用户看得到这次失败),但不标可重转, // 避免前端渲染重转按钮而后端找不到 wav。 let sid = Uuid::new_v4(); - let session = build_transcribe_failed_session(sid, 1, PolishMode::Structured, false); + let session = build_transcribe_failed_session(sid, 1, 250, PolishMode::Structured, false); assert_eq!(session.has_audio_recording, Some(false)); } diff --git a/openless-all/app/src-tauri/src/coordinator/polish_flow.rs b/openless-all/app/src-tauri/src/coordinator/polish_flow.rs index 0c8a7cd0..5bb8b765 100644 --- a/openless-all/app/src-tauri/src/coordinator/polish_flow.rs +++ b/openless-all/app/src-tauri/src/coordinator/polish_flow.rs @@ -25,6 +25,14 @@ pub enum StreamingPolishOutcome { Failed(String), } +fn accumulate_llm_elapsed(total_ms: &mut Option, elapsed_ms: u64) { + *total_ms = Some(total_ms.unwrap_or(0).saturating_add(elapsed_ms)); +} + +fn record_llm_elapsed(total_ms: &mut Option, started: std::time::Instant) { + accumulate_llm_elapsed(total_ms, started.elapsed().as_millis() as u64); +} + /// 流式润色入口。在不支持流式的所有 case 都返回 `UnsupportedFallback`,让调用方 /// 透明降级。不修改任何持久化 / 焦点 / 光标状态。 /// @@ -42,6 +50,8 @@ pub async fn polish_or_passthrough_streaming( llm_thinking_enabled: bool, front_app: Option<&str>, prior_turns: &[(String, String)], + llm_call: &mut Option, + llm_elapsed_ms: &mut Option, on_delta: F, should_cancel: C, ) -> StreamingPolishOutcome @@ -73,13 +83,16 @@ where ); return StreamingPolishOutcome::UnsupportedFallback; } + // 过了所有 early-out、即将发起真实调用——此刻才记录调用快照。 + *llm_call = Some(provider.call_label()); log::info!( "[coord] streaming polish START: provider=openai-compatible mode={:?} raw_chars={} prior_turns={}", mode, raw.text.chars().count(), prior_turns.len() ); - match provider + let call_started = std::time::Instant::now(); + let result = provider .polish_streaming( &raw.text, mode, @@ -93,8 +106,9 @@ where on_delta, should_cancel, ) - .await - { + .await; + record_llm_elapsed(llm_elapsed_ms, call_started); + match result { Ok(text) => { log::info!( "[coord] streaming polish OK: final_chars={}", @@ -121,6 +135,8 @@ pub(super) async fn polish_or_passthrough( llm_thinking_enabled: bool, front_app: Option<&str>, prior_turns: &[(String, String)], + llm_call: &mut Option, + llm_elapsed_ms: &mut Option, ) -> (String, Option) { if mode == PolishMode::Raw && !raw_mode_uses_llm(style_system_prompt) { return (raw.text.clone(), None); @@ -136,6 +152,8 @@ pub(super) async fn polish_or_passthrough( llm_thinking_enabled, front_app, prior_turns, + llm_call, + llm_elapsed_ms, ) .await { @@ -159,6 +177,8 @@ pub(super) async fn polish_text( llm_thinking_enabled: bool, front_app: Option<&str>, prior_turns: &[(String, String)], + llm_call: &mut Option, + llm_elapsed_ms: &mut Option, ) -> anyhow::Result { // 谷歌 Gemini 分支:所有 LLM provider 共用 ark.* 凭据槽,唯独 Gemini 走原生 // generateContent / 自带 thinkingConfig 控制;其余 provider 走 OpenAI @@ -166,10 +186,16 @@ pub(super) async fn polish_text( let active_llm = CredentialsVault::get_active_llm(); if active_llm == "gemini" { let (api_key, model, base_url) = read_gemini_credentials()?; + // 凭据读取成功、即将发起调用——记录构建时快照(preflight 失败走上面的 ? 提前返回,不会记)。 + *llm_call = Some(crate::polish::LlmCallLabel { + provider: active_llm.clone(), + model: model.clone(), + }); let provider = GeminiProvider::new( GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled), ); - return Ok(provider + let call_started = std::time::Instant::now(); + let result = provider .polish( raw, mode, @@ -181,11 +207,15 @@ pub(super) async fn polish_text( front_app, prior_turns, ) - .await?); + .await; + record_llm_elapsed(llm_elapsed_ms, call_started); + return Ok(result?); } let provider = build_active_llm_provider(llm_thinking_enabled)?; - Ok(provider + *llm_call = Some(provider.call_label()); + let call_started = std::time::Instant::now(); + let result = provider .polish( raw, mode, @@ -197,7 +227,9 @@ pub(super) async fn polish_text( front_app, prior_turns, ) - .await?) + .await; + record_llm_elapsed(llm_elapsed_ms, call_started); + Ok(result?) } /// 专用翻译(仅翻译、不润色、单轮)。现作为"润色+翻译"合成调用解析失败时的兜底—— @@ -210,15 +242,22 @@ pub(super) async fn translate_text( output_language_preference: OutputLanguagePreference, llm_thinking_enabled: bool, front_app: Option<&str>, + llm_call: &mut Option, + llm_elapsed_ms: &mut Option, ) -> anyhow::Result { // 见 polish_text 顶部注释——同样的 Gemini / OpenAI-compatible 路由逻辑。 let active_llm = CredentialsVault::get_active_llm(); if active_llm == "gemini" { let (api_key, model, base_url) = read_gemini_credentials()?; + *llm_call = Some(crate::polish::LlmCallLabel { + provider: active_llm.clone(), + model: model.clone(), + }); let provider = GeminiProvider::new( GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled), ); - return Ok(provider + let call_started = std::time::Instant::now(); + let result = provider .translate_to( raw, target_language, @@ -227,11 +266,15 @@ pub(super) async fn translate_text( output_language_preference, front_app, ) - .await?); + .await; + record_llm_elapsed(llm_elapsed_ms, call_started); + return Ok(result?); } let provider = build_active_llm_provider(llm_thinking_enabled)?; - Ok(provider + *llm_call = Some(provider.call_label()); + let call_started = std::time::Instant::now(); + let result = provider .translate_to( raw, target_language, @@ -240,7 +283,9 @@ pub(super) async fn translate_text( output_language_preference, front_app, ) - .await?) + .await; + record_llm_elapsed(llm_elapsed_ms, call_started); + Ok(result?) } /// "润色+翻译"单次调用的两段哨兵。模型按 `SRC\n源文\nTGT\n译文` 输出,解析器据此切分。 @@ -309,6 +354,8 @@ pub(super) async fn polish_and_translate_or_passthrough( llm_thinking_enabled: bool, front_app: Option<&str>, prior_turns: &[(String, String)], + llm_call: &mut Option, + llm_elapsed_ms: &mut Option, ) -> (String, Option, Option) { let system_prompt = build_polish_translate_system_prompt(target_language); match polish_text( @@ -322,6 +369,8 @@ pub(super) async fn polish_and_translate_or_passthrough( llm_thinking_enabled, front_app, prior_turns, + llm_call, + llm_elapsed_ms, ) .await { @@ -341,6 +390,8 @@ pub(super) async fn polish_and_translate_or_passthrough( output_language_preference, llm_thinking_enabled, front_app, + llm_call, + llm_elapsed_ms, ) .await { @@ -360,3 +411,50 @@ pub(super) async fn polish_and_translate_or_passthrough( } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// PR #826 review:llm_call 快照只在真的构建 provider / 发起调用时填充。 + /// Raw 直通在读取任何凭据之前就 early-return,llm_call 必须保持 None—— + /// 调用方据此不落 llm_* / polish_ms。 + #[tokio::test] + async fn raw_passthrough_leaves_llm_call_snapshot_empty() { + let raw = RawTranscript { + text: "原样输出".to_string(), + duration_ms: 800, + }; + let mut llm_call: Option = None; + let mut llm_elapsed_ms = None; + // 直通判定:style prompt 等于内置 raw 提示词 → raw_mode_uses_llm 为 false。 + let builtin_raw_prompt = crate::types::StyleSystemPrompts::default().raw; + let (out, err) = polish_or_passthrough( + &raw, + PolishMode::Raw, + &[], + &builtin_raw_prompt, + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + false, + None, + &[], + &mut llm_call, + &mut llm_elapsed_ms, + ) + .await; + assert_eq!(out, "原样输出"); + assert_eq!(err, None); + assert_eq!(llm_call, None, "Raw 直通不得产生 LLM 调用快照"); + assert_eq!(llm_elapsed_ms, None, "Raw 直通不得产生 LLM 调用耗时"); + } + + #[test] + fn llm_elapsed_accumulates_only_provider_call_durations() { + let mut elapsed_ms = None; + accumulate_llm_elapsed(&mut elapsed_ms, 120); + accumulate_llm_elapsed(&mut elapsed_ms, 80); + assert_eq!(elapsed_ms, Some(200)); + } +} 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 77eb2052..6fb4440d 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa_session.rs @@ -544,6 +544,12 @@ pub(super) async fn answer_qa_question_text( duration_ms: Some(duration_ms), dictionary_entry_count: None, has_audio_recording: None, + asr_provider: None, + asr_model: None, + llm_provider: None, + llm_model: None, + asr_ms: None, + polish_ms: None, }; let prefs_snapshot = inner.prefs.get(); if let Err(error) = inner.history.append_with_retention( @@ -649,8 +655,9 @@ pub(super) async fn begin_qa_session(inner: &Arc) -> Result<(), String> { return Err(message); } + // QA 历史暂不落模型归因字段,构建时快照就地丢弃(dictation / 重转录路径在用)。 let qa_asr = match build_qa_asr_start(inner, &active_asr).await { - Ok(qa_asr) => qa_asr, + Ok((qa_asr, _asr_call_label)) => qa_asr, Err(message) => { log::error!("[coord] QA active ASR init failed: {message}"); finish_qa_with_error(inner, format!("ASR 初始化失败: {message}")); @@ -1236,6 +1243,12 @@ pub(super) async fn end_qa_session(inner: &Arc) -> Result<(), String> { duration_ms: Some(raw.duration_ms), dictionary_entry_count: None, has_audio_recording: None, + asr_provider: None, + asr_model: None, + llm_provider: None, + llm_model: None, + asr_ms: None, + polish_ms: None, }; let prefs_snapshot = inner.prefs.get(); if let Err(e) = inner.history.append_with_retention( @@ -1459,6 +1472,7 @@ mod tests { &coordinator.inner, session_id, ActiveAsr::ElevenLabs(asr), + crate::coordinator::AsrCallLabel::new("elevenlabs", Some("scribe_v2".into())), ); let transcribe = tokio::spawn({ diff --git a/openless-all/app/src-tauri/src/coordinator/resources.rs b/openless-all/app/src-tauri/src/coordinator/resources.rs index c766ffd9..7b44f6c6 100644 --- a/openless-all/app/src-tauri/src/coordinator/resources.rs +++ b/openless-all/app/src-tauri/src/coordinator/resources.rs @@ -5,7 +5,7 @@ use crate::recorder::Recorder; use crate::types::CapsuleState; use tauri::Manager; -use super::{emit_capsule, ActiveAsr, Inner}; +use super::{emit_capsule, ActiveAsr, AsrCallLabel, Inner}; pub(super) struct SessionResource { pub(super) session_id: SessionId, @@ -54,8 +54,17 @@ pub(super) fn take_session_resource( } } -pub(super) fn store_asr_for_session(inner: &Arc, session_id: SessionId, asr: ActiveAsr) { +/// 存放本次会话的 ASR 句柄 + 构建时 (provider, model) 快照。label 必须来自构建 +/// 现场(凭据/alias 归一化后的实际值),不能事后重读全局设置——签名强制每个 +/// 构建分支都交出快照,漏一个就编译不过(PR #826 review)。 +pub(super) fn store_asr_for_session( + inner: &Arc, + session_id: SessionId, + asr: ActiveAsr, + label: AsrCallLabel, +) { *inner.asr.lock() = Some(SessionResource::new(session_id, asr)); + *inner.asr_label.lock() = Some(SessionResource::new(session_id, label)); } pub(super) fn take_asr_for_session(inner: &Arc, session_id: SessionId) -> Option { @@ -63,6 +72,15 @@ pub(super) fn take_asr_for_session(inner: &Arc, session_id: SessionId) -> take_session_resource(&mut slot, session_id) } +/// 取走会话的 ASR 构建时快照(与 take_asr_for_session 相同的 session_id 守卫)。 +pub(super) fn take_asr_label_for_session( + inner: &Arc, + session_id: SessionId, +) -> Option { + let mut slot = inner.asr_label.lock(); + take_session_resource(&mut slot, session_id) +} + pub(super) fn cancel_active_asr(asr: ActiveAsr) { match asr { ActiveAsr::Volcengine(v) => v.cancel(), diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 3f552061..608d62ae 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -105,7 +105,32 @@ pub enum ActiveLLMProvider { Codex(CodexOAuthLLMProvider), } +/// 一次 LLM 调用的构建时快照(provider id + 归一化后的模型 id)。polish 链路在 +/// **成功构建 provider、即将发起真实调用**时填充;凭据缺失等 preflight 失败不填, +/// 调用方据此决定要不要把 llm_* / polish_ms 落进历史——避免"没调用却记了模型"的 +/// 伪数据(PR #826 review)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LlmCallLabel { + pub provider: String, + pub model: String, +} + impl ActiveLLMProvider { + /// 构建时快照:从已构建的 config 读 provider/model(Codex 的 model 已经过 + /// normalize_codex_model 归一化),而不是事后重读全局设置。 + pub fn call_label(&self) -> LlmCallLabel { + match self { + Self::OpenAI(p) => LlmCallLabel { + provider: p.config.provider_id.clone(), + model: p.config.model.clone(), + }, + Self::Codex(p) => LlmCallLabel { + provider: CODEX_OAUTH_PROVIDER_ID.to_string(), + model: p.config.model.clone(), + }, + } + } + /// v1 流式润色只在 OpenAI-compatible 走通;Codex 走 Responses API,shape 与 /// chat completions SSE 不同,留给 v2。Gemini 在 coordinator.rs 路径上自己分流, /// 不进 ActiveLLMProvider 枚举。 diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index aa203e3c..2a82c1f7 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -167,6 +167,26 @@ pub struct DictationSession { /// `None` / `Some(false)` 都按"无录音"处理;旧 JSON 不带这字段也兼容。 #[serde(default)] pub has_audio_recording: Option, + /// 本次转写用的 ASR provider id(如 "volcengine" / "local-qwen3")。历史详情页 + /// 展示用,方便做模型能力对比。旧历史无此字段时 None,前端隐藏对应行。 + #[serde(default)] + pub asr_provider: Option, + /// 本次转写用的 ASR 模型 id。provider 无模型概念(volcengine / apple-speech)时 None。 + #[serde(default)] + pub asr_model: Option, + /// 本次润色用的 LLM provider id。Raw 直通(未调用 LLM)时 None。 + #[serde(default)] + pub llm_provider: Option, + /// 本次润色用的 LLM 模型 id。Raw 直通时 None。 + #[serde(default)] + pub llm_model: Option, + /// 松键后「等待转写结果」的实测耗时(毫秒)。流式 ASR 大部分识别在录音期间已完成, + /// 这里量的是用户感知的收尾延迟;批式 ASR 则是完整转写耗时。 + #[serde(default)] + pub asr_ms: Option, + /// LLM 润色/翻译调用的实测耗时(毫秒)。未调用 LLM 时 None。 + #[serde(default)] + pub polish_ms: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -3347,4 +3367,64 @@ mod tests { assert!(binding.effective_codes().is_empty()); } + + /// PR #826:新增的模型/耗时字段必须向后兼容——旧 history.json 完全没有这些 key。 + #[test] + fn dictation_session_deserializes_legacy_json_without_model_fields() { + let legacy = r#"{ + "id": "abc", + "createdAt": "2026-07-01T00:00:00Z", + "rawTranscript": "你好", + "finalText": "你好。", + "mode": "light", + "appBundleId": null, + "appName": null, + "insertStatus": "inserted", + "errorCode": null, + "durationMs": 1200, + "dictionaryEntryCount": null + }"#; + let session: DictationSession = serde_json::from_str(legacy).expect("legacy json"); + assert_eq!(session.asr_provider, None); + assert_eq!(session.asr_model, None); + assert_eq!(session.llm_provider, None); + assert_eq!(session.llm_model, None); + assert_eq!(session.asr_ms, None); + assert_eq!(session.polish_ms, None); + } + + /// 新字段序列化必须是 camelCase(前端 types.ts 镜像按 camelCase 读)。 + #[test] + fn dictation_session_serializes_model_fields_as_camel_case() { + let session = DictationSession { + id: "abc".into(), + created_at: "2026-07-01T00:00:00Z".into(), + raw_transcript: "你好".into(), + final_text: "你好。".into(), + mode: PolishMode::Light, + style_pack_id: None, + translation_active: false, + polish_source: None, + app_bundle_id: None, + app_name: None, + insert_status: InsertStatus::Inserted, + error_code: None, + duration_ms: Some(1200), + dictionary_entry_count: None, + has_audio_recording: None, + asr_provider: Some("bailian".into()), + asr_model: Some("fun-asr-realtime".into()), + llm_provider: Some("ark".into()), + llm_model: Some("deepseek-v3-2".into()), + asr_ms: Some(230), + polish_ms: Some(1450), + }; + let json = serde_json::to_value(&session).expect("serialize"); + assert_eq!(json["asrProvider"], "bailian"); + assert_eq!(json["asrModel"], "fun-asr-realtime"); + assert_eq!(json["llmProvider"], "ark"); + assert_eq!(json["llmModel"], "deepseek-v3-2"); + assert_eq!(json["asrMs"], 230); + assert_eq!(json["polishMs"], 1450); + } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 9ecb30bd..1ddbe34d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -27,6 +27,7 @@ export const en: typeof zhCN = { operationFailed: 'Operation failed', add: 'Add', durationSeconds: '{{value}}s', + durationMillis: '{{value}}ms', durationMinutes: '{{value}}m', }, capsule: { @@ -353,7 +354,11 @@ export const en: typeof zhCN = { rawLabel: 'Raw', rawEmpty: '(empty)', selectHint: 'Select an entry on the left to see details.', - insertedTo: 'Inserted into', + recorded: 'Recorded {{duration}}', + stepAsr: 'Transcribe', + stepAsrHint: 'Time spent waiting for the transcript after key release. Streaming ASR transcribes while you speak, so this is usually much shorter than the recording.', + stepPolish: 'Polish', + stepInsert: 'Insert', chars: '{{count}} chars', vocabHits: '{{count}} vocab hits', inserted: 'Inserted', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index ba15e526..f356ad12 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -29,6 +29,7 @@ export const ja: typeof zhCN = { operationFailed: '操作に失敗しました', add: '追加', durationSeconds: '{{value}} 秒', + durationMillis: '{{value}}ミリ秒', durationMinutes: '{{value}} 分', }, capsule: { @@ -355,7 +356,11 @@ export const ja: typeof zhCN = { rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側から 1 件選択して詳細を表示。', - insertedTo: '入力先', + recorded: '録音 {{duration}}', + stepAsr: '認識', + stepAsrHint: 'キーを離してから認識結果を待った時間。ストリーミング認識は録音中に変換するため、通常は録音時間よりずっと短くなります。', + stepPolish: '推敲', + stepInsert: '挿入', chars: '{{count}} 文字', vocabHits: '{{count}} ホットワード', inserted: '入力済み', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 59856482..fc5606e3 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -29,6 +29,7 @@ export const ko: typeof zhCN = { operationFailed: '작업 실패', add: '추가', durationSeconds: '{{value}}초', + durationMillis: '{{value}}ms', durationMinutes: '{{value}}분', }, capsule: { @@ -355,7 +356,11 @@ export const ko: typeof zhCN = { rawLabel: '원문', rawEmpty: '(비어 있음)', selectHint: '왼쪽에서 하나를 선택하여 자세히 보기.', - insertedTo: '입력 위치', + recorded: '녹음 {{duration}}', + stepAsr: '인식', + stepAsrHint: '키를 뗀 후 인식 결과를 기다린 시간. 스트리밍 인식은 녹음 중에 변환하므로 보통 녹음 시간보다 훨씬 짧습니다.', + stepPolish: '다듬기', + stepInsert: '삽입', chars: '{{count}}자', vocabHits: '핫워드 {{count}}개', inserted: '입력됨', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 0bcba46b..008a227b 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -25,6 +25,7 @@ export const zhCN = { operationFailed: '操作失败', add: '添加', durationSeconds: '{{value}} 秒', + durationMillis: '{{value}} 毫秒', durationMinutes: '{{value}} 分钟', }, capsule: { @@ -351,7 +352,11 @@ export const zhCN = { rawLabel: '原文', rawEmpty: '(空)', selectHint: '左侧选一条查看详情。', - insertedTo: '插入到', + recorded: '录音 {{duration}}', + stepAsr: '识别', + stepAsrHint: '松键后等待识别结果的耗时。流式识别边录边转,此值通常远小于录音时长。', + stepPolish: '润色', + stepInsert: '插入', chars: '{{count}} 字', vocabHits: '{{count}} 个热词', inserted: '已插入', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 02527a29..c7852481 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -27,6 +27,7 @@ export const zhTW: typeof zhCN = { operationFailed: '操作失敗', add: '添加', durationSeconds: '{{value}} 秒', + durationMillis: '{{value}} 毫秒', durationMinutes: '{{value}} 分鐘', }, capsule: { @@ -353,7 +354,11 @@ export const zhTW: typeof zhCN = { rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側選一條查看詳情。', - insertedTo: '插入到', + recorded: '錄音 {{duration}}', + stepAsr: '辨識', + stepAsrHint: '放開按鍵後等待辨識結果的耗時。串流辨識邊錄邊轉,此值通常遠小於錄音時長。', + stepPolish: '潤飾', + stepInsert: '插入', chars: '{{count}} 字', vocabHits: '{{count}} 個熱詞', inserted: '已插入', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 1dc753f1..0a1adeab 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -456,6 +456,24 @@ export const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ durationMs: 600, dictionaryEntryCount: 28, hasAudioRecording: null, + // 轮换三种画像,覆盖 UI 验收要看的形态:亚秒流式收尾(毫秒精度)、volc resource id、 + // 超长 provider/model 文本换行;i%4==3 模拟 Raw 直通(无 LLM 行)与旧条目缺耗时。 + asrProvider: ["bailian-qwen3-realtime", "volcengine", "openrouter", "apple-speech"][i % 4], + asrModel: [ + "qwen3-asr-flash-realtime", + "volc.seedasr.sauc.duration", + "openai/whisper-large-v3-turbo-preview-2026-01-31", + null, + ][i % 4], + llmProvider: ["ark", "codex_oauth", "openrouter", null][i % 4], + llmModel: [ + "deepseek-v3-2", + "gpt-5.5-codex-spark", + "anthropic/claude-sonnet-5-20260203-preview-long-context", + null, + ][i % 4], + asrMs: [120, 64, 9840, null][i % 4], + polishMs: [1240, 890, 12400, null][i % 4], })) export const mockVocab: DictionaryEntry[] = OL_DATA.vocab.map((v, i) => ({ diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 14aeaf00..2271ad5c 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -50,6 +50,18 @@ export interface DictationSession { /** 该会话是否在录音时归档了原始 wav(取决于当时 prefs.recordAudioForDebug)。 * true 时前端在 History 渲染播放按钮,凭 id 通过 read_audio_recording IPC 拿字节流。 */ hasAudioRecording: boolean | null; + /** 本次转写用的 ASR provider id(如 "volcengine" / "local-qwen3")。旧历史为 null。 */ + asrProvider: string | null; + /** 本次转写用的 ASR 模型 id。provider 无模型概念时为 null。 */ + asrModel: string | null; + /** 本次润色用的 LLM provider id。Raw 直通(未调用 LLM)时为 null。 */ + llmProvider: string | null; + /** 本次润色用的 LLM 模型 id。Raw 直通时为 null。 */ + llmModel: string | null; + /** 松键后等待转写结果的实测耗时(毫秒)。流式 ASR 是收尾延迟,批式是完整转写耗时。 */ + asrMs: number | null; + /** LLM 润色/翻译调用的实测耗时(毫秒)。未调用 LLM 时为 null。 */ + polishMs: number | null; } export interface DictionaryEntry { diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index e115328d..f033510d 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; 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 } from '../lib/ipc'; @@ -378,10 +379,11 @@ export function History() {
{formatTime(item.createdAt)} {MODE_LABEL[item.mode]} - {formatDuration(item.durationMs, t)} + {/* 「录音」前缀:与下方识别/润色耗时区分——录音时长发生在松键前, + 不该与流水线各步耗时加总(用户反馈"时间对不上")。 */} + {t('history.recorded', { duration: formatDuration(item.durationMs, t) })}
- void onCopy()}>{justCopied ? t('common.copied') : t('common.copy')} {item.hasAudioRecording && !audioMissingIds.has(item.id) && ( void onExportAudio()}>{t('history.exportRecording')} )} @@ -417,19 +419,58 @@ export function History() {

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

{item.finalText}

-
- {item.appName && {t('history.insertedTo')} {item.appName}} - {t('history.chars', { count: item.finalText.length })} - {item.dictionaryEntryCount != null && item.dictionaryEntryCount > 0 && ( - {t('history.vocabHits', { count: item.dictionaryEntryCount })} + {/* 流水线明细:识别 / 润色 / 插入 三步各占一行 —— 左列步骤名、中列 + provider·model(或插入目标),右列该步耗时/状态。旧历史没有模型与 + 耗时字段时对应行自动隐藏,只剩插入行 = 改版前的信息量。 */} +
+ {(item.asrProvider || item.asrMs != null) && ( + <> + + + + {t('history.stepAsr')} + + + + + {[item.asrProvider, item.asrModel].filter(Boolean).join(' · ')} + + + {item.asrMs != null ? formatStepDuration(item.asrMs, t) : ''} + + )} - { + {(item.llmProvider || item.llmModel || item.polishMs != null) && ( + <> + {t('history.stepPolish')} + + {[item.llmProvider, item.llmModel].filter(Boolean).join(' · ')} + + + {item.polishMs != null ? formatStepDuration(item.polishMs, t) : ''} + + + )} + {t('history.stepInsert')} + + {item.appName && <>{item.appName}{' · '}} + {t('history.chars', { count: item.finalText.length })} + {item.dictionaryEntryCount != null && item.dictionaryEntryCount > 0 && ( + <>{' · '}{t('history.vocabHits', { count: item.dictionaryEntryCount })} + )} + + { item.insertStatus === 'inserted' ? t('history.inserted') : item.insertStatus === 'pasteSent' @@ -541,6 +582,13 @@ function formatTime(iso: string): string { return `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } +/** 流水线单步耗时:<1s 显示整数毫秒(流式收尾常在几十 ms,0.1s 精度会把不同结果 + * 拍成同一个值,模型对比就失真了——PR #826 review);≥1s 沿用 0.1s 精度。 */ +function formatStepDuration(ms: number, t: ReturnType['t']): string { + if (ms < 1000) return t('common.durationMillis', { value: Math.round(ms) }); + return formatDuration(ms, t); +} + function formatDuration(ms: number | null, t: ReturnType['t']): string { if (ms == null || ms <= 0) return '—'; const sec = ms / 1000;