feat: 跨 App 语气适应(前台 App 注入 LLM context)— closes #116

参考 Wispr Flow / Superwhisper Super Mode / Typeless 的"按应用调整语气"
设计:begin_session 捕获前台 app 标签 → polish/translate 调用前作为
"# 上下文\n当前前台应用:…" 拼到 system prompt 头部,让 LLM 自行根据
app 类型调风格,不维护硬编码 app→style 表。

实现
  - coordinator.rs::capture_frontmost_app(macOS NSWorkspace 拿
    localizedName + bundleIdentifier;Windows GetWindowTextW 拿前台
    HWND 标题;Linux/其他平台 None)
  - SessionState 加 front_app: Option<String>,begin_session 填充
  - polish.rs::polish/translate_to 新增 front_app: Option<&str> 参数
  - 原 working_languages_premise 提升为 context_premise,两个字段
    一起拼成 # 上下文 块
  - repolish 路径独立调用 capture_frontmost_app(不沿用原 session 的
    上下文,因为用户重新润色时所处的 app 可能已经不同)

Out of scope(v1)
  - 不读 AX 焦点元素的 selectedText(隐私 + Electron app 兼容性差)
  - 不维护 app→style 映射表(让 LLM 自己理解)
  - Linux 实现跳过

NSWorkspace 是 macOS 公开 API,无需额外权限;Windows 复用现有
foreground HWND 路径,也不增加权限要求。
This commit is contained in:
baiqing committed 2026-05-01 12:41:30 +08:00
1 parent 39c4633fbd
commit 3ad57983d8
2 files changed
+236 -73

No files matched your search

