Merge branch 'main' of github.com:cjpais/Handy
This commit is contained in:
commit
cd6102c69a
13 changed files with 456 additions and 44 deletions
|
|
@ -1,5 +1,6 @@
|
|||
pub mod audio;
|
||||
pub mod models;
|
||||
pub mod transcription;
|
||||
|
||||
use crate::utils::cancel_current_operation;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
|
|
|||
32
src-tauri/src/commands/transcription.rs
Normal file
32
src-tauri/src/commands/transcription.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use crate::managers::transcription::TranscriptionManager;
|
||||
use crate::settings::{get_settings, write_settings, ModelUnloadTimeout};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_model_unload_timeout(app: AppHandle, timeout: ModelUnloadTimeout) {
|
||||
let mut settings = get_settings(&app);
|
||||
settings.model_unload_timeout = timeout;
|
||||
write_settings(&app, settings);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_model_load_status(
|
||||
transcription_manager: State<TranscriptionManager>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let is_loaded = transcription_manager.is_model_loaded();
|
||||
let current_model = transcription_manager.get_current_model();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"is_loaded": is_loaded,
|
||||
"current_model": current_model
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn unload_model_manually(
|
||||
transcription_manager: State<TranscriptionManager>,
|
||||
) -> Result<(), String> {
|
||||
transcription_manager
|
||||
.unload_model()
|
||||
.map_err(|e| format!("Failed to unload model: {}", e))
|
||||
}
|
||||
|
|
@ -83,20 +83,10 @@ pub fn run() {
|
|||
.manage(Mutex::new(ShortcutToggleStates::default()))
|
||||
.setup(move |app| {
|
||||
// Get the current theme to set the appropriate initial icon
|
||||
let initial_theme = if let Some(main_window) = app.get_webview_window("main") {
|
||||
main_window.theme().unwrap_or(tauri::Theme::Dark)
|
||||
} else {
|
||||
tauri::Theme::Dark
|
||||
};
|
||||
|
||||
println!("Initial system theme: {:?}", initial_theme);
|
||||
let initial_theme = tray::get_current_theme(&app.handle());
|
||||
|
||||
// Choose the appropriate initial icon based on theme
|
||||
let initial_icon_path = match initial_theme {
|
||||
tauri::Theme::Dark => "resources/tray_idle.png",
|
||||
tauri::Theme::Light => "resources/tray_idle_dark.png",
|
||||
_ => "resources/tray_idle.png", // Default fallback
|
||||
};
|
||||
let initial_icon_path = tray::get_icon_path(initial_theme, tray::TrayIconState::Idle);
|
||||
|
||||
let tray = TrayIconBuilder::new()
|
||||
.icon(Image::from_path(app.path().resolve(
|
||||
|
|
@ -213,7 +203,10 @@ pub fn run() {
|
|||
commands::audio::get_selected_microphone,
|
||||
commands::audio::get_available_output_devices,
|
||||
commands::audio::set_selected_output_device,
|
||||
commands::audio::get_selected_output_device
|
||||
commands::audio::get_selected_output_device,
|
||||
commands::transcription::set_model_unload_timeout,
|
||||
commands::transcription::get_model_load_status,
|
||||
commands::transcription::unload_model_manually
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
use crate::managers::model::ModelManager;
|
||||
use crate::settings::get_settings;
|
||||
use crate::settings::{get_settings, ModelUnloadTimeout};
|
||||
use anyhow::Result;
|
||||
use log::debug;
|
||||
use natural::phonetics::soundex;
|
||||
use serde::Serialize;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use strsim::levenshtein;
|
||||
use tauri::{App, AppHandle, Emitter, Manager};
|
||||
use whisper_rs::{
|
||||
|
|
@ -18,12 +22,16 @@ pub struct ModelStateEvent {
|
|||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TranscriptionManager {
|
||||
state: Mutex<Option<WhisperState>>,
|
||||
context: Mutex<Option<WhisperContext>>,
|
||||
state: Arc<Mutex<Option<WhisperState>>>,
|
||||
context: Arc<Mutex<Option<WhisperContext>>>,
|
||||
model_manager: Arc<ModelManager>,
|
||||
app_handle: AppHandle,
|
||||
current_model_id: Mutex<Option<String>>,
|
||||
current_model_id: Arc<Mutex<Option<String>>>,
|
||||
last_activity: Arc<AtomicU64>,
|
||||
shutdown_signal: Arc<AtomicBool>,
|
||||
watcher_handle: Arc<Mutex<Option<thread::JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
fn apply_custom_words(text: &str, custom_words: &[String], threshold: f64) -> String {
|
||||
|
|
@ -139,13 +147,81 @@ impl TranscriptionManager {
|
|||
let app_handle = app.app_handle().clone();
|
||||
|
||||
let manager = Self {
|
||||
state: Mutex::new(None),
|
||||
context: Mutex::new(None),
|
||||
state: Arc::new(Mutex::new(None)),
|
||||
context: Arc::new(Mutex::new(None)),
|
||||
model_manager,
|
||||
app_handle: app_handle.clone(),
|
||||
current_model_id: Mutex::new(None),
|
||||
current_model_id: Arc::new(Mutex::new(None)),
|
||||
last_activity: Arc::new(AtomicU64::new(
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64,
|
||||
)),
|
||||
shutdown_signal: Arc::new(AtomicBool::new(false)),
|
||||
watcher_handle: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
// Start the idle watcher
|
||||
{
|
||||
let app_handle_cloned = app_handle.clone();
|
||||
let manager_cloned = manager.clone();
|
||||
let shutdown_signal = manager.shutdown_signal.clone();
|
||||
let handle = thread::spawn(move || {
|
||||
while !shutdown_signal.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_secs(10)); // Check every 10 seconds
|
||||
|
||||
// Check shutdown signal again after sleep
|
||||
if shutdown_signal.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let settings = get_settings(&app_handle_cloned);
|
||||
let timeout_seconds = settings.model_unload_timeout.to_seconds();
|
||||
|
||||
if let Some(limit_seconds) = timeout_seconds {
|
||||
// Skip polling-based unloading for immediate timeout since it's handled directly in transcribe()
|
||||
if settings.model_unload_timeout == ModelUnloadTimeout::Immediately {
|
||||
continue;
|
||||
}
|
||||
|
||||
let last = manager_cloned.last_activity.load(Ordering::Relaxed);
|
||||
let now_ms = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
|
||||
if now_ms.saturating_sub(last) > limit_seconds * 1000 {
|
||||
// idle -> unload
|
||||
if manager_cloned.is_model_loaded() {
|
||||
let unload_start = std::time::Instant::now();
|
||||
debug!("Starting to unload model due to inactivity");
|
||||
|
||||
if let Ok(()) = manager_cloned.unload_model() {
|
||||
let _ = app_handle_cloned.emit(
|
||||
"model-state-changed",
|
||||
ModelStateEvent {
|
||||
event_type: "unloaded_due_to_idle".to_string(),
|
||||
model_id: None,
|
||||
model_name: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
let unload_duration = unload_start.elapsed();
|
||||
debug!(
|
||||
"Model unloaded due to inactivity (took {}ms)",
|
||||
unload_duration.as_millis()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!("Idle watcher thread shutting down gracefully");
|
||||
});
|
||||
*manager.watcher_handle.lock().unwrap() = Some(handle);
|
||||
}
|
||||
|
||||
// Try to load the default model from settings, but don't fail if no models are available
|
||||
let settings = get_settings(&app_handle);
|
||||
let _ = manager.load_model(&settings.selected_model);
|
||||
|
|
@ -153,7 +229,51 @@ impl TranscriptionManager {
|
|||
Ok(manager)
|
||||
}
|
||||
|
||||
pub fn is_model_loaded(&self) -> bool {
|
||||
let state = self.state.lock().unwrap();
|
||||
state.is_some()
|
||||
}
|
||||
|
||||
pub fn unload_model(&self) -> Result<()> {
|
||||
let unload_start = std::time::Instant::now();
|
||||
debug!("Starting to unload model");
|
||||
|
||||
{
|
||||
let mut state = self.state.lock().unwrap();
|
||||
*state = None; // Dropping state frees GPU/CPU memory
|
||||
}
|
||||
{
|
||||
let mut context = self.context.lock().unwrap();
|
||||
*context = None; // Dropping context frees additional memory
|
||||
}
|
||||
{
|
||||
let mut current_model = self.current_model_id.lock().unwrap();
|
||||
*current_model = None;
|
||||
}
|
||||
|
||||
// Emit unloaded event
|
||||
let _ = self.app_handle.emit(
|
||||
"model-state-changed",
|
||||
ModelStateEvent {
|
||||
event_type: "unloaded_manually".to_string(),
|
||||
model_id: None,
|
||||
model_name: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
let unload_duration = unload_start.elapsed();
|
||||
debug!(
|
||||
"Model unloaded manually (took {}ms)",
|
||||
unload_duration.as_millis()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_model(&self, model_id: &str) -> Result<()> {
|
||||
let load_start = std::time::Instant::now();
|
||||
debug!("Starting to load model: {}", model_id);
|
||||
|
||||
// Emit loading started event
|
||||
let _ = self.app_handle.emit(
|
||||
"model-state-changed",
|
||||
|
|
@ -252,7 +372,12 @@ impl TranscriptionManager {
|
|||
},
|
||||
);
|
||||
|
||||
println!("Successfully loaded transcription model: {}", model_id);
|
||||
let load_duration = load_start.elapsed();
|
||||
debug!(
|
||||
"Successfully loaded transcription model: {} (took {}ms)",
|
||||
model_id,
|
||||
load_duration.as_millis()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -262,6 +387,15 @@ impl TranscriptionManager {
|
|||
}
|
||||
|
||||
pub fn transcribe(&self, audio: Vec<f32>) -> Result<String> {
|
||||
// Update last activity timestamp
|
||||
self.last_activity.store(
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
|
||||
let st = std::time::Instant::now();
|
||||
|
||||
let mut result = String::new();
|
||||
|
|
@ -272,10 +406,34 @@ impl TranscriptionManager {
|
|||
return Ok(result);
|
||||
}
|
||||
|
||||
// Check if model is loaded, if not try to load it
|
||||
{
|
||||
let state_guard = self.state.lock().unwrap();
|
||||
if state_guard.is_none() {
|
||||
// Model not loaded, try to load the selected model from settings
|
||||
let settings = get_settings(&self.app_handle);
|
||||
println!(
|
||||
"Model not loaded, attempting to load: {}",
|
||||
settings.selected_model
|
||||
);
|
||||
|
||||
// Drop the guard before calling load_model to avoid deadlock
|
||||
drop(state_guard);
|
||||
|
||||
// Try to load the model
|
||||
if let Err(e) = self.load_model(&settings.selected_model) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to auto-load model '{}': {}. Please check that the model is downloaded and try again.",
|
||||
settings.selected_model, e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut state_guard = self.state.lock().unwrap();
|
||||
let state = state_guard.as_mut().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No model loaded. Please download and select a model from settings first."
|
||||
"Model failed to load after auto-load attempt. Please check your model settings."
|
||||
)
|
||||
})?;
|
||||
|
||||
|
|
@ -333,6 +491,34 @@ impl TranscriptionManager {
|
|||
};
|
||||
println!("\ntook {}ms{}", (et - st).as_millis(), translation_note);
|
||||
|
||||
// Check if we should immediately unload the model after transcription
|
||||
if settings.model_unload_timeout == ModelUnloadTimeout::Immediately {
|
||||
println!("⚡ Immediately unloading model after transcription");
|
||||
// Drop the state guard first to avoid deadlock
|
||||
drop(state_guard);
|
||||
if let Err(e) = self.unload_model() {
|
||||
eprintln!("Failed to immediately unload model: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(corrected_result.trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TranscriptionManager {
|
||||
fn drop(&mut self) {
|
||||
debug!("Shutting down TranscriptionManager");
|
||||
|
||||
// Signal the watcher thread to shutdown
|
||||
self.shutdown_signal.store(true, Ordering::Relaxed);
|
||||
|
||||
// Wait for the thread to finish gracefully
|
||||
if let Some(handle) = self.watcher_handle.lock().unwrap().take() {
|
||||
if let Err(e) = handle.join() {
|
||||
eprintln!("Failed to join idle watcher thread: {:?}", e);
|
||||
} else {
|
||||
debug!("Idle watcher thread joined successfully");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,49 @@ pub enum OverlayPosition {
|
|||
Bottom,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ModelUnloadTimeout {
|
||||
Never,
|
||||
Immediately,
|
||||
Min2,
|
||||
Min5,
|
||||
Min10,
|
||||
Min15,
|
||||
Hour1,
|
||||
Sec5, // Debug mode only
|
||||
}
|
||||
|
||||
impl Default for ModelUnloadTimeout {
|
||||
fn default() -> Self {
|
||||
ModelUnloadTimeout::Never
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelUnloadTimeout {
|
||||
pub fn to_minutes(self) -> Option<u64> {
|
||||
match self {
|
||||
ModelUnloadTimeout::Never => None,
|
||||
ModelUnloadTimeout::Immediately => Some(0), // Special case for immediate unloading
|
||||
ModelUnloadTimeout::Min2 => Some(2),
|
||||
ModelUnloadTimeout::Min5 => Some(5),
|
||||
ModelUnloadTimeout::Min10 => Some(10),
|
||||
ModelUnloadTimeout::Min15 => Some(15),
|
||||
ModelUnloadTimeout::Hour1 => Some(60),
|
||||
ModelUnloadTimeout::Sec5 => Some(0), // Special case for debug - handled separately
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_seconds(self) -> Option<u64> {
|
||||
match self {
|
||||
ModelUnloadTimeout::Never => None,
|
||||
ModelUnloadTimeout::Immediately => Some(0), // Special case for immediate unloading
|
||||
ModelUnloadTimeout::Sec5 => Some(5),
|
||||
_ => self.to_minutes().map(|m| m * 60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* still handy for composing the initial JSON in the store ------------- */
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AppSettings {
|
||||
|
|
@ -44,6 +87,8 @@ pub struct AppSettings {
|
|||
pub debug_mode: bool,
|
||||
#[serde(default)]
|
||||
pub custom_words: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub model_unload_timeout: ModelUnloadTimeout,
|
||||
#[serde(default = "default_word_correction_threshold")]
|
||||
pub word_correction_threshold: f64,
|
||||
}
|
||||
|
|
@ -113,6 +158,7 @@ pub fn get_default_settings() -> AppSettings {
|
|||
overlay_position: OverlayPosition::Bottom,
|
||||
debug_mode: false,
|
||||
custom_words: Vec::new(),
|
||||
model_unload_timeout: ModelUnloadTimeout::Never,
|
||||
word_correction_threshold: default_word_correction_threshold(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use serde::Serialize;
|
||||
use tauri::{App, AppHandle, Manager};
|
||||
use tauri::{App, AppHandle, Emitter, Manager};
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use tauri_plugin_global_shortcut::{Shortcut, ShortcutState};
|
||||
|
||||
|
|
@ -160,6 +160,16 @@ pub fn change_debug_mode_setting(app: AppHandle, enabled: bool) -> Result<(), St
|
|||
let mut settings = settings::get_settings(&app);
|
||||
settings.debug_mode = enabled;
|
||||
settings::write_settings(&app, settings);
|
||||
|
||||
// Emit event to notify frontend of debug mode change
|
||||
let _ = app.emit(
|
||||
"settings-changed",
|
||||
serde_json::json!({
|
||||
"setting": "debug_mode",
|
||||
"value": enabled
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,47 @@ pub enum TrayIconState {
|
|||
Transcribing,
|
||||
}
|
||||
|
||||
/// Gets the current system theme, defaulting to Dark if unavailable
|
||||
fn get_current_theme(app: &AppHandle) -> Theme {
|
||||
if let Some(main_window) = app.get_webview_window("main") {
|
||||
main_window.theme().unwrap_or(Theme::Dark)
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum AppTheme {
|
||||
Dark,
|
||||
Light,
|
||||
Colored, // Pink/colored theme for Linux
|
||||
}
|
||||
|
||||
/// Gets the current app theme, with Linux defaulting to Colored theme
|
||||
pub fn get_current_theme(app: &AppHandle) -> AppTheme {
|
||||
if cfg!(target_os = "linux") {
|
||||
// On Linux, always use the colored theme
|
||||
AppTheme::Colored
|
||||
} else {
|
||||
Theme::Dark
|
||||
// On other platforms, map system theme to our app theme
|
||||
if let Some(main_window) = app.get_webview_window("main") {
|
||||
match main_window.theme().unwrap_or(Theme::Dark) {
|
||||
Theme::Light => AppTheme::Light,
|
||||
Theme::Dark => AppTheme::Dark,
|
||||
_ => AppTheme::Dark, // Default fallback
|
||||
}
|
||||
} else {
|
||||
AppTheme::Dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the appropriate icon path for the given theme and state
|
||||
pub fn get_icon_path(theme: AppTheme, state: TrayIconState) -> &'static str {
|
||||
match (theme, state) {
|
||||
// Dark theme uses light icons
|
||||
(AppTheme::Dark, TrayIconState::Idle) => "resources/tray_idle.png",
|
||||
(AppTheme::Dark, TrayIconState::Recording) => "resources/tray_recording.png",
|
||||
(AppTheme::Dark, TrayIconState::Transcribing) => "resources/tray_transcribing.png",
|
||||
// Light theme uses dark icons
|
||||
(AppTheme::Light, TrayIconState::Idle) => "resources/tray_idle_dark.png",
|
||||
(AppTheme::Light, TrayIconState::Recording) => "resources/tray_recording_dark.png",
|
||||
(AppTheme::Light, TrayIconState::Transcribing) => "resources/tray_transcribing_dark.png",
|
||||
// Colored theme uses pink icons (for Linux)
|
||||
(AppTheme::Colored, TrayIconState::Idle) => "resources/handy.png",
|
||||
(AppTheme::Colored, TrayIconState::Recording) => "resources/recording.png",
|
||||
(AppTheme::Colored, TrayIconState::Transcribing) => "resources/transcribing.png",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -23,20 +58,7 @@ pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) {
|
|||
let tray = app.state::<TrayIcon>();
|
||||
let theme = get_current_theme(app);
|
||||
|
||||
let icon_path = match (theme, &icon) {
|
||||
// Dark theme uses regular icons (lighter colored for visibility)
|
||||
(Theme::Dark, TrayIconState::Idle) => "resources/tray_idle.png",
|
||||
(Theme::Dark, TrayIconState::Recording) => "resources/tray_recording.png",
|
||||
(Theme::Dark, TrayIconState::Transcribing) => "resources/tray_transcribing.png",
|
||||
// Light theme uses dark icons (darker colored for visibility)
|
||||
(Theme::Light, TrayIconState::Idle) => "resources/tray_idle_dark.png",
|
||||
(Theme::Light, TrayIconState::Recording) => "resources/tray_recording_dark.png",
|
||||
(Theme::Light, TrayIconState::Transcribing) => "resources/tray_transcribing_dark.png",
|
||||
// Fallback for any other theme variants
|
||||
(_, TrayIconState::Idle) => "resources/tray_idle.png",
|
||||
(_, TrayIconState::Recording) => "resources/tray_recording.png",
|
||||
(_, TrayIconState::Transcribing) => "resources/tray_transcribing.png",
|
||||
};
|
||||
let icon_path = get_icon_path(theme, icon.clone());
|
||||
|
||||
let _ = tray.set_icon(Some(
|
||||
Image::from_path(
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@
|
|||
{
|
||||
"title": "Handy",
|
||||
"width": 540,
|
||||
"height": 730,
|
||||
"height": 740,
|
||||
"minWidth": 540,
|
||||
"minHeight": 730,
|
||||
"minHeight": 740,
|
||||
"resizable": true,
|
||||
"maximizable": false
|
||||
}
|
||||
|
|
|
|||
70
src/components/settings/ModelUnloadTimeout.tsx
Normal file
70
src/components/settings/ModelUnloadTimeout.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import React, { useMemo } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import { ModelUnloadTimeout } from "../../lib/types";
|
||||
import { Dropdown } from "../ui/Dropdown";
|
||||
import { SettingContainer } from "../ui/SettingContainer";
|
||||
|
||||
interface ModelUnloadTimeoutProps {
|
||||
descriptionMode?: "tooltip" | "inline";
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
const timeoutOptions = [
|
||||
{ value: "never" as ModelUnloadTimeout, label: "Never" },
|
||||
{ value: "immediately" as ModelUnloadTimeout, label: "Immediately" },
|
||||
{ value: "min2" as ModelUnloadTimeout, label: "After 2 minutes" },
|
||||
{ value: "min5" as ModelUnloadTimeout, label: "After 5 minutes" },
|
||||
{ value: "min10" as ModelUnloadTimeout, label: "After 10 minutes" },
|
||||
{ value: "min15" as ModelUnloadTimeout, label: "After 15 minutes" },
|
||||
{ value: "hour1" as ModelUnloadTimeout, label: "After 1 hour" },
|
||||
];
|
||||
|
||||
const debugTimeoutOptions = [
|
||||
...timeoutOptions,
|
||||
{ value: "sec5" as ModelUnloadTimeout, label: "After 5 seconds (Debug)" },
|
||||
];
|
||||
|
||||
export const ModelUnloadTimeoutSetting: React.FC<ModelUnloadTimeoutProps> = ({
|
||||
descriptionMode = "inline",
|
||||
grouped = false,
|
||||
}) => {
|
||||
const { settings, getSetting, updateSetting } = useSettings();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newTimeout = event.target.value as ModelUnloadTimeout;
|
||||
|
||||
try {
|
||||
await invoke("set_model_unload_timeout", { timeout: newTimeout });
|
||||
updateSetting("model_unload_timeout", newTimeout);
|
||||
} catch (error) {
|
||||
console.error("Failed to update model unload timeout:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const currentValue = getSetting("model_unload_timeout") ?? "never";
|
||||
|
||||
const options = useMemo(() => {
|
||||
return settings?.debug_mode === true ? debugTimeoutOptions : timeoutOptions;
|
||||
}, [settings]);
|
||||
|
||||
return (
|
||||
<SettingContainer
|
||||
title="Unload Model"
|
||||
description="Automatically free GPU/CPU memory when the model hasn't been used for the specified time"
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
>
|
||||
<Dropdown
|
||||
options={options}
|
||||
selectedValue={currentValue}
|
||||
onSelect={(value) =>
|
||||
handleChange({
|
||||
target: { value },
|
||||
} as React.ChangeEvent<HTMLSelectElement>)
|
||||
}
|
||||
disabled={false}
|
||||
/>
|
||||
</SettingContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -9,6 +9,7 @@ import { HandyShortcut } from "./HandyShortcut";
|
|||
import { TranslateToEnglish } from "./TranslateToEnglish";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { CustomWords } from "./CustomWords";
|
||||
import { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
import { WordCorrectionThreshold } from "./debug/WordCorrectionThreshold";
|
||||
import { AppDataDirectory } from "./AppDataDirectory";
|
||||
|
|
@ -56,6 +57,7 @@ export const Settings: React.FC = () => {
|
|||
<OutputDeviceSelector descriptionMode="tooltip" grouped={true} />
|
||||
<ShowOverlay descriptionMode="tooltip" grouped={true} />
|
||||
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
|
||||
<ModelUnloadTimeoutSetting descriptionMode="tooltip" grouped={true} />
|
||||
<CustomWords descriptionMode="tooltip" grouped />
|
||||
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
|
||||
</SettingsGroup>
|
||||
|
|
|
|||
36
src/components/settings/debug/DebugPaths.tsx
Normal file
36
src/components/settings/debug/DebugPaths.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React from "react";
|
||||
import { SettingContainer } from "../../ui/SettingContainer";
|
||||
|
||||
interface DebugPathsProps {
|
||||
descriptionMode?: "tooltip" | "inline";
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export const DebugPaths: React.FC<DebugPathsProps> = ({
|
||||
descriptionMode = "inline",
|
||||
grouped = false,
|
||||
}) => {
|
||||
return (
|
||||
<SettingContainer
|
||||
title="Debug Paths"
|
||||
description="Display internal file paths and directories for debugging purposes"
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
>
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<div>
|
||||
<span className="font-medium">App Data:</span>{" "}
|
||||
<span className="font-mono text-xs">%APPDATA%/handy</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Models:</span>{" "}
|
||||
<span className="font-mono text-xs">%APPDATA%/handy/models</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Settings:</span>{" "}
|
||||
<span className="font-mono text-xs">%APPDATA%/handy/settings_store.json</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -9,3 +9,4 @@ export { HandyShortcut } from "./HandyShortcut";
|
|||
export { TranslateToEnglish } from "./TranslateToEnglish";
|
||||
export { CustomWords } from "./CustomWords";
|
||||
export { AppDataDirectory } from "./AppDataDirectory";
|
||||
export { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout";
|
||||
|
|
|
|||
|
|
@ -22,6 +22,18 @@ export const AudioDeviceSchema = z.object({
|
|||
export const OverlayPositionSchema = z.enum(["none", "top", "bottom"]);
|
||||
export type OverlayPosition = z.infer<typeof OverlayPositionSchema>;
|
||||
|
||||
export const ModelUnloadTimeoutSchema = z.enum([
|
||||
"never",
|
||||
"immediately",
|
||||
"min2",
|
||||
"min5",
|
||||
"min10",
|
||||
"min15",
|
||||
"hour1",
|
||||
"sec5",
|
||||
]);
|
||||
export type ModelUnloadTimeout = z.infer<typeof ModelUnloadTimeoutSchema>;
|
||||
|
||||
export const SettingsSchema = z.object({
|
||||
bindings: ShortcutBindingsMapSchema,
|
||||
push_to_talk: z.boolean(),
|
||||
|
|
@ -35,6 +47,7 @@ export const SettingsSchema = z.object({
|
|||
overlay_position: OverlayPositionSchema,
|
||||
debug_mode: z.boolean(),
|
||||
custom_words: z.array(z.string()).optional().default([]),
|
||||
model_unload_timeout: ModelUnloadTimeoutSchema.optional().default("never"),
|
||||
word_correction_threshold: z.number().optional().default(0.18),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue