feat: add optional hotkey for post-processing request (#355)

* feat: add post-processing hotkey

* rewrite

* bring back toggle

* Make sure the toggle on and off propagates for the key binding.

* rename for clarity

* cleanup

---------

Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
Sam 2026-02-06 19:28:14 +11:00 committed by GitHub
parent 81b90aa675
commit 462b03b024
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 148 additions and 72 deletions

View file

@ -25,16 +25,11 @@ pub trait ShortcutAction: Send + Sync {
}
// Transcribe Action
struct TranscribeAction;
async fn maybe_post_process_transcription(
settings: &AppSettings,
transcription: &str,
) -> Option<String> {
if !settings.post_process_enabled {
return None;
}
struct TranscribeAction {
post_process: bool,
}
async fn post_process_transcription(settings: &AppSettings, transcription: &str) -> Option<String> {
let provider = match settings.active_post_process_provider().cloned() {
Some(provider) => provider,
None => {
@ -305,6 +300,7 @@ impl ShortcutAction for TranscribeAction {
play_feedback_sound(app, SoundType::Stop);
let binding_id = binding_id.to_string(); // Clone binding_id for the async task
let post_process = self.post_process;
tauri::async_runtime::spawn(async move {
let binding_id = binding_id.clone(); // Clone for the inner async task
@ -343,11 +339,14 @@ impl ShortcutAction for TranscribeAction {
final_text = converted_text;
}
// Then apply regular post-processing if enabled
// Then apply LLM post-processing if this is the post-process hotkey
// Uses final_text which may already have Chinese conversion applied
if let Some(processed_text) =
maybe_post_process_transcription(&settings, &final_text).await
{
let processed = if post_process {
post_process_transcription(&settings, &final_text).await
} else {
None
};
if let Some(processed_text) = processed {
post_processed_text = Some(processed_text.clone());
final_text = processed_text;
@ -474,7 +473,13 @@ pub static ACTION_MAP: Lazy<HashMap<String, Arc<dyn ShortcutAction>>> = Lazy::ne
let mut map = HashMap::new();
map.insert(
"transcribe".to_string(),
Arc::new(TranscribeAction) as Arc<dyn ShortcutAction>,
Arc::new(TranscribeAction {
post_process: false,
}) as Arc<dyn ShortcutAction>,
);
map.insert(
"transcribe_with_post_process".to_string(),
Arc::new(TranscribeAction { post_process: true }) as Arc<dyn ShortcutAction>,
);
map.insert(
"cancel".to_string(),

View file

@ -562,6 +562,17 @@ pub fn get_default_settings() -> AppSettings {
current_binding: default_shortcut.to_string(),
},
);
bindings.insert(
"transcribe_with_post_process".to_string(),
ShortcutBinding {
id: "transcribe_with_post_process".to_string(),
name: "Transcribe with Post-Processing".to_string(),
description: "Converts your speech into text and applies AI post-processing."
.to_string(),
default_binding: String::new(),
current_binding: String::new(),
},
);
bindings.insert(
"cancel".to_string(),
ShortcutBinding {

View file

@ -433,6 +433,10 @@ pub fn init_shortcuts(app: &AppHandle) -> Result<(), String> {
if id == "cancel" {
continue;
}
// Skip post-processing shortcut when the feature is disabled
if id == "transcribe_with_post_process" && !user_settings.post_process_enabled {
continue;
}
let binding = user_settings
.bindings

View file

@ -110,17 +110,30 @@ pub fn change_binding(
) -> Result<BindingResponse, String> {
let mut settings = settings::get_settings(&app);
// Get the binding to modify
// Get the binding to modify, or create it from defaults if it doesn't exist
let binding_to_modify = match settings.bindings.get(&id) {
Some(binding) => binding.clone(),
None => {
let error_msg = format!("Binding with id '{}' not found", id);
warn!("change_binding error: {}", error_msg);
return Ok(BindingResponse {
success: false,
binding: None,
error: Some(error_msg),
});
// Try to get the default binding for this id
let default_settings = settings::get_default_settings();
match default_settings.bindings.get(&id) {
Some(default_binding) => {
warn!(
"Binding '{}' not found in settings, creating from defaults",
id
);
default_binding.clone()
}
None => {
let error_msg = format!("Binding with id '{}' not found in defaults", id);
warn!("change_binding error: {}", error_msg);
return Ok(BindingResponse {
success: false,
binding: None,
error: Some(error_msg),
});
}
}
}
};
@ -374,6 +387,11 @@ fn register_all_shortcuts_for_implementation(
continue;
}
// Skip post-processing shortcut when the feature is disabled
if id == "transcribe_with_post_process" && !current_settings.post_process_enabled {
continue;
}
let mut binding = current_settings
.bindings
.get(id)
@ -680,7 +698,24 @@ pub fn change_clipboard_handling_setting(app: AppHandle, handling: String) -> Re
pub fn change_post_process_enabled_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.post_process_enabled = enabled;
settings::write_settings(&app, settings);
settings::write_settings(&app, settings.clone());
// Register or unregister the post-processing shortcut
if let Some(binding) = settings
.bindings
.get("transcribe_with_post_process")
.cloned()
{
if enabled {
// Only register if the user has actually set a binding
if !binding.current_binding.is_empty() {
let _ = register_shortcut(&app, binding);
}
} else {
let _ = unregister_shortcut(&app, binding);
}
}
Ok(())
}

View file

@ -21,6 +21,10 @@ pub fn init_shortcuts(app: &AppHandle) {
if id == "cancel" {
continue; // Skip cancel shortcut, it will be registered dynamically
}
// Skip post-processing shortcut when the feature is disabled
if id == "transcribe_with_post_process" && !user_settings.post_process_enabled {
continue;
}
let binding = user_settings
.bindings
.get(&id)

View file

@ -299,10 +299,16 @@ export const GlobalShortcutInput: React.FC<GlobalShortcutInputProps> = ({
</div>
) : (
<div
className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary"
className={`px-2 py-1 text-sm font-semibold rounded cursor-pointer min-w-[120px] text-center ${
binding.current_binding
? "bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 hover:border-logo-primary"
: "bg-mid-gray/5 border border-mid-gray/40 hover:bg-logo-primary/10 hover:border-logo-primary text-mid-gray/60"
}`}
onClick={() => startRecording(shortcutId)}
>
{formatKeyCombination(binding.current_binding, osType)}
{binding.current_binding
? formatKeyCombination(binding.current_binding, osType)
: t("settings.general.shortcut.notSet")}
</div>
)}
<ResetButton

View file

@ -284,10 +284,16 @@ export const HandyKeysShortcutInput: React.FC<HandyKeysShortcutInputProps> = ({
</div>
) : (
<div
className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary"
className={`px-2 py-1 text-sm font-semibold rounded cursor-pointer min-w-[120px] text-center ${
binding.current_binding
? "bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 hover:border-logo-primary"
: "bg-mid-gray/5 border border-mid-gray/40 hover:bg-logo-primary/10 hover:border-logo-primary text-mid-gray/60"
}`}
onClick={startRecording}
>
{formatKeyCombination(binding.current_binding, osType)}
{binding.current_binding
? formatKeyCombination(binding.current_binding, osType)
: t("settings.general.shortcut.notSet")}
</div>
)}
<ResetButton

View file

@ -5,7 +5,6 @@ import type { ModelOption } from "./types";
import type { DropdownOption } from "../../ui/Dropdown";
type PostProcessProviderState = {
enabled: boolean;
providerOptions: DropdownOption[];
selectedProviderId: string;
selectedProvider: PostProcessProvider | undefined;
@ -43,8 +42,6 @@ export const usePostProcessProviderState = (): PostProcessProviderState => {
postProcessModelOptions,
} = useSettings();
const enabled = settings?.post_process_enabled || false;
// Settings are guaranteed to have providers after migration
const providers = settings?.post_process_providers || [];
@ -191,7 +188,6 @@ export const usePostProcessProviderState = (): PostProcessProviderState => {
// No automatic fetching - user must click refresh button
return {
enabled,
providerOptions,
selectedProviderId,
selectedProvider,

View file

@ -19,28 +19,13 @@ import { BaseUrlField } from "../PostProcessingSettingsApi/BaseUrlField";
import { ApiKeyField } from "../PostProcessingSettingsApi/ApiKeyField";
import { ModelSelect } from "../PostProcessingSettingsApi/ModelSelect";
import { usePostProcessProviderState } from "../PostProcessingSettingsApi/usePostProcessProviderState";
import { ShortcutInput } from "../ShortcutInput";
import { useSettings } from "../../../hooks/useSettings";
const DisabledNotice: React.FC<{ children: React.ReactNode }> = ({
children,
}) => (
<div className="p-4 bg-mid-gray/5 rounded-lg border border-mid-gray/20">
<p className="text-sm text-mid-gray">{children}</p>
</div>
);
const PostProcessingSettingsApiComponent: React.FC = () => {
const { t } = useTranslation();
const state = usePostProcessProviderState();
if (!state.enabled) {
return (
<DisabledNotice>
{t("settings.postProcessing.disabledNotice")}
</DisabledNotice>
);
}
return (
<>
<SettingContainer
@ -166,7 +151,6 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
const [draftName, setDraftName] = useState("");
const [draftText, setDraftText] = useState("");
const enabled = getSetting("post_process_enabled") || false;
const prompts = getSetting("post_process_prompts") || [];
const selectedPromptId = getSetting("post_process_selected_prompt_id") || "";
const selectedPrompt =
@ -257,14 +241,6 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
setDraftText("");
};
if (!enabled) {
return (
<DisabledNotice>
{t("settings.postProcessing.disabledNotice")}
</DisabledNotice>
);
}
const hasPrompts = prompts.length > 0;
const isDirty =
!!selectedPrompt &&
@ -452,6 +428,14 @@ export const PostProcessingSettings: React.FC = () => {
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title={t("settings.postProcessing.hotkey.title")}>
<ShortcutInput
shortcutId="transcribe_with_post_process"
descriptionMode="tooltip"
grouped={true}
/>
</SettingsGroup>
<SettingsGroup title={t("settings.postProcessing.api.title")}>
<PostProcessingSettingsApi />
</SettingsGroup>

View file

@ -241,7 +241,6 @@
},
"postProcessing": {
"title": "معالجة لاحقة",
"disabledNotice": "المعالجة اللاحقة معطلة حالياً. قم بتمكينها في إعدادات تصحيح الأخطاء للتكوين.",
"api": {
"title": "API (متوافق مع OpenAI)",
"provider": {

View file

@ -221,7 +221,6 @@
},
"postProcessing": {
"title": "Následné zpracování",
"disabledNotice": "Následné zpracování je nyní vypnuto. Pro konfiguraci jej zapněte v nastavení Ladění.",
"api": {
"title": "API (kompatibilní s OpenAI)",
"provider": {

View file

@ -237,7 +237,9 @@
},
"postProcessing": {
"title": "Nachbearbeitung",
"disabledNotice": "Die Nachbearbeitung ist derzeit deaktiviert. Aktiviere sie in den Debug-Einstellungen, um sie zu konfigurieren.",
"hotkey": {
"title": "Tastenkürzel"
},
"api": {
"title": "API (OpenAI-kompatibel)",
"provider": {

View file

@ -110,6 +110,7 @@
"loading": "Loading shortcuts...",
"none": "No shortcuts configured",
"notFound": "Shortcut not found",
"notSet": "Not set",
"pressKeys": "Press keys...",
"bindings": {
"transcribe": {
@ -119,6 +120,10 @@
"cancel": {
"name": "Cancel Shortcut",
"description": "The keyboard shortcut to cancel the current recording."
},
"transcribe_with_post_process": {
"name": "Post-Processing Hotkey",
"description": "Optional: A dedicated hotkey that always applies AI post-processing to your transcription."
}
},
"errors": {
@ -241,7 +246,9 @@
},
"postProcessing": {
"title": "Post Process",
"disabledNotice": "Post processing is currently disabled. Enable it in Debug settings to configure.",
"hotkey": {
"title": "Hotkey"
},
"api": {
"title": "API (OpenAI Compatible)",
"provider": {

View file

@ -237,7 +237,9 @@
},
"postProcessing": {
"title": "Post Proceso",
"disabledNotice": "El post procesamiento está actualmente deshabilitado. Habilítalo en la configuración de Depuración para configurarlo.",
"hotkey": {
"title": "Tecla de acceso rápido"
},
"api": {
"title": "API (Compatible con OpenAI)",
"provider": {

View file

@ -238,7 +238,9 @@
},
"postProcessing": {
"title": "Post-traitement",
"disabledNotice": "Le post-traitement est actuellement désactivé. Activez-le dans les paramètres de débogage pour le configurer.",
"hotkey": {
"title": "Raccourci clavier"
},
"api": {
"title": "API (Compatible OpenAI)",
"provider": {

View file

@ -237,7 +237,9 @@
},
"postProcessing": {
"title": "Post-Elaborazione",
"disabledNotice": "La post-elaborazione è attualmente disattivata. Abilitala nelle opzioni di debug per configurarla.",
"hotkey": {
"title": "Tasto di scelta rapida"
},
"api": {
"title": "API (Compatibile con OpenAI)",
"provider": {

View file

@ -237,7 +237,9 @@
},
"postProcessing": {
"title": "後処理",
"disabledNotice": "後処理は現在無効です。設定するにはデバッグ設定で有効にしてください。",
"hotkey": {
"title": "ホットキー"
},
"api": {
"title": "APIOpenAI互換",
"provider": {

View file

@ -241,7 +241,6 @@
},
"postProcessing": {
"title": "후처리",
"disabledNotice": "후처리가 현재 비활성화되어 있습니다. 설정하려면 디버그 설정에서 활성화하세요.",
"api": {
"title": "API (OpenAI 호환)",
"provider": {

View file

@ -237,7 +237,9 @@
},
"postProcessing": {
"title": "Postprocess",
"disabledNotice": "Postprocess jest obecnie wyłączony. Włącz go w ustawieniach Debug, aby skonfigurować.",
"hotkey": {
"title": "Skrót klawiszowy"
},
"api": {
"title": "API (zgodne z OpenAI)",
"provider": {

View file

@ -241,7 +241,9 @@
},
"postProcessing": {
"title": "Pós-Processamento",
"disabledNotice": "O pós-processamento está atualmente desabilitado. Habilite-o nas configurações de Depuração para configurar.",
"hotkey": {
"title": "Tecla de atalho"
},
"api": {
"title": "API (Compatível com OpenAI)",
"provider": {

View file

@ -237,7 +237,9 @@
},
"postProcessing": {
"title": "Постобработка",
"disabledNotice": "Постобработка в настоящее время отключена. Включите его в настройках отладки для настройки.",
"hotkey": {
"title": "Горячая клавиша"
},
"api": {
"title": "API (совместимый с OpenAI)",
"provider": {

View file

@ -241,7 +241,6 @@
},
"postProcessing": {
"title": "Son İşlem",
"disabledNotice": "Son işlem şu anda devre dışı. Yapılandırmak için Hata Ayıklama ayarlarında etkinleştirin.",
"api": {
"title": "API (OpenAI Uyumlu)",
"provider": {

View file

@ -237,7 +237,9 @@
},
"postProcessing": {
"title": "Постобробка",
"disabledNotice": "Постобробка наразі вимкнена. Увімкніть її в розділі Дебаг.",
"hotkey": {
"title": "Гаряча клавіша"
},
"api": {
"title": "API (сумісний з OpenAI)",
"provider": {

View file

@ -238,7 +238,9 @@
},
"postProcessing": {
"title": "Xử lý sau",
"disabledNotice": "Xử lý sau hiện đang bị tắt. Bật nó trong cài đặt Gỡ lỗi để cấu hình.",
"hotkey": {
"title": "Phím tắt"
},
"api": {
"title": "API (Tương thích OpenAI)",
"provider": {

View file

@ -237,7 +237,9 @@
},
"postProcessing": {
"title": "后处理",
"disabledNotice": "后处理当前已禁用。请在调试设置中启用以进行配置。",
"hotkey": {
"title": "快捷键"
},
"api": {
"title": "API兼容 OpenAI",
"provider": {