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
755 changes: 328 additions & 427 deletions openless-all/app/src-tauri/Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions openless-all/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ chrono = { version = "0.4", features = ["serde"] }
bytes = "1"
url = "2"
raw-window-handle = "0.6"
ferrous-opencc = "0.4"

# Hotkey + audio + insertion
global-hotkey = "0.6"
Expand Down
14 changes: 11 additions & 3 deletions openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ use crate::permissions::{self, PermissionStatus};
use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault};
use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
use crate::types::{
CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus,
PolishMode, QaHotkeyBinding, UserPreferences, VocabPresetStore, WindowsImeStatus,
ChineseScriptPreference, CredentialsStatus, DictationSession, DictionaryEntry,
HotkeyCapability, HotkeyStatus, PolishMode, QaHotkeyBinding, UserPreferences,
VocabPresetStore, WindowsImeStatus,
};

type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
Expand Down Expand Up @@ -223,7 +224,14 @@ async fn validate_llm_provider() -> Result<(), String> {
model,
));
provider
.polish("验证连接", PolishMode::Raw, &[], &[], None)
.polish(
"验证连接",
PolishMode::Raw,
&[],
&[],
ChineseScriptPreference::Auto,
None,
)
.await
.map(|_| ())
.map_err(|e| match e {
Expand Down
94 changes: 87 additions & 7 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use std::sync::Arc;
use std::time::Instant;

use chrono::Utc;
use ferrous_opencc::{config::BuiltinConfig, OpenCC};
use parking_lot::Mutex;
use tauri::{async_runtime, AppHandle, Emitter, Manager};
use uuid::Uuid;
Expand All @@ -30,8 +31,8 @@ use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor};
use crate::recorder::{Recorder, RecorderError};
use crate::selection::{capture_selection, SelectionContext};
use crate::types::{
CapsulePayload, CapsuleState, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus,
HotkeyStatusState, InsertStatus, PolishMode,
CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability,
HotkeyMode, HotkeyStatus, HotkeyStatusState, InsertStatus, PolishMode,
};
#[cfg(target_os = "windows")]
use crate::windows_ime_ipc::ImeSubmitTarget;
Expand Down Expand Up @@ -473,7 +474,9 @@ 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;
let prefs = self.inner.prefs.get();
let working_languages = prefs.working_languages;
let chinese_script_preference = prefs.chinese_script_preference;
// repolish 是历史记录里手动重新润色,不再绑定原 session 的前台 app;
// 当下用户调起的 app 才是相关上下文(如果可拿)。
let front_app = capture_frontmost_app();
Expand All @@ -482,6 +485,7 @@ impl Coordinator {
mode,
&hotwords,
&working_languages,
chinese_script_preference,
front_app.as_deref(),
)
.await
Expand Down Expand Up @@ -1640,6 +1644,7 @@ 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 chinese_script_preference = prefs.chinese_script_preference;
let front_app = inner.state.lock().front_app.clone();
let translation_target = prefs.translation_target_language.trim().to_string();
let translation_active =
Expand All @@ -1655,6 +1660,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
&raw,
&translation_target,
&working_languages,
chinese_script_preference,
front_app.as_deref(),
)
.await
Expand All @@ -1664,11 +1670,26 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
mode,
&hotword_strs,
&working_languages,
chinese_script_preference,
front_app.as_deref(),
)
.await
};

// 仅在“ASR 直出文本”场景做强制简繁收敛,避免误伤成功的翻译/常规 LLM 输出:
// - 非翻译模式:mode=Raw(本来就不走润色)或润色失败回退 raw
// - 翻译模式:仅翻译失败回退 raw 时才收敛
let should_force_script = if translation_active {
polish_error.is_some()
} else {
mode == PolishMode::Raw || polish_error.is_some()
};
let polished = if should_force_script {
apply_chinese_script_preference(&polished, chinese_script_preference)
} else {
polished
};

