Merge branch 'main' of github.com:cjpais/Handy

This commit is contained in:
CJ Pais 2025-09-09 19:22:59 -07:00
commit 153e5e0dcd
6 changed files with 76 additions and 42 deletions

View file

@ -120,6 +120,6 @@ pub async fn cancel_download(
#[tauri::command]
pub async fn get_recommended_first_model() -> Result<String, String> {
// Recommend small model for first-time users
Ok("small".to_string())
// Recommend Parakeet V3 model for first-time users - fastest and most accurate
Ok("parakeet-tdt-0.6b-v3".to_string())
}

View file

@ -1,16 +1,16 @@
use crate::settings::{get_settings, write_settings};
use anyhow::Result;
use flate2::read::GzDecoder;
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{App, AppHandle, Emitter, Manager};
use tar::Archive;
use std::fs::File;
use flate2::read::GzDecoder;
use tauri::{App, AppHandle, Emitter, Manager};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EngineType {
@ -69,7 +69,7 @@ impl ModelManager {
ModelInfo {
id: "small".to_string(),
name: "Whisper Small".to_string(),
description: "Fast and efficient, great for most use cases".to_string(),
description: "Fast and fairly accurate.".to_string(),
filename: "ggml-small.bin".to_string(),
url: Some("https://blob.handy.computer/ggml-small.bin".to_string()),
size_mb: 244,
@ -121,7 +121,7 @@ impl ModelManager {
ModelInfo {
id: "large".to_string(),
name: "Whisper Large".to_string(),
description: "Highest accuracy, but slow.".to_string(),
description: "Good accuracy, but slow.".to_string(),
filename: "ggml-large-v3-q5_0.bin".to_string(),
url: Some("https://blob.handy.computer/ggml-large-v3-q5_0.bin".to_string()),
size_mb: 1080, // Approximate size
@ -139,7 +139,7 @@ impl ModelManager {
ModelInfo {
id: "parakeet-tdt-0.6b-v3".to_string(),
name: "Parakeet V3".to_string(),
description: "The fastest and most accurate model".to_string(),
description: "Fast and accurate".to_string(),
filename: "parakeet-tdt-0.6b-v3-int8".to_string(), // Directory name
url: Some("https://blob.handy.computer/parakeet-v3-int8.tar.gz".to_string()),
size_mb: 850, // Approximate size for int8 quantized model
@ -214,7 +214,9 @@ impl ModelManager {
// For directory-based models, check if the directory exists
let model_path = self.models_dir.join(&model.filename);
let partial_path = self.models_dir.join(format!("{}.partial", &model.filename));
let extracting_path = self.models_dir.join(format!("{}.extracting", &model.filename));
let extracting_path = self
.models_dir
.join(format!("{}.extracting", &model.filename));
// Clean up any leftover .extracting directories from interrupted extractions
if extracting_path.exists() {
@ -427,14 +429,16 @@ impl ModelManager {
println!("Extracting archive for directory-based model: {}", model_id);
// Use a temporary extraction directory to ensure atomic operations
let temp_extract_dir = self.models_dir.join(format!("{}.extracting", &model_info.filename));
let temp_extract_dir = self
.models_dir
.join(format!("{}.extracting", &model_info.filename));
let final_model_dir = self.models_dir.join(&model_info.filename);
// Clean up any previous incomplete extraction
if temp_extract_dir.exists() {
let _ = fs::remove_dir_all(&temp_extract_dir);
}
// Create temporary extraction directory
fs::create_dir_all(&temp_extract_dir)?;
@ -448,19 +452,22 @@ impl ModelManager {
let error_msg = format!("Failed to extract archive: {}", e);
// Clean up failed extraction
let _ = fs::remove_dir_all(&temp_extract_dir);
let _ = self.app_handle.emit("model-extraction-failed", &serde_json::json!({
"model_id": model_id,
"error": error_msg
}));
let _ = self.app_handle.emit(
"model-extraction-failed",
&serde_json::json!({
"model_id": model_id,
"error": error_msg
}),
);
anyhow::anyhow!(error_msg)
})?;
// Find the actual extracted directory (archive might have a nested structure)
let extracted_dirs: Vec<_> = fs::read_dir(&temp_extract_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
.collect();
if extracted_dirs.len() == 1 {
// Single directory extracted, move it to the final location
let source_dir = extracted_dirs[0].path();
@ -481,7 +488,7 @@ impl ModelManager {
println!("Successfully extracted archive for model: {}", model_id);
// Emit extraction completed event
let _ = self.app_handle.emit("model-extraction-completed", model_id);
// Remove the downloaded tar.gz file
let _ = fs::remove_file(&partial_path);
} else {

View file

@ -208,11 +208,6 @@ impl TranscriptionManager {
let model_path = self.model_manager.get_model_path(model_id)?;
println!(
"Loading transcription model {} from: {:?}",
model_id, model_path
);
// Create appropriate engine based on model type
let loaded_engine = match model_info.engine_type {
EngineType::Whisper => {

View file

@ -73,7 +73,6 @@ function App() {
<HandyTextLogo width={200} />
<Onboarding onModelSelected={handleModelSelected} />
</div>
<Footer />
</div>
);
}

View file

@ -1,6 +1,12 @@
import React from "react";
type ModelStatus = "ready" | "loading" | "downloading" | "error" | "none";
type ModelStatus =
| "ready"
| "loading"
| "downloading"
| "extracting"
| "error"
| "none";
interface ModelStatusButtonProps {
status: ModelStatus;
@ -25,6 +31,8 @@ const ModelStatusButton: React.FC<ModelStatusButtonProps> = ({
return "bg-yellow-400 animate-pulse";
case "downloading":
return "bg-logo-primary animate-pulse";
case "extracting":
return "bg-orange-400 animate-pulse";
case "error":
return "bg-red-400";
case "none":

View file

@ -43,13 +43,13 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
};
const getRecommendedBadge = (modelId: string): boolean => {
return modelId === "small";
return modelId === "parakeet-tdt-0.6b-v3";
};
return (
<div className="max-w-4xl mx-auto text-center space-y-8">
<p className="text-text/70 max-w-md mx-auto">
To get started, choose a transcription model to download
<div className="max-w-4xl mx-auto text-center space-y-6">
<p className="text-text/70 max-w-md font-medium mx-auto">
To get started, choose a transcription model
</p>
{error && (
@ -58,34 +58,59 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
</div>
)}
<div className="grid grid-cols-2 gap-4 sm:gap-6 max-w-2xl mx-auto">
<div className="max-w-2xl mx-auto space-y-4">
{/* Recommended model - full width */}
{availableModels
.sort((a, b) => a.size_mb - b.size_mb)
.filter((model) => getRecommendedBadge(model.id))
.map((model) => (
<button
key={model.id}
onClick={() => handleDownloadModel(model.id)}
disabled={downloading}
className="relative border-2 border-mid-gray/20 rounded-xl p-4 sm:p-6 text-left hover:border-logo-primary/50 hover:bg-logo-primary/5 hover:shadow-lg hover:scale-[1.02] focus:border-logo-primary focus:outline-none focus:ring-2 focus:ring-logo-primary/25 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-mid-gray/20 disabled:hover:bg-transparent disabled:hover:shadow-none disabled:hover:scale-100 cursor-pointer group"
className="relative w-full border-2 border-logo-primary/40 bg-logo-primary/5 rounded-xl p-4 text-left hover:border-logo-primary/60 hover:bg-logo-primary/10 hover:shadow-lg hover:scale-[1.02] focus:border-logo-primary focus:outline-none focus:ring-2 focus:ring-logo-primary/25 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-logo-primary/40 disabled:hover:bg-logo-primary/5 disabled:hover:shadow-none disabled:hover:scale-100 cursor-pointer group"
>
{getRecommendedBadge(model.id) && (
<div className="absolute -top-2 -right-2 bg-logo-primary text-white text-xs px-3 py-1 rounded-full font-medium shadow-md">
Recommended
</div>
)}
<div className="absolute -top-2 -right-2 bg-logo-primary text-white text-sm px-4 py-2 rounded-full font-medium shadow-md">
Recommended
</div>
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-lg sm:text-xl font-semibold text-text group-hover:text-logo-primary transition-colors">
<div className="space-y-4">
<div className="space-y-0">
<h3 className="text-xl sm:text-2xl font-bold text-text group-hover:text-logo-primary transition-colors">
{model.name}
</h3>
<p className="text-text/60 text-xs sm:text-sm leading-relaxed">
<p className="text-text/70 text-sm sm:text-base leading-relaxed">
{model.description}
</p>
</div>
</div>
</button>
))}
{/* Other models - 2 column grid */}
<div className="grid grid-cols-2 gap-4">
{availableModels
.filter((model) => !getRecommendedBadge(model.id))
.sort((a, b) => a.size_mb - b.size_mb)
.map((model) => (
<button
key={model.id}
onClick={() => handleDownloadModel(model.id)}
disabled={downloading}
className="relative border-2 border-mid-gray/20 rounded-xl sm:p-4 text-left hover:border-logo-primary/50 hover:bg-logo-primary/5 hover:shadow-lg hover:scale-[1.02] focus:border-logo-primary focus:outline-none focus:ring-2 focus:ring-logo-primary/25 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-mid-gray/20 disabled:hover:bg-transparent disabled:hover:shadow-none disabled:hover:scale-100 cursor-pointer group"
>
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-lg sm:text-xl font-semibold text-text group-hover:text-logo-primary transition-colors">
{model.name}
</h3>
<p className="text-text/60 text-xs sm:text-sm leading-relaxed">
{model.description}
</p>
</div>
</div>
</button>
))}
</div>
</div>
</div>
);