Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions openless-all/app/src-tauri/src/commands/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,20 @@ pub async fn inject_hotkey_click_for_dev(coord: CoordinatorState<'_>) -> Result<
coord.inject_hotkey_click_for_dev().await
}

/// `style_pack_id` 省略 = 用当前激活风格包(历史页「重试」);给了 id = 用指定风格包
/// 试算一次(历史页「换风格重润色」),不改变激活状态。
#[tauri::command]
pub async fn repolish(
coord: CoordinatorState<'_>,
raw_text: String,
mode: PolishMode,
style_pack_id: Option<String>,
) -> Result<String, String> {
log::info!(
"[style-pack] command repolish requested legacy_mode={:?} raw_chars={}",
"[style-pack] command repolish requested legacy_mode={:?} raw_chars={} style_pack_id={:?}",
mode,
raw_text.chars().count()
raw_text.chars().count(),
style_pack_id
);
coord.repolish(raw_text, mode).await
coord.repolish(raw_text, mode, style_pack_id).await
}
31 changes: 25 additions & 6 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2028,14 +2028,33 @@ impl Coordinator {
Ok(())
}

pub async fn repolish(&self, raw_text: String, mode: PolishMode) -> Result<String, String> {
/// 用某个风格包重新润色一段已有原文。
///
/// `style_pack_id`:
/// - `None` → 用当前激活的风格包。历史页的「重试」走这条:同样的输入再给模型看一遍,
/// 用来判断上一次的结果是模型抖动还是稳定行为。
/// - `Some(id)` → 用指定的风格包。历史页的「换风格重润色」走这条。
///
/// 指定的包**不需要**处于激活状态,也不会改变激活状态:这只是一次一次性试算,
/// 不该有把用户当前风格换掉的副作用。
pub async fn repolish(
&self,
raw_text: String,
mode: PolishMode,
style_pack_id: Option<String>,
) -> Result<String, String> {
let hotwords = enabled_phrases(&self.inner);
let prefs = self.inner.prefs.get();
let pack = self
.inner
.style_packs
.get_or_default_active(&prefs.active_style_pack_id)
.map_err(|e| e.to_string())?;
let pack = match style_pack_id.as_deref() {
// 显式指定时按 id 精确取,不走 get_or_default_active 的兜底链——用户点的是
// 「用这个风格看看」,静默回落到别的包会让结果无从解释。
Some(id) => self.inner.style_packs.get(id).map_err(|e| e.to_string())?,
None => self
.inner
.style_packs
.get_or_default_active(&prefs.active_style_pack_id)
.map_err(|e| e.to_string())?,
};
let style_system_prompt = crate::types::style_pack_prompt(
&pack,
crate::types::StylePromptKind::DictationAsr,
Expand Down
31 changes: 22 additions & 9 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2633,7 +2633,10 @@ fn build_transcribe_failed_session(
asr_ms: u64,
mode: PolishMode,
has_audio_recording: bool,
front_app: Option<&str>,
) -> DictationSession {
// 失败条目也记前台应用:排查「在某个 app 里总是转录失败」时这一列就是线索。
let front = crate::types::split_front_app_opt(front_app);
DictationSession {
id: session_id.to_string(),
created_at: Utc::now().to_rfc3339(),
Expand All @@ -2644,8 +2647,8 @@ fn build_transcribe_failed_session(
style_pack_id: None,
translation_active: false,
polish_source: None,
app_bundle_id: None,
app_name: None,
app_bundle_id: front.bundle_id,
app_name: front.name,
insert_status: InsertStatus::Failed,
error_code: Some("transcribeFailed".to_string()),
duration_ms: Some(duration_ms),
Expand All @@ -2668,12 +2671,14 @@ fn write_transcribe_failed_history(
asr_call_label: Option<&AsrCallLabel>,
) {
let prefs = inner.prefs.get();
let front_app = inner.state.lock().front_app.clone();
let mut session = build_transcribe_failed_session(
session_id,
duration_ms,
asr_ms,
prefs.default_mode,
inner.audio_archive_active.load(Ordering::Relaxed),
front_app.as_deref(),
);
// 失败条目也记下是哪个 ASR 出的错——「哪个模型转不出来」正是模型对比要看的信息。
// 用 begin_session 的构建时快照,而不是此刻重读设置(PR #826 review)。
Expand Down Expand Up @@ -3468,6 +3473,10 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
}

if raw.text.trim().is_empty() {
// 失败条目同样记下当时的前台应用:排查「在某个 app 里总是识别不到」时,这一列
// 就是线索本身。
let empty_front =
crate::types::split_front_app_opt(inner.state.lock().front_app.as_deref());
let session = DictationSession {
// session_id 与归档 wav 同名,empty 录音才能被 read_audio_recording /
// retranscribe_recording 凭 id 找回(之前用 Uuid::new_v4,与 `<session_id>.wav`
Expand All @@ -3481,8 +3490,8 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
style_pack_id: None,
translation_active: false,
polish_source: None,
app_bundle_id: None,
app_name: None,
app_bundle_id: empty_front.bundle_id,
app_name: empty_front.name,
insert_status: InsertStatus::Failed,
error_code: Some("emptyTranscript".to_string()),
duration_ms: Some(raw.duration_ms),
Expand Down Expand Up @@ -3900,6 +3909,10 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let history_session_id = current_session_id.to_string();
let history_created_at = Utc::now().to_rfc3339();
let prefs_snapshot = inner.prefs.get();
// 落字目标应用:begin_session 就采过(capture_frontmost_app),此前只喂给了 polish
// prompt,没写进历史 —— 于是详情页的「插入」行永远只有字数,看不出这段话落到了哪。
// 前端早就会渲染 app_name,缺的一直是这里的写入。
let insert_front = crate::types::split_front_app_opt(front_app.as_deref());
let session = DictationSession {
id: history_session_id.clone(),
created_at: history_created_at.clone(),
Expand All @@ -3910,8 +3923,8 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
style_pack_id: Some(pack.id.clone()),
translation_active,
polish_source,
app_bundle_id: None,
app_name: None,
app_bundle_id: insert_front.bundle_id,
app_name: insert_front.name,
insert_status: status,
error_code,
duration_ms: Some(raw.duration_ms),
Expand Down Expand Up @@ -4344,15 +4357,15 @@ mod tests {
// 录音随 prune 丢失(用户报告「识别失败之前的语音也都丢失了」)。
let sid = Uuid::new_v4();
let session =
build_transcribe_failed_session(sid, 4200, 17_250, PolishMode::Structured, true);
build_transcribe_failed_session(sid, 4200, 17_250, PolishMode::Structured, true, None);
assert_eq!(session.id, sid.to_string());
}

#[test]
fn transcribe_failed_history_marks_failed_and_recoverable() {
let sid = Uuid::new_v4();
let session =
build_transcribe_failed_session(sid, 1234, 17_250, PolishMode::Structured, true);
build_transcribe_failed_session(sid, 1234, 17_250, PolishMode::Structured, true, None);
assert!(matches!(session.insert_status, InsertStatus::Failed));
assert_eq!(session.error_code.as_deref(), Some("transcribeFailed"));
assert_eq!(session.duration_ms, Some(1234));
Expand All @@ -4366,7 +4379,7 @@ mod tests {
// 录音归档失败(has_audio=false)→ 条目仍写(用户看得到这次失败),但不标可重转,
// 避免前端渲染重转按钮而后端找不到 wav。
let sid = Uuid::new_v4();
let session = build_transcribe_failed_session(sid, 1, 250, PolishMode::Structured, false);
let session = build_transcribe_failed_session(sid, 1, 250, PolishMode::Structured, false, None);
assert_eq!(session.has_audio_recording, Some(false));
}

Expand Down
6 changes: 4 additions & 2 deletions openless-all/app/src-tauri/src/coordinator/qa_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,8 @@ pub(super) async fn answer_qa_question_text(
}

if prefs.qa_save_history {
// 与听写路径同口径:应用名与 bundle id 分开存。
let qa_front = crate::types::split_front_app_opt(front_app.as_deref());
let session = DictationSession {
id: Uuid::new_v4().to_string(),
created_at: Utc::now().to_rfc3339(),
Expand All @@ -760,8 +762,8 @@ pub(super) async fn answer_qa_question_text(
style_pack_id: None,
translation_active: false,
polish_source: None,
app_bundle_id: None,
app_name: front_app,
app_bundle_id: qa_front.bundle_id,
app_name: qa_front.name,
insert_status: InsertStatus::CopiedFallback,
error_code: Some("qaSession".to_string()),
duration_ms: Some(duration_ms),
Expand Down
13 changes: 9 additions & 4 deletions openless-all/app/src-tauri/src/coordinator/selection_polish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ pub(super) async fn run_selection_polish(inner: &Arc<Inner>) -> Result<(), Strin
None => (None, None),
};
let raw_chars = raw_text.chars().count();
// 与听写路径同口径:应用名与 bundle id 分开存。
let source_front = crate::types::split_front_app_opt(source_app.as_deref());
let session = DictationSession {
id: Uuid::new_v4().to_string(),
created_at: Utc::now().to_rfc3339(),
Expand All @@ -327,8 +329,8 @@ pub(super) async fn run_selection_polish(inner: &Arc<Inner>) -> Result<(), Strin
style_pack_id: Some(pack.id.clone()),
translation_active: false,
polish_source: None,
app_bundle_id: None,
app_name: source_app,
app_bundle_id: source_front.bundle_id,
app_name: source_front.name,
insert_status: status,
error_code: (status == InsertStatus::Failed)
.then_some("selectionPolishInsertFailed".into()),
Expand Down Expand Up @@ -438,6 +440,9 @@ impl Coordinator {
log::error!("[selection-polish] record vocabulary hits failed: {error}");
Some(0)
});
// 与听写路径同口径:应用名与 bundle id 分开存,详情页才不会把一长串 bundle id
// 糊进正文。
let preview_front = crate::types::split_front_app_opt(preview.source_app.as_deref());
let session = DictationSession {
id: Uuid::new_v4().to_string(),
created_at: Utc::now().to_rfc3339(),
Expand All @@ -448,8 +453,8 @@ impl Coordinator {
style_pack_id: Some(preview.style_pack_id),
translation_active: false,
polish_source: None,
app_bundle_id: None,
app_name: preview.source_app,
app_bundle_id: preview_front.bundle_id,
app_name: preview_front.name,
insert_status: status,
error_code: None,
duration_ms: Some(preview.started_at.elapsed().as_millis() as u64),
Expand Down
119 changes: 119 additions & 0 deletions openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,56 @@ pub enum SelectionPolishOutputMode {
PreviewConfirm,
}

/// 前台应用标签拆分结果:人读的应用名 +(macOS 的)bundle id。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrontApp {
pub name: Option<String>,
pub bundle_id: Option<String>,
}

/// 把 `capture_frontmost_app()` 的显示串拆成 `FrontApp { name, bundle_id }`。
///
/// macOS 那边拼的是 `"Claude (com.anthropic.claudefordesktop)"`;Windows 拿的是窗口
/// 标题,没有 bundle id。历史条目有 `app_name` / `app_bundle_id` 两个字段,拆开存
/// 才能让详情页只显示人读得懂的应用名,而不是把一长串 bundle id 也糊在正文里。
///
/// 只有 macOS 的标签才是 `"名称 (bundle.id)"` 格式;Windows 拿的是窗口标题,括号属于
/// 标题正文。调用方必须按平台传入 `is_macos`(生产路径统一走 `split_front_app_opt`),
/// 非 macOS 一律整串当应用名。认不出括号结构也整串当应用名 —— 宁可显示得啰嗦,
/// 也不要把窗口标题里的普通括号误当成 bundle id。
pub fn split_front_app_label(label: &str, is_macos: bool) -> FrontApp {
let trimmed = label.trim();
if trimmed.is_empty() {
return FrontApp { name: None, bundle_id: None };
}
if is_macos {
if let Some(open) = trimmed.rfind(" (") {
if trimmed.ends_with(')') {
let name = trimmed[..open].trim();
let bundle = trimmed[open + 2..trimmed.len() - 1].trim();
// bundle id 必然是点分的反向域名。没有点的括号内容("记事本 (未保存)"
// 这类窗口标题)不是 bundle id,不能拆。
if !name.is_empty() && bundle.contains('.') && !bundle.contains(' ') {
return FrontApp {
name: Some(name.to_string()),
bundle_id: Some(bundle.to_string()),
};
}
}
}
}
FrontApp { name: Some(trimmed.to_string()), bundle_id: None }
}

/// `split_front_app_label` 的 `Option` 便捷版,平台开关收敛在这一处:
/// 只有 macOS 的显示串才是 `"名称 (bundle.id)"`,其它平台(Windows 窗口标题、Linux)
/// 整串当应用名,bundle id 留空。
pub fn split_front_app_opt(label: Option<&str>) -> FrontApp {
label
.map(|l| split_front_app_label(l, cfg!(target_os = "macos")))
.unwrap_or(FrontApp { name: None, bundle_id: None })
}

/// 概览页活动统计的单日汇总(date = 本地日期 YYYY-MM-DD)。
///
/// 年度热力图只用 `count`;`chars` / `duration_ms` 供「近 7 天 / 近 30 天」的
Expand Down Expand Up @@ -3019,6 +3069,75 @@ pub struct QaChatMessage {
pub selection_text: Option<String>,
}

#[cfg(test)]
mod split_front_app_label_tests {
use super::{split_front_app_label, split_front_app_opt, FrontApp};

#[test]
fn macos_label_splits_into_name_and_bundle() {
let split = split_front_app_label("Claude (com.anthropic.claudefordesktop)", true);
assert_eq!(split.name.as_deref(), Some("Claude"));
assert_eq!(split.bundle_id.as_deref(), Some("com.anthropic.claudefordesktop"));
}

#[test]
fn app_names_containing_spaces_and_parens_still_split_on_the_last_group() {
let split = split_front_app_label("Visual Studio Code (com.microsoft.VSCode)", true);
assert_eq!(split.name.as_deref(), Some("Visual Studio Code"));
assert_eq!(split.bundle_id.as_deref(), Some("com.microsoft.VSCode"));
}

/// Windows 拿的是窗口标题,里面的括号是正文的一部分,不是 bundle id。
/// 平台开关关闭时整串保留——即使括号内容恰好形如反向域名、文件路径或版本号,
/// 也绝不拆。误拆会把标题截断,显示成半句话,还写入错误的 bundle id。
#[test]
fn window_titles_are_never_split_outside_macos() {
for title in [
"未命名文档 (未保存)",
"report.txt (~/Documents)",
"Inbox (12)",
"script.py (C:\\dir\\script.py)",
"会议 (meet.example.com)",
"卸载 (2.4.1)",
] {
let split = split_front_app_label(title, false);
assert_eq!(split.name.as_deref(), Some(title), "{title} should stay intact");
assert_eq!(split.bundle_id, None, "{title} has no bundle id");
}
}

#[test]
fn bare_names_pass_through() {
let split = split_front_app_label("Terminal", true);
assert_eq!(split.name.as_deref(), Some("Terminal"));
assert_eq!(split.bundle_id, None);
}

#[test]
fn blank_input_yields_nothing() {
assert_eq!(
split_front_app_label("", true),
FrontApp { name: None, bundle_id: None }
);
assert_eq!(
split_front_app_label(" ", true),
FrontApp { name: None, bundle_id: None }
);
assert_eq!(
split_front_app_label("", false),
FrontApp { name: None, bundle_id: None }
);
assert_eq!(
split_front_app_label(" ", false),
FrontApp { name: None, bundle_id: None }
);
assert_eq!(
split_front_app_opt(None),
FrontApp { name: None, bundle_id: None }
);
}
}

#[cfg(test)]
mod translation_effective_tests {
use super::translation_effective;
Expand Down
17 changes: 17 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,23 @@ export const en: typeof zhCN = {
insertFailed: 'Insert failed',
confirmClear: 'Delete all {{count}} history entries? This cannot be undone.',
backToList: 'Back to list',
repolish: {
title: 'Re-polish',
hint: 'Run polish again on the transcript above. Results are shown for this visit only and are not written back to the record. When the original style pack was deleted or the record predates style packs, retry uses the current style.',
retry: 'Retry with same style',
retrying: 'Retrying…',
apply: 'Apply',
applying: 'Polishing…',
pickStyle: 'Pick a style pack',
noPacks: 'No style packs available.',
packsLoadFailed: 'Failed to load style packs: {{err}}',
failed: 'Re-polish failed: {{err}}',
timeout: 'The current LLM provider did not respond within 30 seconds. Switch to a faster provider, or try again later — free model pools often queue.',
resultTitle: 'Result from {{name}}',
retryResultTitle: 'Retry result',
empty: '(the model returned an empty result)',
clear: 'Clear results',
},
},
vocab: {
kicker: 'VOCABULARY',
Expand Down
Loading
Loading