// 原子化最后一次 cancel 检查 + 转 Inserting:
// 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting,
// cancel_session 就拒绝介入(Cmd+V 已发出,撤销不掉)。这是 audit HIGH #2 的修复,
Expand Down Expand Up @@ -2154,6 +2175,27 @@ fn is_whisper_compatible_provider(id: &str) -> bool {
matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq")
}

fn apply_chinese_script_preference(text: &str, pref: ChineseScriptPreference) -> String {
if text.is_empty() {
return String::new();
}
let config = match pref {
ChineseScriptPreference::Simplified => Some(BuiltinConfig::T2s),
ChineseScriptPreference::Traditional => Some(BuiltinConfig::S2t),
ChineseScriptPreference::Auto => None,
};
let Some(config) = config else {
return text.to_string();
};
match OpenCC::from_config(config) {
Ok(converter) => converter.convert(text),
Err(err) => {
log::warn!("[coord] OpenCC init failed, skip script conversion: {err}");
text.to_string()
}
}
}

/// QA 路径专用:begin_qa_session 永远走 Volcengine 流式(低延迟要求),所以
/// 凭据校验也只看 Volcengine 字段,不依赖 active_asr。dictation 路径请用
/// `ensure_asr_credentials`。
Expand All @@ -2173,12 +2215,22 @@ async fn polish_or_passthrough(
mode: PolishMode,
hotwords: &[String],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
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, front_app).await {
match polish_text(
&raw.text,
mode,
hotwords,
working_languages,
chinese_script_preference,
front_app,
)
.await
{
Ok(s) => (s, None),
Err(e) => {
let reason = e.to_string();
Expand All @@ -2193,6 +2245,7 @@ async fn polish_text(
mode: PolishMode,
hotwords: &[String],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> anyhow::Result<String> {
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
Expand All @@ -2208,7 +2261,14 @@ async fn polish_text(
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, front_app)
.polish(
raw,
mode,
hotwords,
working_languages,
chinese_script_preference,
front_app,
)
.await?)
}

Expand All @@ -2217,9 +2277,18 @@ async fn translate_or_passthrough(
raw: &RawTranscript,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> (String, Option<String>) {
match translate_text(&raw.text, target_language, working_languages, front_app).await {
match translate_text(
&raw.text,
target_language,
working_languages,
chinese_script_preference,
front_app,
)
.await
{
Ok(s) => (s, None),
Err(e) => {
let reason = e.to_string();
Expand All @@ -2233,6 +2302,7 @@ async fn translate_text(
raw: &str,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> anyhow::Result<String> {
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
Expand All @@ -2248,7 +2318,13 @@ 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, front_app)
.translate_to(
raw,
target_language,
working_languages,
chinese_script_preference,
front_app,
)
.await?)
}

Expand Down Expand Up @@ -2574,6 +2650,7 @@ async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {

let prefs = inner.prefs.get();
let working_languages = prefs.working_languages.clone();
let chinese_script_preference = prefs.chinese_script_preference;
let (messages_for_llm, front_app) = {
let st = inner.qa_state.lock();
(st.messages.clone(), st.front_app.clone())
Expand Down Expand Up @@ -2612,6 +2689,7 @@ async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
let answer = match answer_chat_dispatch(
&messages_for_llm,
&working_languages,
chinese_script_preference,
front_app.as_deref(),
on_delta,
should_cancel,
Expand Down Expand Up @@ -2759,6 +2837,7 @@ fn cancel_qa_session(inner: &Arc<Inner>) {
async fn answer_chat_dispatch<F, C>(
messages: &[crate::types::QaChatMessage],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
on_delta: F,
should_cancel: C,
Expand All @@ -2782,6 +2861,7 @@ where
.answer_chat_streaming(
messages,
working_languages,
chinese_script_preference,
front_app,
on_delta,
should_cancel,
Expand Down
49 changes: 42 additions & 7 deletions openless-all/app/src-tauri/src/polish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::time::Duration;
use serde_json::{json, Value};
use thiserror::Error;

use crate::types::{PolishMode, QaChatMessage};
use crate::types::{ChineseScriptPreference, PolishMode, QaChatMessage};

const DEFAULT_TEMPERATURE: f32 = 0.3;
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
Expand Down Expand Up @@ -90,10 +90,13 @@ impl OpenAICompatibleLLMProvider {
mode: PolishMode,
hotwords: &[String],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> Result<String, LLMError> {
let mut system_prompt = compose_system_prompt(mode, hotwords);
if let Some(premise) = context_premise(working_languages, front_app) {
if let Some(premise) =
context_premise(working_languages, chinese_script_preference, front_app)
{
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
let user_prompt = prompts::user_prompt(raw_text);
Expand All @@ -108,6 +111,7 @@ impl OpenAICompatibleLLMProvider {
&self,
messages: &[QaChatMessage],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
on_delta: F,
should_cancel: C,
Expand All @@ -117,7 +121,9 @@ impl OpenAICompatibleLLMProvider {
C: Fn() -> bool + Send + Sync,
{
let mut system_prompt = prompts::qa_system_prompt();
if let Some(premise) = context_premise(working_languages, front_app) {
if let Some(premise) =
context_premise(working_languages, chinese_script_preference, front_app)
{
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
self.chat_completion_history_streaming(&system_prompt, messages, on_delta, should_cancel)
Expand All @@ -131,10 +137,13 @@ impl OpenAICompatibleLLMProvider {
raw_text: &str,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> Result<String, LLMError> {
let mut system_prompt = prompts::translate_system_prompt(target_language);
if let Some(premise) = context_premise(working_languages, front_app) {
if let Some(premise) =
context_premise(working_languages, chinese_script_preference, front_app)
{
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
let user_prompt = prompts::user_prompt(raw_text);
Expand Down Expand Up @@ -378,15 +387,31 @@ fn chat_completions_url(base_url: &str) -> String {
/// 当前前台应用:…(请按这个 app 的常见沟通风格调整语气)
///
/// 两个字段都空时返回 None,调用方就不拼前缀。详见 issue #4 / #116。
fn context_premise(working_languages: &[String], front_app: Option<&str>) -> Option<String> {
fn context_premise(
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> Option<String> {
let langs: Vec<&str> = working_languages
.iter()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let app = front_app.map(str::trim).filter(|s| !s.is_empty());

if langs.is_empty() && app.is_none() {
let script_line = match chinese_script_preference {
ChineseScriptPreference::Simplified => Some(
"中文输出偏好:简体中文。若最终输出包含中文,请统一使用简体字形(不要混用繁体)。"
.to_string(),
),
ChineseScriptPreference::Traditional => Some(
"中文输出偏好:繁体中文。若最终输出包含中文,请统一使用繁体字形(不要混用简体)。"
.to_string(),
),
ChineseScriptPreference::Auto => None,
};

if langs.is_empty() && app.is_none() && script_line.is_none() {
return None;
}

Expand All @@ -402,6 +427,9 @@ fn context_premise(working_languages: &[String], front_app: Option<&str>) -> Opt
"当前前台应用:{name}。请按这个应用的常见沟通风格调整语气——例如邮件类 app 偏正式、聊天类 app 偏口语、IDE / 文档类 app 偏技术或结构化。\u{4E0D}主动加入与用户原意无关的客套话。"
));
}
if let Some(line) = script_line {
lines.push(line);
}
Some(lines.join("\n"))
}

Expand Down Expand Up @@ -982,7 +1010,14 @@ mod tests {
));

let output = provider
.polish("原文", PolishMode::Raw, &[], &[], None)
.polish(
"原文",
PolishMode::Raw,
&[],
&[],
ChineseScriptPreference::Auto,
None,
)
.await
.unwrap();
assert_eq!(output, "最终文本。");
Expand Down
Loading
Loading