irongit

Open source voice dictation for your whole desktop. Local whisper.cpp transcription, global hotkeys, optional LLM enhancement via Claude Code, Gemini CLI, Codex, or any API. Wayland-first.

mutterbox/src-tauri/src/settings.rs
160 lines5.2 KBRust
1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::PathBuf;
4use tauri::{AppHandle, Manager};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(default)]
8pub struct EnhanceProfile {
9 pub name: String,
10 /// "anthropic" | "gemini" | "openai" | "ollama" | "claude-cli" | "gemini-cli" | "codex-cli" | "custom-cli"
11 pub provider: String,
12 pub model: String,
13 /// Base URL for OpenAI-compatible endpoints (openai/ollama providers)
14 pub base_url: String,
15 /// Env var name holding the API key; empty means the provider default
16 pub api_key_env: String,
17 /// Shell command for the custom-cli provider; prompt arrives on stdin
18 pub cli_command: String,
19 pub instructions: String,
20}
21
22impl Default for EnhanceProfile {
23 fn default() -> Self {
24 Self {
25 name: "cleanup".into(),
26 provider: "anthropic".into(),
27 model: "claude-opus-4-8".into(),
28 base_url: "".into(),
29 api_key_env: "".into(),
30 cli_command: "".into(),
31 instructions: DEFAULT_INSTRUCTIONS.into(),
32 }
33 }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(default)]
38pub struct EnhanceSettings {
39 pub enabled: bool,
40 pub active_profile: String,
41 pub profiles: Vec<EnhanceProfile>,
42}
43
44impl Default for EnhanceSettings {
45 fn default() -> Self {
46 Self {
47 enabled: false,
48 active_profile: "cleanup".into(),
49 profiles: vec![
50 EnhanceProfile::default(),
51 EnhanceProfile {
52 name: "email".into(),
53 instructions: EMAIL_INSTRUCTIONS.into(),
54 ..Default::default()
55 },
56 EnhanceProfile {
57 name: "bullets".into(),
58 instructions: BULLET_INSTRUCTIONS.into(),
59 ..Default::default()
60 },
61 ],
62 }
63 }
64}
65
66impl EnhanceSettings {
67 /// The profile dictation should use: the active one, else the first.
68 pub fn active(&self) -> Option<&EnhanceProfile> {
69 self.profiles
70 .iter()
71 .find(|p| p.name == self.active_profile)
72 .or_else(|| self.profiles.first())
73 }
74}
75
76pub const DEFAULT_INSTRUCTIONS: &str = "Fix punctuation, capitalization, filler words, and obvious transcription mistakes. Keep the speaker's wording and tone unchanged.";
77
78pub const EMAIL_INSTRUCTIONS: &str = "Rewrite the transcript as a clear, friendly, professional email body with short paragraphs. Only include a greeting or sign-off if the speaker dictated one.";
79
80pub const BULLET_INSTRUCTIONS: &str = "Convert the transcript into terse bullet points, one idea per line, keeping every concrete detail the speaker mentioned.";
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(default)]
84pub struct Settings {
85 pub hotkey: String,
86 /// "toggle" (press to start/stop) | "hold" (record while held)
87 pub activation: String,
88 /// "paste" | "type"
89 pub injection_mode: String,
90 pub restore_clipboard: bool,
91 /// Catalog id, e.g. "base.en"
92 pub dictation_model: String,
93 /// "auto" or an ISO language code understood by whisper
94 pub language: String,
95 pub enhance: EnhanceSettings,
96}
97
98impl Default for Settings {
99 fn default() -> Self {
100 Self {
101 hotkey: "Ctrl+Alt+Space".into(),
102 activation: "toggle".into(),
103 injection_mode: "paste".into(),
104 restore_clipboard: true,
105 dictation_model: "base.en".into(),
106 language: "auto".into(),
107 enhance: EnhanceSettings::default(),
108 }
109 }
110}
111
112fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
113 let dir = app
114 .path()
115 .app_config_dir()
116 .map_err(|e| format!("no config dir: {e}"))?;
117 fs::create_dir_all(&dir).map_err(|e| format!("create config dir: {e}"))?;
118 Ok(dir.join("settings.json"))
119}
120
121pub fn load(app: &AppHandle) -> Settings {
122 let Ok(path) = settings_path(app) else {
123 return Settings::default();
124 };
125 let Some(mut value) = fs::read_to_string(path)
126 .ok()
127 .and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
128 else {
129 return Settings::default();
130 };
131 migrate_legacy_enhance(&mut value);
132 serde_json::from_value(value).unwrap_or_default()
133}
134
135/// Older settings stored one flat enhance config; wrap it into a profile.
136fn migrate_legacy_enhance(value: &mut serde_json::Value) {
137 let Some(enhance) = value.get_mut("enhance") else {
138 return;
139 };
140 if enhance.get("profiles").is_some() || enhance.get("provider").is_none() {
141 return;
142 }
143 let enabled = enhance.get("enabled").cloned().unwrap_or(false.into());
144 let mut profile = enhance.clone();
145 if let Some(obj) = profile.as_object_mut() {
146 obj.remove("enabled");
147 obj.insert("name".into(), "default".into());
148 }
149 *enhance = serde_json::json!({
150 "enabled": enabled,
151 "active_profile": "default",
152 "profiles": [profile],
153 });
154}
155
156pub fn save(app: &AppHandle, settings: &Settings) -> Result<(), String> {
157 let path = settings_path(app)?;
158 let raw = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?;
159 fs::write(path, raw).map_err(|e| format!("write settings: {e}"))
160}