Skip to content
Open
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
151 changes: 148 additions & 3 deletions openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVaul
use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
use crate::recorder::{AudioConsumer, Recorder};
use crate::types::{
ChineseScriptPreference, ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry,
HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding,
UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus,
AppModeOverride, ChineseScriptPreference, ComboBinding, CredentialsStatus, CustomMode,
DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference,
PolishMode, ShortcutBinding, UpdateChannel, UserPreferences, VocabPresetStore,
WindowsImeStatus,
};

type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
Expand Down Expand Up @@ -590,6 +591,7 @@ async fn validate_llm_provider() -> Result<(), String> {
OutputLanguagePreference::Auto,
None,
&[],
None,
)
.await
.map(|_| ())
Expand Down Expand Up @@ -926,6 +928,12 @@ pub fn set_default_polish_mode(
mode: PolishMode,
) -> Result<(), String> {
let mut prefs = coord.prefs().get();
// Custom mode の場合は id が custom_modes に存在することを検証
if let PolishMode::Custom(id) = &mode {
if !prefs.custom_modes.iter().any(|m| m.id == *id) {
return Err(format!("custom mode id '{id}' not found"));
}
}
prefs.default_mode = mode;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
Expand All @@ -937,7 +945,13 @@ pub fn set_style_enabled(
enabled: bool,
) -> Result<(), String> {
let mut prefs = coord.prefs().get();
// Custom mode を有効化する場合は id が custom_modes に存在することを検証
if enabled {
if let PolishMode::Custom(id) = &mode {
if !prefs.custom_modes.iter().any(|m| m.id == *id) {
return Err(format!("custom mode id '{id}' not found"));
}
}
if !prefs.enabled_modes.contains(&mode) {
prefs.enabled_modes.push(mode);
}
Expand Down Expand Up @@ -1152,6 +1166,137 @@ pub fn set_translation_hotkey(
Ok(())
}

// ─────────────────────────── ビルトインprompt表示 + カスタムスタイル CRUD ───────────────────────────
//
// `get_default_polish_prompt` はビルトイン4 mode(raw/light/structured/formal)の既定 prompt を返す。
// Settings → Style ページで「prompt を見る」を押した時の参考表示用。Custom mode は対象外。
//
// CustomMode 系コマンド:
// - `add_custom_mode(id, name, prompt)` — 末尾に追加。重複 id はエラー。
// - `update_custom_mode(id, name, prompt)` — 既存 id の name/prompt を更新。
// - `delete_custom_mode(id)` — 削除 + enabled_modes / default_mode のフォールバック。

#[tauri::command]
pub fn get_default_polish_prompt(mode: String) -> Result<String, String> {
let parsed: PolishMode = mode
.clone()
.try_into()
.map_err(|e: String| e)?;
if let PolishMode::Custom(_) = parsed {
return Err("get_default_polish_prompt is for builtin modes only".to_string());
}
Ok(crate::polish::prompts::system_prompt(parsed, None))
}

#[tauri::command]
pub fn add_custom_mode(
coord: CoordinatorState<'_>,
app: AppHandle,
id: String,
name: String,
prompt: String,
) -> Result<(), String> {
let id_trim = id.trim().to_string();
if id_trim.is_empty() {
return Err("custom mode id is empty".to_string());
}
if id_trim.contains(':') {
return Err("custom mode id cannot contain ':'".to_string());
}
let mut prefs = coord.prefs().get();
if prefs.custom_modes.iter().any(|m| m.id == id_trim) {
return Err(format!("custom mode id '{id_trim}' already exists"));
}
prefs.custom_modes.push(CustomMode {
id: id_trim,
name,
prompt,
});
persist_settings(&*coord, prefs.clone())?;
let _ = app.emit("prefs:changed", &prefs);
Ok(())
}

#[tauri::command]
pub fn update_custom_mode(
coord: CoordinatorState<'_>,
app: AppHandle,
id: String,
name: String,
prompt: String,
) -> Result<(), String> {
let mut prefs = coord.prefs().get();
let entry = prefs
.custom_modes
.iter_mut()
.find(|m| m.id == id)
.ok_or_else(|| format!("custom mode id '{id}' not found"))?;
entry.name = name;
entry.prompt = prompt;
persist_settings(&*coord, prefs.clone())?;
let _ = app.emit("prefs:changed", &prefs);
Ok(())
}

#[tauri::command]
pub fn delete_custom_mode(
coord: CoordinatorState<'_>,
app: AppHandle,
id: String,
) -> Result<(), String> {
let mut prefs = coord.prefs().get();
let before = prefs.custom_modes.len();
prefs.custom_modes.retain(|m| m.id != id);
if prefs.custom_modes.len() == before {
return Err(format!("custom mode id '{id}' not found"));
}
// default_mode が消したカスタムmodeを参照していたら Light にフォールバック
if matches!(&prefs.default_mode, PolishMode::Custom(cur_id) if *cur_id == id) {
prefs.default_mode = PolishMode::Light;
}
// enabled_modes から該当 Custom を除去
prefs.enabled_modes.retain(|m| !matches!(m, PolishMode::Custom(cur_id) if *cur_id == id));
persist_settings(&*coord, prefs.clone())?;
let _ = app.emit("prefs:changed", &prefs);
Ok(())
}

// ─────────────────────────── アプリ別自動 mode 切替 ───────────────────────────
//
// `set_app_mode_overrides(overrides)` — 一括置換。フロントは編集中の配列丸ごとを
// 送ってくる前提。各 override の `mode` が `Custom(id)` なら `custom_modes` に
// 該当 id が存在することを検証してから保存する(dangling 参照を弾く)。
// 空 `app_pattern`(trim 後)はそのまま保存可(UI 側で「未入力行」を持ったまま
// 別行を編集するケースを許容したいため)。`pick_mode_for_app` は空パターンを
// スキップするため実害は無い。

#[tauri::command]
pub fn set_app_mode_overrides(
coord: CoordinatorState<'_>,
app: AppHandle,
overrides: Vec<AppModeOverride>,
) -> Result<(), String> {
let mut prefs = coord.prefs().get();
for ov in &overrides {
if let PolishMode::Custom(id) = &ov.mode {
if !prefs.custom_modes.iter().any(|m| m.id == *id) {
return Err(format!(
"app_mode_override references unknown custom mode id '{id}'"
));
}
}
}
prefs.app_mode_overrides = overrides;
persist_settings(&*coord, prefs.clone())?;
let _ = app.emit("prefs:changed", &prefs);
Ok(())
}

#[tauri::command]
pub fn get_app_mode_overrides(coord: CoordinatorState<'_>) -> Vec<AppModeOverride> {
coord.prefs().get().app_mode_overrides
}

#[tauri::command]
pub fn set_switch_style_hotkey(
coord: CoordinatorState<'_>,
Expand Down
95 changes: 77 additions & 18 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,18 @@ impl Coordinator {

pub fn try_update_translation_hotkey_binding(&self) -> Result<(), String> {
let prefs = self.inner.prefs.get();
// Translation feature is gated by `translation_target_language` being
// non-empty. When it's empty we treat translation as fully off and
// tear down any registered hotkey, so a stray modifier press cannot
// flash the translation overlay or run a translate pipeline.
if prefs.translation_target_language.trim().is_empty() {
take_translation_hotkey_on_main_thread(&self.inner);
self.update_modifier_shortcut_bindings();
log::info!(
"[coord] translation_target_language is empty; translation hotkey not registered"
);
return Ok(());
}
if is_builtin_translation_shift(&prefs.translation_hotkey)
|| crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey).is_some()
{
Expand Down Expand Up @@ -845,6 +857,16 @@ impl Coordinator {
let working_languages = prefs.working_languages;
let chinese_script_preference = prefs.chinese_script_preference;
let output_language_preference = prefs.output_language_preference;
// Custom mode のときだけ custom_modes から prompt を引いてくる。
// 既存4 mode ではハードコード prompt(system_prompt 側でハンドリング)。
let prompt_override_owned: Option<String> = match &mode {
PolishMode::Custom(id) => prefs
.custom_modes
.iter()
.find(|m| m.id == *id)
.map(|m| m.prompt.clone()),
_ => None,
};
// repolish 是历史记录里手动重新润色,不再绑定原 session 的前台 app;
// 当下用户调起的 app 才是相关上下文(如果可拿)。
let front_app = capture_frontmost_app();
Expand All @@ -859,6 +881,7 @@ impl Coordinator {
output_language_preference,
front_app.as_deref(),
&[],
prompt_override_owned.as_deref(),
)
.await
.map_err(|e| e.to_string())
Expand Down Expand Up @@ -1146,7 +1169,18 @@ fn combo_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<ComboHotkeyEve
fn translation_hotkey_supervisor_loop(inner: Arc<Inner>) {
let mut attempts: u32 = 0;
loop {
let binding = inner.prefs.get().translation_hotkey;
let prefs_snapshot = inner.prefs.get();
let binding = prefs_snapshot.translation_hotkey.clone();
// If the user has not picked a target language, treat translation as
// off and don't keep a hotkey registered — even if a binding is
// configured. Without this, a stray combo press flashes the overlay
// and starts an empty-target translate that the pipeline then
// immediately drops.
if prefs_snapshot.translation_target_language.trim().is_empty() {
take_translation_hotkey_on_main_thread(&inner);
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
if is_builtin_translation_shift(&binding)
|| crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some()
{
Expand Down Expand Up @@ -1346,16 +1380,9 @@ fn handle_action_hotkey_pressed(inner: &Arc<Inner>, kind: ActionHotkeyKind) {

fn switch_to_previous_style(inner: &Arc<Inner>) {
let mut prefs = inner.prefs.get();
let order = [
PolishMode::Raw,
PolishMode::Light,
PolishMode::Structured,
PolishMode::Formal,
];
let enabled: Vec<PolishMode> = order
.into_iter()
.filter(|mode| prefs.enabled_modes.contains(mode))
.collect();
// 既存4 mode + Custom mode を含む、enabled_modes の順番をそのまま使う。
// Custom mode は `custom_modes` の登録順に enabled_modes へ並ぶ前提。
let enabled: Vec<PolishMode> = prefs.enabled_modes.clone();
if enabled.len() <= 1 {
log::info!("[coord] switch style hotkey ignored: enabled style count <= 1");
return;
Expand All @@ -1369,14 +1396,14 @@ fn switch_to_previous_style(inner: &Arc<Inner>) {
} else {
current_index - 1
};
prefs.default_mode = enabled[next_index];
prefs.default_mode = enabled[next_index].clone();
let next_label = prefs
.default_mode
.display_name_with_customs(&prefs.custom_modes);
if let Err(e) = inner.prefs.set(prefs.clone()) {
log::warn!("[coord] switch style hotkey 保存失败: {e}");
} else {
log::info!(
"[coord] switch style hotkey changed default mode to {}",
prefs.default_mode.display_name()
);
log::info!("[coord] switch style hotkey changed default mode to {next_label}");
}
}

Expand Down Expand Up @@ -2701,12 +2728,27 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None);

let prefs = inner.prefs.get();
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 output_language_preference = prefs.output_language_preference;
let front_app = inner.state.lock().front_app.clone();
// アプリ別自動 mode 切替:app_mode_overrides を順次評価して最初にマッチした mode を採用。
// どのルールにもマッチしないなら default_mode(既存挙動)。
let mode = match crate::types::pick_mode_for_app(
front_app.as_deref(),
&prefs.app_mode_overrides,
) {
Some(m) => {
log::info!(
"[coord] app override matched: front_app={:?} → mode={}",
front_app,
m.as_serde_string()
);
m
}
None => prefs.default_mode.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();
Expand Down Expand Up @@ -2754,15 +2796,26 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
)
.await
} else {
// Custom mode のときだけ custom_modes から prompt 本文を引いてくる。
// 既存4 mode ではハードコード prompt を使うため None を渡す。
let prompt_override_owned: Option<String> = match &mode {
PolishMode::Custom(id) => prefs
.custom_modes
.iter()
.find(|m| m.id == *id)
.map(|m| m.prompt.clone()),
_ => None,
};
polish_or_passthrough(
&raw,
mode,
mode.clone(),
&hotword_strs,
&working_languages,
chinese_script_preference,
output_language_preference,
front_app.as_deref(),
&prior_turns,
prompt_override_owned.as_deref(),
)
.await
};
Expand Down Expand Up @@ -3355,6 +3408,7 @@ fn ensure_qa_volcengine_credentials() -> Result<(), String> {

/// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。
/// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。
#[allow(clippy::too_many_arguments)]
async fn polish_or_passthrough(
raw: &RawTranscript,
mode: PolishMode,
Expand All @@ -3364,6 +3418,7 @@ async fn polish_or_passthrough(
output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
prior_turns: &[(String, String)],
prompt_override: Option<&str>,
) -> (String, Option<String>) {
if mode == PolishMode::Raw {
return (raw.text.clone(), None);
Expand All @@ -3377,6 +3432,7 @@ async fn polish_or_passthrough(
output_language_preference,
front_app,
prior_turns,
prompt_override,
)
.await
{
Expand All @@ -3389,6 +3445,7 @@ async fn polish_or_passthrough(
}
}

#[allow(clippy::too_many_arguments)]
async fn polish_text(
raw: &str,
mode: PolishMode,
Expand All @@ -3398,6 +3455,7 @@ async fn polish_text(
output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
prior_turns: &[(String, String)],
prompt_override: Option<&str>,
) -> anyhow::Result<String> {
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
Expand All @@ -3421,6 +3479,7 @@ async fn polish_text(
output_language_preference,
front_app,
prior_turns,
prompt_override,
)
.await?)
}
Expand Down
Loading
Loading