+174 -50
View File
@@ -64,6 +64,9 @@ struct SessionState {
/// recorder error monitor 持有 captured id,处理时若与当前不等说明
/// 是上一 session 的迟到错误,必须 drop,不要 abort 当前 active session。
session_id: u64,
/// 用户开始 dictation 时所处的前台 app 标签("Mail (com.apple.mail)" / Windows 窗口标题)。
/// 用作 LLM polish/translate 的上下文前提,让模型按 app 调风格。详见 issue #116。
front_app: Option<String>,
}
impl Default for SessionState {
@@ -75,6 +78,7 @@ impl Default for SessionState {
cancelled: false,
focus_target: None,
session_id: 0,
front_app: None,
}
}
}
@@ -209,9 +213,18 @@ impl Coordinator {
pub async fn repolish(&self, raw_text: String, mode: PolishMode) -> Result<String, String> {
let hotwords = enabled_phrases(&self.inner);
let working_languages = self.inner.prefs.get().working_languages;
polish_text(&raw_text, mode, &hotwords, &working_languages)
.await
.map_err(|e| e.to_string())
// repolish 是历史记录里手动重新润色,不再绑定原 session 的前台 app;
// 当下用户调起的 app 才是相关上下文(如果可拿)。
let front_app = capture_frontmost_app();
polish_text(
&raw_text,
mode,
&hotwords,
&working_languages,
front_app.as_deref(),
)
.await
.map_err(|e| e.to_string())
}
}
@@ -464,6 +477,10 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
// 自增 session_id;spawn 出去的 recorder error monitor 会捕获这个值,
// 如果迟到错误到达时 id 已不匹配就 drop,不会误中止后续 session。
state.session_id = state.session_id.wrapping_add(1);
state.front_app = capture_frontmost_app();
if let Some(label) = state.front_app.as_deref() {
log::info!("[coord] front_app captured: {label}");
}
}
// 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。
inner.translation_modifier_seen.store(false, Ordering::SeqCst);
@@ -874,18 +891,33 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let mode = prefs.default_mode;
let hotword_strs = enabled_phrases(inner);
let working_languages = prefs.working_languages.clone();
let front_app = inner.state.lock().front_app.clone();
let translation_target = prefs.translation_target_language.trim().to_string();
let translation_active = inner.translation_modifier_seen.load(Ordering::SeqCst)
&& !translation_target.is_empty();
let (polished, polish_error) = if translation_active {
log::info!(
"[coord] translation mode → target=\u{300C}{}\u{300D} working={:?}",
"[coord] translation mode → target=\u{300C}{}\u{300D} working={:?} front_app={:?}",
translation_target,
working_languages
working_languages,
front_app
);
translate_or_passthrough(&raw, &translation_target, &working_languages).await
translate_or_passthrough(
&raw,
&translation_target,
&working_languages,
front_app.as_deref(),
)
.await
} else {
polish_or_passthrough(&raw, mode, &hotword_strs, &working_languages).await
polish_or_passthrough(
&raw,
mode,
&hotword_strs,
&working_languages,
front_app.as_deref(),
)
.await
};
// 原子化最后一次 cancel 检查 + 转 Inserting:
@@ -1098,11 +1130,12 @@ async fn polish_or_passthrough(
mode: PolishMode,
hotwords: &[String],
working_languages: &[String],
front_app: Option<&str>,
) -> (String, Option<String>) {
if mode == PolishMode::Raw {
return (raw.text.clone(), None);
}
match polish_text(&raw.text, mode, hotwords, working_languages).await {
match polish_text(&raw.text, mode, hotwords, working_languages, front_app).await {
Ok(s) => (s, None),
Err(e) => {
let reason = e.to_string();
@@ -1117,47 +1150,7 @@ async fn polish_text(
mode: PolishMode,
hotwords: &[String],
working_languages: &[String],
) -> anyhow::Result<String> {
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
if api_key.is_empty() {
anyhow::bail!("ark api key missing");
}
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "deepseek-v3-2".to_string());
let endpoint = CredentialsVault::get(CredentialAccount::ArkEndpoint)?
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "https://ark.cn-beijing.volces.com/api/v3/chat/completions".to_string());
let base_url = endpoint
.trim_end_matches("/chat/completions")
.trim_end_matches('/')
.to_string();
let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model);
let provider = OpenAICompatibleLLMProvider::new(config);
Ok(provider.polish(raw, mode, hotwords, working_languages).await?)
}
/// 翻译路径——和 polish 一样失败时返回原文 + 失败原因,避免"不丢字"约定被违反(CLAUDE.md)。
async fn translate_or_passthrough(
raw: &RawTranscript,
target_language: &str,
working_languages: &[String],
) -> (String, Option<String>) {
match translate_text(&raw.text, target_language, working_languages).await {
Ok(s) => (s, None),
Err(e) => {
let reason = e.to_string();
log::error!("[coord] translate failed, falling back to raw: {reason}");
(raw.text.clone(), Some(reason))
}
}
}
async fn translate_text(
raw: &str,
target_language: &str,
working_languages: &[String],
front_app: Option<&str>,
) -> anyhow::Result<String> {
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
if api_key.is_empty() {
@@ -1177,7 +1170,52 @@ async fn translate_text(
let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model);
let provider = OpenAICompatibleLLMProvider::new(config);
Ok(provider
.translate_to(raw, target_language, working_languages)
.polish(raw, mode, hotwords, working_languages, front_app)
.await?)
}
/// 翻译路径——和 polish 一样失败时返回原文 + 失败原因,避免"不丢字"约定被违反(CLAUDE.md)。
async fn translate_or_passthrough(
raw: &RawTranscript,
target_language: &str,
working_languages: &[String],
front_app: Option<&str>,
) -> (String, Option<String>) {
match translate_text(&raw.text, target_language, working_languages, front_app).await {
Ok(s) => (s, None),
Err(e) => {
let reason = e.to_string();
log::error!("[coord] translate failed, falling back to raw: {reason}");
(raw.text.clone(), Some(reason))
}
}
}
async fn translate_text(
raw: &str,
target_language: &str,
working_languages: &[String],
front_app: Option<&str>,
) -> anyhow::Result<String> {
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
if api_key.is_empty() {
anyhow::bail!("ark api key missing");
}
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "deepseek-v3-2".to_string());
let endpoint = CredentialsVault::get(CredentialAccount::ArkEndpoint)?
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "https://ark.cn-beijing.volces.com/api/v3/chat/completions".to_string());
let base_url = endpoint
.trim_end_matches("/chat/completions")
.trim_end_matches('/')
.to_string();
let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model);
let provider = OpenAICompatibleLLMProvider::new(config);
Ok(provider
.translate_to(raw, target_language, working_languages, front_app)
.await?)
}
@@ -1436,6 +1474,92 @@ fn capture_focus_target() -> Option<usize> {
None
}
/// 捕获用户开始 dictation 时的前台 app 标签("localizedName (bundle.id)"),用作 LLM
/// polish/translate 的上下文前提,让模型按 app 调风格。详见 issue #116。
///
/// macOS 走 NSWorkspace.frontmostApplication(公开 API,无需额外权限);
/// Windows 复用前台 HWND 拿窗口标题;Linux/其他平台返回 None。
#[cfg(target_os = "macos")]
fn capture_frontmost_app() -> Option<String> {
use objc2::msg_send;
use objc2::runtime::{AnyClass, AnyObject};
unsafe {
let cls = AnyClass::get("NSWorkspace")?;
let workspace: *mut AnyObject = msg_send![cls, sharedWorkspace];
if workspace.is_null() {
return None;
}
let app: *mut AnyObject = msg_send![workspace, frontmostApplication];
if app.is_null() {
return None;
}
let name_obj: *mut AnyObject = msg_send![app, localizedName];
let bundle_obj: *mut AnyObject = msg_send![app, bundleIdentifier];
let name = nsstring_to_string(name_obj);
let bundle = nsstring_to_string(bundle_obj);
match (name, bundle) {
(Some(n), Some(b)) => Some(format!("{n} ({b})")),
(Some(n), None) => Some(n),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
}
#[cfg(target_os = "macos")]
unsafe fn nsstring_to_string(ns_string: *mut objc2::runtime::AnyObject) -> Option<String> {
use objc2::msg_send;
if ns_string.is_null() {
return None;
}
let utf8: *const std::os::raw::c_char = unsafe { msg_send![ns_string, UTF8String] };
if utf8.is_null() {
return None;
}
let cstr = unsafe { std::ffi::CStr::from_ptr(utf8) };
let s = cstr.to_string_lossy().into_owned();
if s.is_empty() {
None
} else {
Some(s)
}
}
#[cfg(target_os = "windows")]
fn capture_frontmost_app() -> Option<String> {
use windows::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW,
};
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.0.is_null() {
return None;
}
let len = GetWindowTextLengthW(hwnd);
if len <= 0 {
return None;
}
let mut buf = vec![0u16; (len + 1) as usize];
let copied = GetWindowTextW(hwnd, &mut buf);
if copied <= 0 {
return None;
}
let title = String::from_utf16_lossy(&buf[..copied as usize]);
if title.is_empty() {
None
} else {
Some(title)
}
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn capture_frontmost_app() -> Option<String> {
None
}
#[cfg(target_os = "windows")]
fn restore_focus_target_if_possible(target: Option<usize>) {
use std::ffi::c_void;
+62 -23
View File
@@ -90,9 +90,10 @@ impl OpenAICompatibleLLMProvider {
mode: PolishMode,
hotwords: &[String],
working_languages: &[String],
front_app: Option<&str>,
) -> Result<String, LLMError> {
let mut system_prompt = compose_system_prompt(mode, hotwords);
if let Some(premise) = working_languages_premise(working_languages) {
if let Some(premise) = context_premise(working_languages, front_app) {
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
let user_prompt = prompts::user_prompt(raw_text);
@@ -100,15 +101,16 @@ impl OpenAICompatibleLLMProvider {
}
/// 把转写翻译成 `target_language`(前端从内置语言列表里选出来的原生名)。
/// `working_languages` 作为前提注入头部。详见 issue #4。
/// `working_languages` 与 `front_app` 作为前提注入头部。详见 issue #4 与 #116。
pub async fn translate_to(
&self,
raw_text: &str,
target_language: &str,
working_languages: &[String],
front_app: Option<&str>,
) -> Result<String, LLMError> {
let mut system_prompt = prompts::translate_system_prompt(target_language);
if let Some(premise) = working_languages_premise(working_languages) {
if let Some(premise) = context_premise(working_languages, front_app) {
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
let user_prompt = prompts::user_prompt(raw_text);
@@ -204,21 +206,39 @@ fn chat_completions_url(base_url: &str) -> String {
format!("{}/chat/completions", without_trailing)
}
/// 把 working_languages 拼成 system prompt 头部前提:"# 上下文\n用户的工作语言:A、B、C"。
/// 列表为空或全空白返回 None,调用方就不拼前缀。
fn working_languages_premise(working_languages: &[String]) -> Option<String> {
let cleaned: Vec<&str> = working_languages
/// 把 working_languages + front_app 拼成 system prompt 头部前提:
/// # 上下文
/// 用户的工作语言:…
/// 当前前台应用:…(请按这个 app 的常见沟通风格调整语气)
///
/// 两个字段都空时返回 None,调用方就不拼前缀。详见 issue #4 / #116。
fn context_premise(working_languages: &[String], front_app: Option<&str>) -> Option<String> {
let langs: Vec<&str> = working_languages
.iter()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if cleaned.is_empty() {
let app = front_app
.map(str::trim)
.filter(|s| !s.is_empty());
if langs.is_empty() && app.is_none() {
return None;
}
Some(format!(
"# 上下文\n用户的工作语言:{}。处理任何文本时请把这一前提带进考虑(识别专名、判定语气、决定写法)。",
cleaned.join("、")
))
let mut lines = vec!["# 上下文".to_string()];
if !langs.is_empty() {
lines.push(format!(
"用户的工作语言:{}。处理任何文本时请把这一前提带进考虑(识别专名、判定语气、决定写法)。",
langs.join("、")
));
}
if let Some(name) = app {
lines.push(format!(
"当前前台应用:{name}。请按这个应用的常见沟通风格调整语气——例如邮件类 app 偏正式、聊天类 app 偏口语、IDE / 文档类 app 偏技术或结构化。\u{4E0D}主动加入与用户原意无关的客套话。"
));
}
Some(lines.join("\n"))
}
fn compose_system_prompt(mode: PolishMode, hotwords: &[String]) -> String {
@@ -586,23 +606,42 @@ pub mod prompts {
)
}
/// 翻译模式 system prompt — 用户在 Settings 里填的任意自然语言文本作为目标语言,
/// 直接拼进来。LLM 自己理解("繁体中文"/"English"/"美式英文,正式邮件风格" 都行)。
/// 翻译模式 system prompt — 用户在「翻译」页选定的目标语言(内置 15 种自然语言原生名)。
/// LLM 自己理解("繁体中文"/"English"/"美式英文"/"日本語" 都行)。
/// 此 prompt 之上还有 working_languages_premise 拼出的"# 上下文"前提。
pub fn translate_system_prompt(target_language: &str) -> String {
format!(
"# 任务(翻译输出)\n\
你刚收到一段语音转写。请把它翻译成 \u{300C}{}\u{300D},\
保持原意、语气和必要的标点;不增不减、不解释、不加任何前缀或后缀。\n\
把下面收到的一段语音转写翻译成 \u{300C}{lang}\u{300D}。\n\
这是用户对着语音输入工具说的话——他正在某个 app 的输入框前,\
转译结果会直接被插入到光标位置。\n\
\n\
# 行为\n\
- 直接输出翻译结果。\n\
- 如果转写里夹杂多种语言,统一翻译到目标语言。\n\
- 转写本来就是目标语言时,做最小润色(补标点、去口癖)后输出。\n\
- 不要带 \u{300C}翻译:\u{300D}\u{300C}译文:\u{300D}之类前缀。\n\
# 翻译规则\n\
## 必须保留原文(不要翻译)\n\
- 人名、地名、品牌名(OpenAI、Tauri、字节跳动、张三 等)。\n\
- 代码标识符、技术术语(useState、async/await、HTTP、Rust crate 名 等)。\n\
- URL、邮箱、文件路径、命令行片段。\n\
- 说话人**故意**用源语言夹进来的英文/技术词,按原样保留,\u{4E0D}替换为目标语言对应词。\n\
\n\
## 主体翻译\n\
- 句子骨架、动作、形容、连接词翻译成 \u{300C}{lang}\u{300D}。\n\
- **保持原说话语气**:口语就维持口语化(\u{4E0D}强行正式化),书面就维持书面。\n\
- **保持原意**:不增不减、不解释、不扩写、不替用户做决策。\
如\"我想给老板发个邮件说今天我们要推迟发布\"应翻译成\"I want to email my boss saying we need to delay the release today\",\
\u{800C}\u{4E0D}\u{662F}主动生成邮件正文。\n\
- 数字、日期、时间用目标语言地区常见写法(\"5月1日下午两点\" → \"May 1, 2 PM\";\
\"明天上午十点\" → \"tomorrow at 10 AM\";\"100块\" → \"100 yuan\")。\n\
- 转写已经是目标语言时:去明显口癖(嗯、那个、就是、um、you know)+ 补必要标点,\u{4E0D}做风格改写。\n\
\n\
## 边界 case\n\
- 转写非常短(一两个字)也照译,\u{4E0D}因为短就硬补内容。\n\
- 转写是命令式(\"加个空格 / 删除最后一行\")时,照原意翻译,\u{4E0D}改成陈述句。\n\
- 转写全是 fillers(\"嗯嗯啊那个\")时,输出空字符串。\n\
\n\
# 输出\n\
只输出翻译后的文本正文。",
target_language
只输出翻译后的正文,\u{4E0D}带 \u{300C}翻译:\u{300D}\u{300C}译文:\u{300D}\u{300C}Translation:\u{300D}之类前缀,\
\u{4E0D}加引号、\u{4E0D}加 markdown 围栏。",
lang = target_language
)
}
}