feat: add offline model manager
This commit is contained in:
1 parent
a88206db2b
commit
f324566b42
9 files changed
+1094
-21
No files matched your search
+1
-1
@@ -9,7 +9,7 @@ src-tauri/target/
|
|||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
|
|
||||||
# Local app data and large speech models
|
# Local app data and large speech models
|
||||||
models/
|
/models/
|
||||||
*.gguf
|
*.gguf
|
||||||
*.nemo
|
*.nemo
|
||||||
*.wav
|
*.wav
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Integrações Nativas do Transcript Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Implementar as integrações que faltavam no MVP: gerenciador real do modelo GGUF, captura de áudio WAV, execução CrispASR, hotkey global e inserção/cópia com fallback cross-platform.
|
||||||
|
|
||||||
|
**Architecture:** Manter cada integração atrás de um módulo pequeno. A UI chama comandos Tauri, os comandos consultam `ManagedAppState`, e integrações dependentes de SO retornam mensagens explícitas quando o ambiente limita a automação.
|
||||||
|
|
||||||
|
**Tech Stack:** Tauri 2, Rust, CPAL, hound, reqwest, Tauri global-shortcut plugin, TypeScript.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Model manager real
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src-tauri/src/models/mod.rs`
|
||||||
|
- Modify: `src-tauri/src/ui_commands.rs`
|
||||||
|
- Modify: `src-tauri/Cargo.toml`
|
||||||
|
|
||||||
|
- [ ] Add URL de download do Hugging Face e resolução de diretório local.
|
||||||
|
- [ ] Add validação por existência e tamanho mínimo/esperado.
|
||||||
|
- [ ] Add comando `download_default_model` com escrita em arquivo temporário e rename atômico.
|
||||||
|
- [ ] Testar status notInstalled/ready sem baixar o arquivo real.
|
||||||
|
|
||||||
|
## Task 2: Captura real de áudio
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src-tauri/src/audio/mod.rs`
|
||||||
|
- Modify: `src-tauri/src/app_state.rs`
|
||||||
|
- Modify: `src-tauri/src/ui_commands.rs`
|
||||||
|
- Modify: `src-tauri/Cargo.toml`
|
||||||
|
|
||||||
|
- [ ] Implementar gravador CPAL que escreve WAV via hound.
|
||||||
|
- [ ] Guardar stream ativo no estado para manter gravação viva.
|
||||||
|
- [ ] Stop finaliza WAV e retorna caminho.
|
||||||
|
- [ ] Manter teste unitário de máquina de estados.
|
||||||
|
|
||||||
|
## Task 3: Execução CrispASR
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src-tauri/src/transcription/crispasr.rs`
|
||||||
|
- Modify: `src-tauri/src/ui_commands.rs`
|
||||||
|
- Modify: `src-tauri/tauri.conf.json`
|
||||||
|
|
||||||
|
- [ ] Resolver binário via variável/env/config e depois sidecar empacotado.
|
||||||
|
- [ ] Executar `crispasr -m <model> -f <audio>`.
|
||||||
|
- [ ] Capturar stdout/stderr e retornar erro claro.
|
||||||
|
- [ ] Parsear texto simples ou JSON quando disponível.
|
||||||
|
|
||||||
|
## Task 4: Hotkey global
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src-tauri/Cargo.toml`
|
||||||
|
- Modify: `src-tauri/src/lib.rs`
|
||||||
|
|
||||||
|
- [ ] Adicionar `tauri-plugin-global-shortcut`.
|
||||||
|
- [ ] Registrar `Ctrl+Alt+Space` no setup.
|
||||||
|
- [ ] Ao pressionar, mostrar/focar janela e emitir evento `hotkey-toggle-recording` para a UI.
|
||||||
|
|
||||||
|
## Task 5: Inserção/cópia cross-platform
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src-tauri/src/injection/mod.rs`
|
||||||
|
- Modify: `src-tauri/src/ui_commands.rs`
|
||||||
|
- Modify: `src-tauri/Cargo.toml`
|
||||||
|
|
||||||
|
- [ ] Adicionar clipboard backend com `arboard`.
|
||||||
|
- [ ] Implementar `request_insert_text` que sempre coloca o texto no clipboard.
|
||||||
|
- [ ] Retornar mensagens por plataforma/display server.
|
||||||
|
- [ ] Manter Wayland como parcial/manual.
|
||||||
|
|
||||||
|
## Task 6: UI, CI e docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app.ts`
|
||||||
|
- Modify: `src/app.test.ts`
|
||||||
|
- Modify: `.github/workflows/*.yml`
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] Mostrar status de modelo e botão de download.
|
||||||
|
- [ ] Reagir ao evento de hotkey.
|
||||||
|
- [ ] Atualizar CI Linux com `libasound2-dev`.
|
||||||
|
- [ ] Rodar verificação final completa.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run test -- --run
|
||||||
|
npm run build
|
||||||
|
cargo test --manifest-path src-tauri/Cargo.toml
|
||||||
|
npm run tauri:build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: todos passam; build Windows gera MSI/NSIS no ambiente atual.
|
||||||
Generated
+772
-16
File diff suppressed because it is too large.
Load diff
@@ -17,4 +17,9 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-global-shortcut = "2"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||||
|
cpal = "0.15.3"
|
||||||
|
hound = "3.5"
|
||||||
|
arboard = "3.6"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -11,6 +12,7 @@ pub struct ManagedAppState {
|
|||||||
pub settings: Mutex<AppSettings>,
|
pub settings: Mutex<AppSettings>,
|
||||||
pub recording: Mutex<RecordingSession>,
|
pub recording: Mutex<RecordingSession>,
|
||||||
pub transcript: Mutex<TranscriptDraft>,
|
pub transcript: Mutex<TranscriptDraft>,
|
||||||
|
pub model_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -35,6 +37,7 @@ impl Default for ManagedAppState {
|
|||||||
settings: Mutex::new(AppSettings::default()),
|
settings: Mutex::new(AppSettings::default()),
|
||||||
recording: Mutex::new(RecordingSession::default()),
|
recording: Mutex::new(RecordingSession::default()),
|
||||||
transcript: Mutex::new(TranscriptDraft::default()),
|
transcript: Mutex::new(TranscriptDraft::default()),
|
||||||
|
model_dir: default_model_dir(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,11 +65,17 @@ impl ManagedAppState {
|
|||||||
settings,
|
settings,
|
||||||
recording_status,
|
recording_status,
|
||||||
transcript,
|
transcript,
|
||||||
models: model_statuses(),
|
models: model_statuses(&self.model_dir),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_model_dir() -> PathBuf {
|
||||||
|
std::env::current_dir()
|
||||||
|
.unwrap_or_else(|_| std::env::temp_dir())
|
||||||
|
.join("models")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ pub enum AppError {
|
|||||||
Unsupported(String),
|
Unsupported(String),
|
||||||
#[error("erro de configuração: {0}")]
|
#[error("erro de configuração: {0}")]
|
||||||
Configuration(String),
|
Configuration(String),
|
||||||
|
#[error("erro de E/S: {0}")]
|
||||||
|
Io(String),
|
||||||
|
#[error("erro de rede: {0}")]
|
||||||
|
Network(String),
|
||||||
|
#[error("modelo offline inválido: {0}")]
|
||||||
|
InvalidModel(String),
|
||||||
|
#[error("erro de áudio: {0}")]
|
||||||
|
Audio(String),
|
||||||
|
#[error("transcrição falhou: {0}")]
|
||||||
|
Transcription(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<AppError> for String {
|
impl From<AppError> for String {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub fn run() {
|
|||||||
ui_commands::copy_ready_text,
|
ui_commands::copy_ready_text,
|
||||||
ui_commands::request_insert_text,
|
ui_commands::request_insert_text,
|
||||||
ui_commands::list_models,
|
ui_commands::list_models,
|
||||||
|
ui_commands::download_default_model_command,
|
||||||
ui_commands::get_settings,
|
ui_commands::get_settings,
|
||||||
ui_commands::save_settings
|
ui_commands::save_settings
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
|
pub const DEFAULT_MODEL_ID: &str = "parakeet-tdt-0.6b-v3-q8_0";
|
||||||
|
pub const DEFAULT_MODEL_URL: &str = "https://huggingface.co/cstr/parakeet-tdt-0.6b-v3-GGUF/resolve/main/parakeet-tdt-0.6b-v3-q8_0.gguf";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OfflineModel {
|
||||||
|
pub id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub repository: String,
|
||||||
|
pub filename: String,
|
||||||
|
pub quantization: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub download_url: String,
|
||||||
|
pub languages: Vec<String>,
|
||||||
|
pub recommended_for_simple_pc: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum ModelInstallStatus {
|
||||||
|
NotInstalled,
|
||||||
|
Ready,
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelWithStatus {
|
||||||
|
pub model: OfflineModel,
|
||||||
|
pub status: ModelInstallStatus,
|
||||||
|
pub local_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelDownloadResult {
|
||||||
|
pub model_id: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub bytes_written: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn catalog() -> Vec<OfflineModel> {
|
||||||
|
vec![OfflineModel {
|
||||||
|
id: DEFAULT_MODEL_ID.to_string(),
|
||||||
|
display_name: "Parakeet TDT 0.6B v3 Q8".to_string(),
|
||||||
|
repository: "cstr/parakeet-tdt-0.6b-v3-GGUF".to_string(),
|
||||||
|
filename: "parakeet-tdt-0.6b-v3-q8_0.gguf".to_string(),
|
||||||
|
quantization: "Q8_0".to_string(),
|
||||||
|
size_bytes: 745_121_600,
|
||||||
|
download_url: DEFAULT_MODEL_URL.to_string(),
|
||||||
|
languages: vec![
|
||||||
|
"bg", "hr", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el", "hu", "it", "lv",
|
||||||
|
"lt", "mt", "pl", "pt", "ro", "sk", "sl", "es", "sv", "ru", "uk",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect(),
|
||||||
|
recommended_for_simple_pc: true,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn model_statuses(model_dir: &Path) -> Vec<ModelWithStatus> {
|
||||||
|
catalog()
|
||||||
|
.into_iter()
|
||||||
|
.map(|model| {
|
||||||
|
let path = model_dir.join(&model.filename);
|
||||||
|
let status = status_for_path(&path, model.size_bytes);
|
||||||
|
ModelWithStatus {
|
||||||
|
model,
|
||||||
|
local_path: path.exists().then_some(path),
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status_for_path(path: &Path, expected_size: u64) -> ModelInstallStatus {
|
||||||
|
match fs::metadata(path) {
|
||||||
|
Ok(metadata) if metadata.len() >= expected_size => ModelInstallStatus::Ready,
|
||||||
|
Ok(metadata) => ModelInstallStatus::Error(format!(
|
||||||
|
"arquivo incompleto: {} de {} bytes",
|
||||||
|
metadata.len(), expected_size
|
||||||
|
)),
|
||||||
|
Err(_) => ModelInstallStatus::NotInstalled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_model() -> OfflineModel {
|
||||||
|
catalog()
|
||||||
|
.into_iter()
|
||||||
|
.find(|model| model.id == DEFAULT_MODEL_ID)
|
||||||
|
.expect("catálogo deve conter o modelo padrão")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_model_path(model_dir: &Path) -> PathBuf {
|
||||||
|
model_dir.join(default_model().filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn download_default_model(model_dir: &Path) -> AppResult<ModelDownloadResult> {
|
||||||
|
let model = default_model();
|
||||||
|
fs::create_dir_all(model_dir).map_err(|error| AppError::Io(error.to_string()))?;
|
||||||
|
|
||||||
|
let final_path = model_dir.join(&model.filename);
|
||||||
|
let temp_path = model_dir.join(format!("{}.partial", model.filename));
|
||||||
|
|
||||||
|
let mut response = reqwest::blocking::get(&model.download_url)
|
||||||
|
.map_err(|error| AppError::Network(error.to_string()))?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|error| AppError::Network(error.to_string()))?;
|
||||||
|
|
||||||
|
let mut file = fs::File::create(&temp_path).map_err(|error| AppError::Io(error.to_string()))?;
|
||||||
|
let mut buffer = [0_u8; 1024 * 128];
|
||||||
|
let mut bytes_written = 0_u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let read = response
|
||||||
|
.read(&mut buffer)
|
||||||
|
.map_err(|error| AppError::Network(error.to_string()))?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
file.write_all(&buffer[..read])
|
||||||
|
.map_err(|error| AppError::Io(error.to_string()))?;
|
||||||
|
bytes_written += read as u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
file.flush().map_err(|error| AppError::Io(error.to_string()))?;
|
||||||
|
|
||||||
|
if bytes_written < model.size_bytes {
|
||||||
|
let _ = fs::remove_file(&temp_path);
|
||||||
|
return Err(AppError::InvalidModel(format!(
|
||||||
|
"download incompleto: {bytes_written} de {} bytes",
|
||||||
|
model.size_bytes
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::rename(&temp_path, &final_path).map_err(|error| AppError::Io(error.to_string()))?;
|
||||||
|
|
||||||
|
Ok(ModelDownloadResult {
|
||||||
|
model_id: model.id,
|
||||||
|
path: final_path,
|
||||||
|
bytes_written,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_contains_parakeet_q8_model() {
|
||||||
|
let model = default_model();
|
||||||
|
|
||||||
|
assert_eq!(model.repository, "cstr/parakeet-tdt-0.6b-v3-GGUF");
|
||||||
|
assert_eq!(model.filename, "parakeet-tdt-0.6b-v3-q8_0.gguf");
|
||||||
|
assert_eq!(model.quantization, "Q8_0");
|
||||||
|
assert_eq!(model.download_url, DEFAULT_MODEL_URL);
|
||||||
|
assert!(model.languages.contains(&"pt".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_detects_ready_model_by_size() {
|
||||||
|
let unique = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let dir = std::env::temp_dir().join(format!("transcript-model-test-{unique}"));
|
||||||
|
fs::create_dir_all(&dir).unwrap_or_else(|error| panic!("create temp dir: {error}"));
|
||||||
|
let path = dir.join("model.gguf");
|
||||||
|
fs::write(&path, [1_u8, 2, 3, 4]).unwrap_or_else(|error| panic!("write model: {error}"));
|
||||||
|
|
||||||
|
assert_eq!(status_for_path(&path, 4), ModelInstallStatus::Ready);
|
||||||
|
assert!(matches!(status_for_path(&path, 5), ModelInstallStatus::Error(_)));
|
||||||
|
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
let _ = fs::remove_dir(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ use tauri::State;
|
|||||||
|
|
||||||
use crate::app_state::{AppSnapshot, ManagedAppState};
|
use crate::app_state::{AppSnapshot, ManagedAppState};
|
||||||
use crate::injection::{FallbackInjector, InsertMethod, InsertRequest, InsertResult, TextInjector};
|
use crate::injection::{FallbackInjector, InsertMethod, InsertRequest, InsertResult, TextInjector};
|
||||||
use crate::models::{model_statuses, ModelWithStatus};
|
use crate::models::{download_default_model, model_statuses, ModelDownloadResult, ModelWithStatus};
|
||||||
use crate::settings::AppSettings;
|
use crate::settings::AppSettings;
|
||||||
use crate::transcription::crispasr::{CrispAsrConfig, CrispAsrTranscriber};
|
use crate::transcription::crispasr::{CrispAsrConfig, CrispAsrTranscriber};
|
||||||
use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionOptions};
|
use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionOptions};
|
||||||
@@ -109,8 +109,15 @@ pub(crate) fn request_insert_text(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn list_models() -> Vec<ModelWithStatus> {
|
pub(crate) fn list_models(state: State<'_, ManagedAppState>) -> Vec<ModelWithStatus> {
|
||||||
model_statuses()
|
model_statuses(&state.model_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn download_default_model_command(
|
||||||
|
state: State<'_, ManagedAppState>,
|
||||||
|
) -> Result<ModelDownloadResult, String> {
|
||||||
|
download_default_model(&state.model_dir).map_err(String::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
Reference in new issue
Block a user