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:
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));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 枚举。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '入力済み',
|
||||
|
||||
@@ -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: '입력됨',
|
||||
|
||||
@@ -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: '已插入',
|
||||
|
||||
@@ -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: '已插入',
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user