Keep Chinese output script aligned with the selected UI locale
This change binds locale selection to a persisted chineseScriptPreference so zh-CN and zh-TW drive output script consistently. It injects script preference into LLM context for polish/translate/QA, removes ASR language-hint submission, and applies script conversion only in pure-ASR fallback paths. It also hardens preference persistence sequencing in HotkeySettingsContext to reduce write-order races. Constraint: Must keep output script behavior coupled to locale choice without adding a new toggle Constraint: ASR requests should not send language parameters Rejected: Add an independent output-script option | Conflicts with required locale-driven behavior Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep zh-CN=>simplified and zh-TW=>traditional mapping consistent in both Settings entry points and backend consumers Tested: npm run -s build (openless-all/app) Tested: cargo test -q (openless-all/app/src-tauri) Not-tested: Live external provider E2E verification across multiple real ASR/LLM vendors Related: #242 Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
1 parent
c85b1bd4c6
commit
1075ba463f
12 files changed
+562
-453
No files matched your search
Generated
+328
-427
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
|
||||
@@ -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>>;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -482,6 +485,7 @@ impl Coordinator {
|
||||
mode,
|
||||
&hotwords,
|
||||
&working_languages,
|
||||
chinese_script_preference,
|
||||
front_app.as_deref(),
|
||||
)
|
||||
.await
|
||||
@@ -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 =
|
||||
@@ -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
|
||||
@@ -1664,11 +1670,21 @@ 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:本来就不走润色
|
||||
// - polish_error.is_some():润色/翻译失败后回退到原始 ASR 文本
|
||||
let polished = if !translation_active && (mode == PolishMode::Raw || polish_error.is_some()) {
|
||||
apply_chinese_script_preference(&polished, chinese_script_preference)
|
||||
} else {
|
||||
polished
|
||||
};
|
||||
|
||||
// 原子化最后一次 cancel 检查 + 转 Inserting:
|
||||
// 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting,
|
||||
// cancel_session 就拒绝介入(Cmd+V 已发出,撤销不掉)。这是 audit HIGH #2 的修复,
|
||||
@@ -2154,6 +2170,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`。
|
||||
@@ -2173,12 +2210,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();
|
||||
@@ -2193,6 +2240,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();
|
||||
@@ -2208,7 +2256,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?)
|
||||
}
|
||||
|
||||
@@ -2217,9 +2272,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();
|
||||
@@ -2233,6 +2297,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();
|
||||
@@ -2248,7 +2313,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?)
|
||||
}
|
||||
|
||||
@@ -2574,6 +2645,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())
|
||||
@@ -2612,6 +2684,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,
|
||||
@@ -2759,6 +2832,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,
|
||||
@@ -2782,6 +2856,7 @@ where
|
||||
.answer_chat_streaming(
|
||||
messages,
|
||||
working_languages,
|
||||
chinese_script_preference,
|
||||
front_app,
|
||||
on_delta,
|
||||
should_cancel,
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
@@ -378,7 +387,11 @@ 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())
|
||||
@@ -386,7 +399,19 @@ fn context_premise(working_languages: &[String], front_app: Option<&str>) -> Opt
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -982,7 +1010,14 @@ mod tests {
|
||||
));
|
||||
|
||||
let output = provider
|
||||
.polish("原文", PolishMode::Raw, &[], &[], None)
|
||||
.polish(
|
||||
"原文",
|
||||
PolishMode::Raw,
|
||||
&[],
|
||||
&[],
|
||||
ChineseScriptPreference::Auto,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output, "最终文本。");
|
||||
|
||||
@@ -24,6 +24,15 @@ impl PolishMode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChineseScriptPreference {
|
||||
#[default]
|
||||
Auto,
|
||||
Simplified,
|
||||
Traditional,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum InsertStatus {
|
||||
@@ -114,6 +123,14 @@ pub struct UserPreferences {
|
||||
/// 由前端从内置语言列表中选择,后端只接收最终的原生名字符串拼进 prompt。详见 issue #4。
|
||||
#[serde(default)]
|
||||
pub translation_target_language: String,
|
||||
/// 中文输出字形偏好(不额外暴露为 UI 开关):
|
||||
/// - Simplified: 中文输出优先简体
|
||||
/// - Traditional: 中文输出优先繁体
|
||||
/// - Auto: 不额外约束
|
||||
///
|
||||
/// 由前端「界面语言」选择同步驱动(简体/繁体),详见 issue #259。
|
||||
#[serde(default)]
|
||||
pub chinese_script_preference: ChineseScriptPreference,
|
||||
/// 划词语音问答(QA)的全局快捷键。`None` = 关闭功能;`Some(...)` 时
|
||||
/// coordinator 用 global-hotkey crate 注册组合键(modifier + 主键)。
|
||||
/// 默认 Cmd+Shift+; (macOS) / Ctrl+Shift+; (Windows)。详见 issue #118。
|
||||
@@ -176,6 +193,7 @@ impl Default for UserPreferences {
|
||||
allow_non_tsf_insertion_fallback: true,
|
||||
working_languages: default_working_languages(),
|
||||
translation_target_language: String::new(),
|
||||
chinese_script_preference: ChineseScriptPreference::Auto,
|
||||
qa_hotkey: default_qa_hotkey(),
|
||||
qa_save_history: false,
|
||||
local_asr_active_model: default_local_asr_model(),
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
setLocalePreference,
|
||||
type SupportedLocale,
|
||||
} from '../i18n';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import type { OS } from './WindowChrome';
|
||||
|
||||
interface SettingsModalProps {
|
||||
@@ -311,11 +312,17 @@ const btnGhost: CSSProperties = {
|
||||
// 真正可用的语言切换器 —— 用原生 <select>,与 Settings → Language 分区共享同一份 localStorage 偏好。
|
||||
function LanguagePicker() {
|
||||
const { t } = useTranslation();
|
||||
const { prefs, updatePrefs } = useHotkeySettings();
|
||||
const [pref, setPref] = useState<SupportedLocale | typeof FOLLOW_SYSTEM>(getLocalePreference());
|
||||
|
||||
const apply = async (next: SupportedLocale | typeof FOLLOW_SYSTEM) => {
|
||||
setPref(next);
|
||||
await setLocalePreference(next);
|
||||
const resolved = await setLocalePreference(next);
|
||||
if (!prefs) return;
|
||||
const chineseScriptPreference =
|
||||
resolved === 'zh-CN' ? 'simplified' : resolved === 'zh-TW' ? 'traditional' : 'auto';
|
||||
if (prefs.chineseScriptPreference === chineseScriptPreference) return;
|
||||
await updatePrefs({ ...prefs, chineseScriptPreference });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,6 +30,11 @@ function detectSystemLocale(): SupportedLocale {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
function resolveLocalePreference(pref: SupportedLocale | typeof FOLLOW_SYSTEM_VALUE): SupportedLocale {
|
||||
if (pref === FOLLOW_SYSTEM_VALUE) return detectSystemLocale();
|
||||
return pref;
|
||||
}
|
||||
|
||||
function getStoredLocale(): SupportedLocale | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
@@ -66,14 +71,17 @@ export function getLocalePreference(): SupportedLocale | typeof FOLLOW_SYSTEM_VA
|
||||
* 写入用户偏好并立即切换 i18n 语言。
|
||||
* pref === 'system' 时清除存储项,重新走 navigator 检测。
|
||||
*/
|
||||
export async function setLocalePreference(pref: SupportedLocale | typeof FOLLOW_SYSTEM_VALUE): Promise<void> {
|
||||
export async function setLocalePreference(
|
||||
pref: SupportedLocale | typeof FOLLOW_SYSTEM_VALUE,
|
||||
): Promise<SupportedLocale> {
|
||||
const resolved = resolveLocalePreference(pref);
|
||||
if (pref === FOLLOW_SYSTEM_VALUE) {
|
||||
window.localStorage.removeItem(LOCALE_STORAGE_KEY);
|
||||
await i18n.changeLanguage(detectSystemLocale());
|
||||
return;
|
||||
} else {
|
||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, pref);
|
||||
}
|
||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, pref);
|
||||
await i18n.changeLanguage(pref);
|
||||
await i18n.changeLanguage(resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export const FOLLOW_SYSTEM = FOLLOW_SYSTEM_VALUE;
|
||||
@@ -51,6 +51,7 @@ const mockSettings: UserPreferences = {
|
||||
allowNonTsfInsertionFallback: true,
|
||||
workingLanguages: ['简体中文'],
|
||||
translationTargetLanguage: '',
|
||||
chineseScriptPreference: 'auto',
|
||||
qaHotkey: { primary: ';', modifiers: ['cmd', 'shift'] },
|
||||
qaSaveHistory: false,
|
||||
localAsrActiveModel: 'qwen3-asr-0.6b',
|
||||
|
||||
@@ -120,6 +120,8 @@ export interface UserPreferences {
|
||||
workingLanguages: string[];
|
||||
/** 翻译模式目标语言(单选,原生名);空串 = 不启用 Shift 翻译。详见 issue #4。 */
|
||||
translationTargetLanguage: string;
|
||||
/** 中文输出字形偏好:由界面语言(简/繁)自动同步,不单独暴露设置项。 */
|
||||
chineseScriptPreference: 'auto' | 'simplified' | 'traditional';
|
||||
/** 划词语音问答快捷键。null = 未启用。详见 issue #118。 */
|
||||
qaHotkey: QaHotkeyBinding | null;
|
||||
/** 是否把 Q&A 历史写到本地存档。详见 issue #118。 */
|
||||
|
||||
@@ -1092,11 +1092,17 @@ function PermissionPill({ status }: { status: PermissionStatus | 'loading' }) {
|
||||
|
||||
function LanguageSection() {
|
||||
const { t } = useTranslation();
|
||||
const { prefs, updatePrefs } = useHotkeySettings();
|
||||
const [pref, setPref] = useState<SupportedLocale | typeof FOLLOW_SYSTEM>(getLocalePreference());
|
||||
|
||||
const apply = async (next: SupportedLocale | typeof FOLLOW_SYSTEM) => {
|
||||
setPref(next);
|
||||
await setLocalePreference(next);
|
||||
const resolved = await setLocalePreference(next);
|
||||
if (!prefs) return;
|
||||
const chineseScriptPreference =
|
||||
resolved === 'zh-CN' ? 'simplified' : resolved === 'zh-TW' ? 'traditional' : 'auto';
|
||||
if (prefs.chineseScriptPreference === chineseScriptPreference) return;
|
||||
await updatePrefs({ ...prefs, chineseScriptPreference });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { getHotkeyCapability, getSettings, setSettings } from '../lib/ipc';
|
||||
import type { HotkeyBinding, HotkeyCapability, UserPreferences } from '../lib/types';
|
||||
import i18n from '../i18n';
|
||||
|
||||
interface HotkeySettingsContextValue {
|
||||
prefs: UserPreferences | null;
|
||||
@@ -25,6 +27,9 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [prefs, setPrefs] = useState<UserPreferences | null>(null);
|
||||
const [capability, setCapability] = useState<HotkeyCapability | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const syncSeqRef = useRef(0);
|
||||
const persistQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const latestPrefsRef = useRef<UserPreferences | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [nextPrefs, nextCapability] = await Promise.all([getSettings(), getHotkeyCapability()]);
|
||||
@@ -33,14 +38,56 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const queueSetSettings = useCallback((next: UserPreferences) => {
|
||||
const task = persistQueueRef.current
|
||||
.catch(() => undefined)
|
||||
.then(() => setSettings(next));
|
||||
persistQueueRef.current = task;
|
||||
return task;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
latestPrefsRef.current = prefs;
|
||||
}, [prefs]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentPrefs = latestPrefsRef.current;
|
||||
if (!currentPrefs) return;
|
||||
const lang = (i18n.resolvedLanguage || i18n.language || '').toLowerCase();
|
||||
const nextScript: UserPreferences['chineseScriptPreference'] =
|
||||
lang.startsWith('zh-tw') || lang.includes('hant')
|
||||
? 'traditional'
|
||||
: lang.startsWith('zh-cn') || lang.startsWith('zh')
|
||||
? 'simplified'
|
||||
: 'auto';
|
||||
if (currentPrefs.chineseScriptPreference === nextScript) return;
|
||||
const previousScript = currentPrefs.chineseScriptPreference;
|
||||
const next = { ...currentPrefs, chineseScriptPreference: nextScript };
|
||||
const seq = ++syncSeqRef.current;
|
||||
void queueSetSettings(next)
|
||||
.then(() => {
|
||||
setPrefs(current => {
|
||||
if (!current || syncSeqRef.current !== seq) return current;
|
||||
if (current.chineseScriptPreference !== previousScript) return current;
|
||||
return { ...current, chineseScriptPreference: nextScript };
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
if (syncSeqRef.current === seq) {
|
||||
console.warn('[settings] sync chineseScriptPreference failed', error);
|
||||
}
|
||||
});
|
||||
}, [prefs, queueSetSettings]);
|
||||
|
||||
const updatePrefs = useCallback(async (next: UserPreferences) => {
|
||||
setPrefs(next);
|
||||
await setSettings(next);
|
||||
}, []);
|
||||
latestPrefsRef.current = next;
|
||||
await queueSetSettings(next);
|
||||
}, [queueSetSettings]);
|
||||
|
||||
const value = useMemo<HotkeySettingsContextValue>(
|
||||
() => ({
|
||||
|
||||
Reference in new issue
Block a user