feat(history): 详情页展示 ASR/LLM 模型与分步耗时,可观测的模型表现对比 (#826)

* feat(history): 详情页展示 ASR/LLM 模型与分步耗时,复制按钮归位润色卡片

- DictationSession 新增 asr_provider/asr_model/llm_provider/llm_model/asr_ms/polish_ms
  六个可选字段(旧 JSON 兼容);听写收尾实测写入,Raw 直通不记 LLM 字段
- asr_ms 量松键后等待转写结果的时间(流式=收尾延迟,批式=完整转写);
  polish_ms 量 LLM 润色/翻译调用耗时
- 失败/空转写历史也记 ASR provider/model,方便模型对比排查;
  手动重转用当前 ASR 覆盖并清掉旧润色信息
- 详情页底部改为 识别/润色/插入 三行流水线明细;录音时长加「录音」前缀
  并给识别行加悬停说明,避免与分步耗时混淆
- 润色卡片复制按钮移入卡片头部,与原文卡片对称

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(history): 模型归因改为构建时快照 + 亚秒毫秒精度 + volc resource id(review 反馈)

按 #826 review 逐条修复:

1. ASR/LLM 归因改为构建时快照。store_asr_for_session 新增 AsrCallLabel 参数
   (编译期强制 11 个构建分支交出快照),记录实际构建的具体协议 id(含统一百炼
   重定向后的 effective id)与归一化后的模型/alias;LLM 侧 polish 链路新增
   llm_call 出参,在成功构建 provider、即将发起真实调用时才填充。会话中途改
   设置不再污染归因。
2. preflight 失败不再产生伪数据。凭据缺失等构建失败时 llm_call 保持 None,
   不落 llm_*/polish_ms;polish_ms 也只在真实调用发生时记录。
3. 步骤耗时 <1s 显示整数毫秒(durationMillis,5 语言包),流式收尾几十毫秒
   不再被 0.1s 精度拍平。
4. Volcengine 经 allowlist(volc. 命名空间、ASCII 安全字符集、≤64)脱敏后
   把 resource id 落入 asr_model,支持 issue #373 的引擎对比排障;异常值不落盘。
5. 识别行提示改用可聚焦 Tooltip 组件(hover/键盘 focus/触摸三通道 + 虚线下划线)。
6. 重转录命令改用 retranscribe_pcm 返回的构建时快照;抽出 apply_retranscription
   纯函数。新增测试:旧 JSON 缺字段反序列化、camelCase 序列化、volc allowlist、
   重转覆盖归因并清 LLM 字段、Raw 直通不产生调用快照。

UI 验收:深/浅色 × 桌面/移动 × 超长 provider/model 换行 × 键盘聚焦均通过
(mock 数据已扩成对应压力画像)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(history): preserve actual model timing attribution

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-07-16 11:56:00 +08:00
1 parent 033eb5e731
commit 842ae5fd64
17 files changed
+817 -100

No files matched your search

@@ -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));
}
}
+92 -7
View File
@@ -428,6 +428,10 @@ struct Inner {
prepared_windows_ime_session: Arc<Mutex<Vec<PreparedWindowsImeSessionSlot>>>,
state: Mutex<SessionState>,
asr: Mutex<Option<SessionResource<ActiveAsr>>>,
/// 与 `asr` 同生命周期的构建时快照:本次会话实际构建的 (provider, model)。
/// store_asr_for_session 一并写入,end_session 取走落 history——比事后重读
/// 全局设置可靠:会话中途切 provider/model 不会污染归因(PR #826 review)。
asr_label: Mutex<Option<SessionResource<AsrCallLabel>>>,
/// 本地 Qwen3-ASR 引擎缓存。跨会话复用,避免每次重加载 1.2GB+ 模型。
/// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。
local_asr_cache: Arc<crate::asr::local::LocalAsrCache>,
@@ -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<u8>) -> Result<String, String> {
self.retranscribe_pcm_inner(pcm, false).await
/// 返回 (转写文本, 本次实际构建的 ASR (provider, model) 快照)。快照供命令层把
/// 「重转用了哪个模型」写回历史(构建时归因,PR #826 review)。
pub async fn retranscribe_pcm(
&self,
pcm: Vec<u8>,
) -> Result<(String, AsrCallLabel), String> {
self.retranscribe_pcm_inner(pcm, false, None).await
}
pub(super) async fn retranscribe_pcm_until_cancelled(
&self,
pcm: Vec<u8>,
) -> Result<String, String> {
self.retranscribe_pcm_inner(pcm, true).await
) -> (Result<String, String>, Option<AsrCallLabel>) {
// 自动静默重试会重新读取当前设置并构建一条全新的 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<u8>,
cancel_on_drop: bool,
) -> Result<String, String> {
attempted_label: Option<&mut Option<AsrCallLabel>>,
) -> 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<String>,
}
impl AsrCallLabel {
pub(crate) fn new(provider: impl Into<String>, model: Option<String>) -> 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<String> {
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<ActiveLLMProvider> {
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::*;
@@ -277,9 +277,11 @@ pub(super) fn schedule_sherpa_onnx_release(inner: &Arc<Inner>, session: AsrRelea
}
#[cfg(target_os = "macos")]
/// 返回 (provider, 实际加载的模型 id)。模型 id 是 ModelId 校验归一后的值,调用方
/// 直接用它做历史归因(构建时快照,PR #826 review)。
pub(super) async fn build_local_qwen3(
inner: &Arc<Inner>,
) -> anyhow::Result<Arc<crate::asr::local::LocalQwenAsr>> {
) -> anyhow::Result<(Arc<crate::asr::local::LocalQwenAsr>, 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<Inner>, active_asr: &str) -> Result<QaAsrStart, String> {
/// 返回 (启动器, 构建时 (provider, model) 快照)。快照供 QA / 重转录把「实际用了哪个
/// 模型」写回历史(PR #826 review:归因必须来自构建现场,不能事后重读设置)。
pub(super) async fn build_qa_asr_start(
inner: &Arc<Inner>,
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<Inner>, 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<dyn crate::recorder::AudioConsumer> = 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<Inner>, 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<Inner>, active_asr: &str) ->
let local = Arc::new(local);
let active = ActiveAsr::SherpaOnnxLocal(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = 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<dyn crate::recorder::AudioConsumer> = 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<Inner>, active_asr: &str) ->
let local = build_apple_speech(&inner.prefs.get());
let active = ActiveAsr::AppleSpeech(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = 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<Inner>, 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<dyn crate::recorder::AudioConsumer> = 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<dyn crate::recorder::AudioConsumer> = 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<dyn crate::recorder::AudioConsumer> = 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<Inner>, active_asr: &str) ->
);
let active = ActiveAsr::Whisper(Arc::clone(&whisper));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = 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()),
}),
}
}
@@ -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<crate::polish::LlmCallLabel>,
llm_elapsed_ms: &mut Option<u64>,
) -> (String, Option<String>, 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<dyn crate::recorder::AudioConsumer> = 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<dyn crate::recorder::AudioConsumer> = 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<dyn crate::recorder::AudioConsumer> = 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<dyn crate::recorder::AudioConsumer> = 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<dyn crate::recorder::AudioConsumer> = 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<dyn crate::recorder::AudioConsumer> = 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<dyn crate::recorder::AudioConsumer> = 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<dyn crate::recorder::AudioConsumer> = 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<dyn crate::recorder::AudioConsumer> = 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<dyn crate::recorder::AudioConsumer> = 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<dyn crate::recorder::AudioConsumer> = 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<Inner>, 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<Inner>, session_id: SessionId, duration_ms: u64) {
fn write_transcribe_failed_history(
inner: &Arc<Inner>,
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<Inner>,
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<AsrCallLabel>),
Cancelled,
}
fn accept_silent_retry_transcript(
raw: RawTranscript,
retry_label: AsrCallLabel,
asr_call_label: &mut Option<AsrCallLabel>,
) -> 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<Vec<u8>> {
@@ -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<Inner>, pcm: Vec<u8>) -> Result<String, String> {
async fn retranscribe_pcm_via_inner(
inner: &Arc<Inner>,
pcm: Vec<u8>,
) -> (Result<String, String>, Option<AsrCallLabel>) {
Coordinator {
inner: Arc::clone(inner),
}
@@ -2305,24 +2385,25 @@ async fn try_silent_retranscribe(inner: &Arc<Inner>, 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<Inner>, 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<Inner>, session_id: SessionId) -> bool {
@@ -2434,6 +2522,8 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> Result<(), String> {
// 翻译会话润色后的源语言文本(译文前的中间产物),仅翻译路径解析成功时有值,
// 写进 history 供后续普通润色轮复用(剔除译文、避免外语污染)。
let mut polish_source: Option<String> = None;
// 一次 LLM 调用的构建时快照:polish 链路在成功构建 provider、即将发起真实调用时
// 填充(见 polish_flow.rs)。Raw 直通、凭据缺失等 preflight 失败都保持 None——
// 此时不落 llm_* / polish_ms,避免"没调用却记了模型/耗时"的伪数据(PR #826 review)。
let mut llm_call: Option<crate::polish::LlmCallLabel> = None;
// 只累计 provider 请求本身的耗时。流式路径的输入法切换、逐字上屏和队列排空
// 属于插入阶段,不能混入用于模型对比的 polish_ms。
let mut llm_elapsed_ms: Option<u64> = 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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<Inner>) -> 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));
}
@@ -25,6 +25,14 @@ pub enum StreamingPolishOutcome {
Failed(String),
}
fn accumulate_llm_elapsed(total_ms: &mut Option<u64>, elapsed_ms: u64) {
*total_ms = Some(total_ms.unwrap_or(0).saturating_add(elapsed_ms));
}
fn record_llm_elapsed(total_ms: &mut Option<u64>, 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<F, C>(
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
llm_call: &mut Option<crate::polish::LlmCallLabel>,
llm_elapsed_ms: &mut Option<u64>,
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<crate::polish::LlmCallLabel>,
llm_elapsed_ms: &mut Option<u64>,
) -> (String, Option<String>) {
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<crate::polish::LlmCallLabel>,
llm_elapsed_ms: &mut Option<u64>,
) -> anyhow::Result<String> {
// 谷歌 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<crate::polish::LlmCallLabel>,
llm_elapsed_ms: &mut Option<u64>,
) -> anyhow::Result<String> {
// 见 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<crate::polish::LlmCallLabel>,
llm_elapsed_ms: &mut Option<u64>,
) -> (String, Option<String>, Option<String>) {
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<crate::polish::LlmCallLabel> = 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));
}
}
@@ -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<Inner>) -> 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<Inner>) -> 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({
@@ -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<T> {
pub(super) session_id: SessionId,
@@ -54,8 +54,17 @@ pub(super) fn take_session_resource<T>(
}
}
pub(super) fn store_asr_for_session(inner: &Arc<Inner>, session_id: SessionId, asr: ActiveAsr) {
/// 存放本次会话的 ASR 句柄 + 构建时 (provider, model) 快照。label 必须来自构建
/// 现场(凭据/alias 归一化后的实际值),不能事后重读全局设置——签名强制每个
/// 构建分支都交出快照,漏一个就编译不过(PR #826 review)。
pub(super) fn store_asr_for_session(
inner: &Arc<Inner>,
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<Inner>, session_id: SessionId) -> Option<ActiveAsr> {
@@ -63,6 +72,15 @@ pub(super) fn take_asr_for_session(inner: &Arc<Inner>, 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<Inner>,
session_id: SessionId,
) -> Option<AsrCallLabel> {
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(),
+25
View File
@@ -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 枚举。
+80
View File
@@ -167,6 +167,26 @@ pub struct DictationSession {
/// `None` / `Some(false)` 都按"无录音"处理;旧 JSON 不带这字段也兼容。
#[serde(default)]
pub has_audio_recording: Option<bool>,
/// 本次转写用的 ASR provider id(如 "volcengine" / "local-qwen3")。历史详情页
/// 展示用,方便做模型能力对比。旧历史无此字段时 None,前端隐藏对应行。
#[serde(default)]
pub asr_provider: Option<String>,
/// 本次转写用的 ASR 模型 id。provider 无模型概念(volcengine / apple-speech)时 None。
#[serde(default)]
pub asr_model: Option<String>,
/// 本次润色用的 LLM provider id。Raw 直通(未调用 LLM)时 None。
#[serde(default)]
pub llm_provider: Option<String>,
/// 本次润色用的 LLM 模型 id。Raw 直通时 None。
#[serde(default)]
pub llm_model: Option<String>,
/// 松键后「等待转写结果」的实测耗时(毫秒)。流式 ASR 大部分识别在录音期间已完成,
/// 这里量的是用户感知的收尾延迟;批式 ASR 则是完整转写耗时。
#[serde(default)]
pub asr_ms: Option<u64>,
/// LLM 润色/翻译调用的实测耗时(毫秒)。未调用 LLM 时 None。
#[serde(default)]
pub polish_ms: Option<u64>,
}
#[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);
}
}
+6 -1
View File
@@ -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',
+6 -1
View File
@@ -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: '入力済み',
+6 -1
View File
@@ -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: '입력됨',
+6 -1
View File
@@ -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: '已插入',
+6 -1
View File
@@ -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: '已插入',
+18
View File
@@ -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) => ({
+12
View File
@@ -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 {
+57 -9
View File
@@ -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() {
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, fontFamily: 'var(--ol-font-mono)', color: 'var(--ol-ink-3)' }}>{formatTime(item.createdAt)}</span>
<Pill size="sm" tone="default">{MODE_LABEL[item.mode]}</Pill>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>{formatDuration(item.durationMs, t)}</span>
{/* 「录音」前缀:与下方识别/润色耗时区分——录音时长发生在松键前,
不该与流水线各步耗时加总(用户反馈"时间对不上")。 */}
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>{t('history.recorded', { duration: formatDuration(item.durationMs, t) })}</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<Btn icon={justCopied ? 'check' : 'copy'} variant="ghost" size="sm" onClick={() => void onCopy()}>{justCopied ? t('common.copied') : t('common.copy')}</Btn>
{item.hasAudioRecording && !audioMissingIds.has(item.id) && (
<Btn icon="download" variant="ghost" size="sm" onClick={() => void onExportAudio()}>{t('history.exportRecording')}</Btn>
)}
@@ -417,19 +419,58 @@ export function History() {
</p>
</div>
<div style={{ padding: 14, border: '0.5px solid var(--ol-blue)', borderRadius: 10, background: 'var(--ol-blue-soft)' }}>
<Pill size="sm" tone="blue" style={{ marginBottom: 10 }}>{MODE_LABEL[item.mode]}</Pill>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10 }}>
<Pill size="sm" tone="blue">{MODE_LABEL[item.mode]}</Pill>
<Btn icon={justCopied ? 'check' : 'copy'} variant="ghost" size="sm" onClick={() => void onCopy()}>
{justCopied ? t('common.copied') : t('common.copy')}
</Btn>
</div>
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.7, color: 'var(--ol-ink)', whiteSpace: 'pre-line' }}>
{item.finalText}
</p>
</div>
</div>
<div style={{ marginTop: 18, paddingTop: 14, borderTop: '0.5px solid var(--ol-line-soft)', display: 'flex', gap: 18, fontSize: 11, color: 'var(--ol-ink-4)', flexWrap: 'wrap' }}>
{item.appName && <span>{t('history.insertedTo')} <b style={{ color: 'var(--ol-ink-2)' }}>{item.appName}</b></span>}
<span>{t('history.chars', { count: item.finalText.length })}</span>
{item.dictionaryEntryCount != null && item.dictionaryEntryCount > 0 && (
<span>{t('history.vocabHits', { count: item.dictionaryEntryCount })}</span>
{/* 流水线明细:识别 / 润色 / 插入 三步各占一行 —— 左列步骤名、中列
provider·model(或插入目标),右列该步耗时/状态。旧历史没有模型与
耗时字段时对应行自动隐藏,只剩插入行 = 改版前的信息量。 */}
<div style={{ marginTop: 18, paddingTop: 14, borderTop: '0.5px solid var(--ol-line-soft)', display: 'grid', gridTemplateColumns: 'auto 1fr auto', columnGap: 14, rowGap: 7, fontSize: 11, color: 'var(--ol-ink-4)', alignItems: 'baseline' }}>
{(item.asrProvider || item.asrMs != null) && (
<>
<span style={{ display: 'flex' }}>
<Tooltip content={t('history.stepAsrHint')} wrap placement="bottom" focusable>
<span style={{ cursor: 'help', textDecoration: 'underline dotted', textDecorationColor: 'var(--ol-ink-4)', textUnderlineOffset: 3 }}>
{t('history.stepAsr')}
</span>
</Tooltip>
</span>
<span style={{ color: 'var(--ol-ink-2)', fontFamily: 'var(--ol-font-mono)', overflowWrap: 'anywhere' }}>
{[item.asrProvider, item.asrModel].filter(Boolean).join(' · ')}
</span>
<span style={{ fontFamily: 'var(--ol-font-mono)', textAlign: 'right' }}>
{item.asrMs != null ? formatStepDuration(item.asrMs, t) : ''}
</span>
</>
)}
<span>{
{(item.llmProvider || item.llmModel || item.polishMs != null) && (
<>
<span>{t('history.stepPolish')}</span>
<span style={{ color: 'var(--ol-ink-2)', fontFamily: 'var(--ol-font-mono)', overflowWrap: 'anywhere' }}>
{[item.llmProvider, item.llmModel].filter(Boolean).join(' · ')}
</span>
<span style={{ fontFamily: 'var(--ol-font-mono)', textAlign: 'right' }}>
{item.polishMs != null ? formatStepDuration(item.polishMs, t) : ''}
</span>
</>
)}
<span>{t('history.stepInsert')}</span>
<span style={{ color: 'var(--ol-ink-2)' }}>
{item.appName && <><b>{item.appName}</b>{' · '}</>}
{t('history.chars', { count: item.finalText.length })}
{item.dictionaryEntryCount != null && item.dictionaryEntryCount > 0 && (
<>{' · '}{t('history.vocabHits', { count: item.dictionaryEntryCount })}</>
)}
</span>
<span style={{ textAlign: 'right' }}>{
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<typeof useTranslation>['t']): string {
if (ms < 1000) return t('common.durationMillis', { value: Math.round(ms) });
return formatDuration(ms, t);
}
function formatDuration(ms: number | null, t: ReturnType<typeof useTranslation>['t']): string {
if (ms == null || ms <= 0) return '—';
const sec = ms / 1000;