tweak onboarding (#112)

This commit is contained in:
CJ Pais 2025-09-09 18:59:09 -07:00 committed by GitHub
parent 442438b142
commit 338d15d2c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 76 additions and 42 deletions

View file

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

View file

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

View file

@ -208,11 +208,6 @@ impl TranscriptionManager {
let model_path = self.model_manager.get_model_path(model_id)?; 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 // Create appropriate engine based on model type
let loaded_engine = match model_info.engine_type { let loaded_engine = match model_info.engine_type {
EngineType::Whisper => { EngineType::Whisper => {

View file

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

View file

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

View file

@ -43,13 +43,13 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
}; };
const getRecommendedBadge = (modelId: string): boolean => { const getRecommendedBadge = (modelId: string): boolean => {
return modelId === "small"; return modelId === "parakeet-tdt-0.6b-v3";
}; };
return ( return (
<div className="max-w-4xl mx-auto text-center space-y-8"> <div className="max-w-4xl mx-auto text-center space-y-6">
<p className="text-text/70 max-w-md mx-auto"> <p className="text-text/70 max-w-md font-medium mx-auto">
To get started, choose a transcription model to download To get started, choose a transcription model
</p> </p>
{error && ( {error && (
@ -58,34 +58,59 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
</div> </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 {availableModels
.sort((a, b) => a.size_mb - b.size_mb) .filter((model) => getRecommendedBadge(model.id))
.map((model) => ( .map((model) => (
<button <button
key={model.id} key={model.id}
onClick={() => handleDownloadModel(model.id)} onClick={() => handleDownloadModel(model.id)}
disabled={downloading} 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-sm px-4 py-2 rounded-full font-medium shadow-md">
<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
Recommended </div>
</div>
)}
<div className="space-y-3"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-0">
<h3 className="text-lg sm:text-xl font-semibold text-text group-hover:text-logo-primary transition-colors"> <h3 className="text-xl sm:text-2xl font-bold text-text group-hover:text-logo-primary transition-colors">
{model.name} {model.name}
</h3> </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} {model.description}
</p> </p>
</div> </div>
</div> </div>
</button> </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>
</div> </div>
); );