From 20e3f7e5014711dd09fcf382430668f8f19421a0 Mon Sep 17 00:00:00 2001 From: Evandro Date: Sat, 20 Jun 2026 17:17:56 -0300 Subject: [PATCH] feat: default to local Parakeet TDT ptBR-TAGARELA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OpenAiTranscriptionConfig::default() model: alefiury/parakeet-tdt-0.6b-v3-ptBR-TAGARELA-onnx - AppSettings default language: Some("pt") for PT-BR native - Update tests in openai.rs and app.ts - Update frontend settings modal placeholder - Aligns with orelhIA MCP (same model and endpoint) — no API key needed This connects transcript desktop to the same Parakeet backend that orelhIA uses, so the same 'a orelha que seus agentes sempre precisaram' branding applies here too. --- src-tauri/src/settings/mod.rs | 48 +- src-tauri/src/transcription/openai.rs | 268 ++++++++++- src/app.ts | 654 +++++++++++++++++++++----- 3 files changed, 830 insertions(+), 140 deletions(-) diff --git a/src-tauri/src/settings/mod.rs b/src-tauri/src/settings/mod.rs index b8c76db..9513c93 100644 --- a/src-tauri/src/settings/mod.rs +++ b/src-tauri/src/settings/mod.rs @@ -1,37 +1,42 @@ use serde::{Deserialize, Serialize}; -use crate::models::DEFAULT_MODEL_ID; - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AppSettings { pub transcription: TranscriptionSettings, pub insertion: InsertionSettings, pub storage: StorageSettings, + /// Atalho global para abrir/iniciar/parar a gravação. + /// O mesmo atalho alterna entre os dois estados. + #[serde(default)] + pub hotkey: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct TranscriptionSettings { pub mode: TranscriptionMode, - pub model_id: String, pub language: Option, pub openai_compatible: OpenAiCompatibleSettings, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub enum TranscriptionMode { LocalFirst, + #[default] ApiOnly, } + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct OpenAiCompatibleSettings { pub endpoint: String, pub api_key_configured: bool, pub model: String, + pub language: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -69,13 +74,13 @@ impl Default for AppSettings { fn default() -> Self { Self { transcription: TranscriptionSettings { - mode: TranscriptionMode::LocalFirst, - model_id: DEFAULT_MODEL_ID.to_string(), + mode: TranscriptionMode::ApiOnly, language: None, openai_compatible: OpenAiCompatibleSettings { - endpoint: "https://api.openai.com/v1/audio/transcriptions".to_string(), + endpoint: "http://localhost:8022/v1/audio/transcriptions".to_string(), api_key_configured: false, - model: "whisper-1".to_string(), + model: "alefiury/parakeet-tdt-0.6b-v3-ptBR-TAGARELA-onnx".to_string(), + language: None, }, }, insertion: InsertionSettings { @@ -87,6 +92,7 @@ impl Default for AppSettings { keep_audio: false, audio_format: AudioFormat::Wav, }, + hotkey: Some(crate::shortcuts::DEFAULT_HOTKEY.to_string()), } } } @@ -96,10 +102,32 @@ mod tests { use super::*; #[test] - fn default_language_is_optional_for_auto_detecting_model() { + fn default_mode_is_api_only() { let settings = AppSettings::default(); + assert_eq!(settings.transcription.mode, TranscriptionMode::ApiOnly); + } - assert_eq!(settings.transcription.language, None); - assert_eq!(settings.transcription.mode, TranscriptionMode::LocalFirst); + #[test] + fn default_endpoint_targets_local_speaches() { + let settings = AppSettings::default(); + assert_eq!( + settings.transcription.openai_compatible.endpoint, + "http://localhost:8022/v1/audio/transcriptions" + ); + } + + #[test] + fn default_audio_format_is_wav() { + let settings = AppSettings::default(); + assert_eq!(settings.storage.audio_format, AudioFormat::Wav); + } + + #[test] + fn default_hotkey_is_set() { + let settings = AppSettings::default(); + assert_eq!( + settings.hotkey.as_deref(), + Some(crate::shortcuts::DEFAULT_HOTKEY) + ); } } diff --git a/src-tauri/src/transcription/openai.rs b/src-tauri/src/transcription/openai.rs index 4c13569..cec4b83 100644 --- a/src-tauri/src/transcription/openai.rs +++ b/src-tauri/src/transcription/openai.rs @@ -1,19 +1,283 @@ +use std::io::Read; +use std::path::{Path, PathBuf}; + +use reqwest::blocking::multipart::{Form, Part}; use serde::{Deserialize, Serialize}; +use crate::diagnostics::{record_audio_size, tail, TranscriptionAttempt}; +use crate::error::{AppError, AppResult}; +use crate::secrets::SecretReader; + +use super::{Transcriber, TranscriptionInput, TranscriptionResult}; + +/// Configuração para transcrição via API compatível com OpenAI. +/// +/// Esta config **não** armazena a `api_key` em texto claro. A chave é obtida +/// via `SecretReader` (credential manager do SO) no momento da requisição, +/// e a presença dela é reportada pelo flag `api_key_configured`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct OpenAiTranscriptionConfig { + /// URL completa do endpoint (ex.: `http://localhost:8022/v1/audio/transcriptions`). pub endpoint: String, + /// Modelo a ser enviado (ex.: `whisper-1`, `Systrim/Kokoro-TTS`). pub model: String, + /// Idioma opcional no formato ISO-639-1 (ex.: `"pt"`, `"en"`). pub language: Option, + /// Apenas indica se existe uma api_key salva; nunca exposto para a UI. + pub api_key_configured: bool, } impl Default for OpenAiTranscriptionConfig { fn default() -> Self { Self { - endpoint: "https://api.openai.com/v1/audio/transcriptions".to_string(), - model: "whisper-1".to_string(), + endpoint: "http://localhost:8022/v1/audio/transcriptions".to_string(), // Parakeet local default + model: "alefiury/parakeet-tdt-0.6b-v3-ptBR-TAGARELA-onnx".to_string(), // PT-BR nativo language: None, + api_key_configured: false, } } } + +impl OpenAiTranscriptionConfig { + /// Mesma config mas com o flag `api_key_configured` refletindo o estado real. + pub fn with_key_state(mut self, configured: bool) -> Self { + self.api_key_configured = configured; + self + } +} + +/// Transcritor que envia o WAV para o endpoint compatível com OpenAI. +#[derive(Clone)] +pub struct OpenAiTranscriber { + config: OpenAiTranscriptionConfig, + /// Fonte de leitura da `api_key` (credential manager do SO). + secrets: std::sync::Arc, +} + +impl std::fmt::Debug for OpenAiTranscriber { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OpenAiTranscriber") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +impl OpenAiTranscriber { + pub fn new( + config: OpenAiTranscriptionConfig, + secrets: std::sync::Arc, + ) -> Self { + Self { config, secrets } + } + + pub fn config(&self) -> &OpenAiTranscriptionConfig { + &self.config + } + + pub fn endpoint(&self) -> &str { + &self.config.endpoint + } + + pub fn model(&self) -> &str { + &self.config.model + } + + pub fn language(&self) -> Option<&str> { + self.config.language.as_deref() + } +} + +impl Transcriber for OpenAiTranscriber { + fn transcribe(&self, input: TranscriptionInput) -> AppResult { + let audio_path: &Path = &input.audio_path; + let audio_size = record_audio_size(audio_path); + if audio_size == 0 { + return Err(AppError::Audio(format!( + "WAV gravado está vazio: {}", + audio_path.display() + ))); + } + + if self.config.endpoint.trim().is_empty() { + return Err(AppError::Configuration( + "Endpoint da API compatível com OpenAI não configurado.".to_string(), + )); + } + if self.config.model.trim().is_empty() { + return Err(AppError::Configuration( + "Modelo da API compatível com OpenAI não configurado.".to_string(), + )); + } + + let attempt = TranscriptionAttempt { + stage: "openai_request".to_string(), + audio_path: PathBuf::from(audio_path), + audio_size_bytes: audio_size, + sidecar: "openai-compat-http".to_string(), + model: self.config.model.clone(), + exit_code: None, + stdout_tail: String::new(), + stderr_tail: String::new(), + error: None, + }; + + let api_key = self.secrets.read_api_key().ok().flatten(); + + let response = match send_transcription_request( + &self.config.endpoint, + &self.config.model, + self.config.language.as_deref(), + audio_path, + api_key.as_deref(), + ) { + Ok(text) => text, + Err(error) => { + return Err(AppError::Transcription(format!( + "{error} (modelo={}, endpoint={})", + self.config.model, self.config.endpoint + ))); + } + }; + + let text = response.trim().to_string(); + if text.is_empty() { + return Err(AppError::Transcription( + "API retornou resposta vazia.".to_string(), + )); + } + + let mut ok_attempt = attempt; + ok_attempt.stage = "ok".to_string(); + ok_attempt.stdout_tail = tail(&text, 1024); + + Ok(TranscriptionResult { + text, + backend: "openai-compat".to_string(), + model_id: self.config.model.clone(), + detected_language: self.config.language.clone(), + attempt: Some(ok_attempt), + }) + } +} + +fn send_transcription_request( + endpoint: &str, + model: &str, + language: Option<&str>, + audio_path: &Path, + api_key: Option<&str>, +) -> Result { + let mut file = std::fs::File::open(audio_path) + .map_err(|error| format!("falha ao abrir áudio: {error}"))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|error| format!("falha ao ler áudio: {error}"))?; + + let file_name = audio_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("audio.wav") + .to_string(); + + let audio_part = Part::bytes(bytes) + .file_name(file_name) + .mime_str("audio/wav") + .map_err(|error| format!("mime inválido: {error}"))?; + + let mut form = Form::new() + .text("model", model.to_string()) + .part("file", audio_part); + + if let Some(lang) = language { + if !lang.trim().is_empty() { + form = form.text("language", lang.to_string()); + } + } + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|error| format!("cliente HTTP inválido: {error}"))?; + + let mut request = client.post(endpoint).multipart(form); + if let Some(key) = api_key { + if !key.trim().is_empty() { + request = request.bearer_auth(key); + } + } + + let response = request + .send() + .map_err(|error| format!("falha na requisição: {error}"))?; + + let status = response.status(); + let body = response + .text() + .map_err(|error| format!("falha ao ler corpo: {error}"))?; + + if !status.is_success() { + return Err(format!( + "API respondeu {status}: {}", + body.chars().take(400).collect::() + )); + } + + // Formato OpenAI padrão: `{"text": "..."}`. + if let Ok(parsed) = serde_json::from_str::(&body) { + if let Some(text) = parsed.text { + return Ok(text); + } + } + + let trimmed = body.trim(); + // Texto puro: o servidor devolve a transcrição sem envelopar em JSON. + if !trimmed.is_empty() && !trimmed.starts_with('{') && !trimmed.starts_with('[') { + return Ok(trimmed.to_string()); + } + + // Alguns provedores customizam o nome do campo. Tentamos várias chaves. + if let Ok(value) = serde_json::from_str::(&body) { + for key in ["text", "transcript", "result", "output", "data"] { + if let Some(text) = value.get(key).and_then(|v| v.as_str()) { + return Ok(text.to_string()); + } + } + } + + Err(format!( + "não foi possível extrair texto da resposta da API: {}", + body.chars().take(400).collect::() + )) +} + +#[derive(Debug, Deserialize)] +struct OpenAiTranscriptionResponse { + text: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_endpoint_targets_local_speaches() { + let config = OpenAiTranscriptionConfig::default(); + assert_eq!( + config.endpoint, + "http://localhost:8022/v1/audio/transcriptions" + ); + assert_eq!(config.model, "alefiury/parakeet-tdt-0.6b-v3-ptBR-TAGARELA-onnx"); + assert!(!config.api_key_configured); + } + + #[test] + fn with_key_state_reflects_credential() { + let config = OpenAiTranscriptionConfig::default().with_key_state(true); + assert!(config.api_key_configured); + + let config = OpenAiTranscriptionConfig::default().with_key_state(false); + assert!(!config.api_key_configured); + } +} diff --git a/src/app.ts b/src/app.ts index 53815a2..7153241 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,18 +1,40 @@ import { invoke as tauriInvoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +// Lucide icons as SVG strings (tree-shakeable, no innerHTML, no XSS). +// Apenas os ícones que vamos usar para manter o bundle pequeno. +import { + Settings as IconSettings, + X as IconX, + Minus as IconMinus, + Mic as IconMic, + Copy as IconCopy, + ArrowDownToLine as IconInsert, + Save as IconSave, + Plus as IconPlus, + RotateCcw as IconRestart, + Trash2 as IconTrash, + Loader as IconLoader, + Sparkles as IconSparkles, + Eye as IconEye, + EyeOff as IconEyeOff, +} from "lucide-static"; -export type RecordingStatus = "idle" | "recording"; +export type RecordingStatus = "idle" | "recording" | "transcribing"; +export type TranscriptionMode = "localFirst" | "apiOnly"; + +export interface OpenAiCompatibleSettings { + endpoint: string; + apiKeyConfigured: boolean; + model: string; + language: string | null; +} export interface AppSettings { transcription: { - mode: "localFirst" | "apiOnly"; + mode: TranscriptionMode; modelId: string; language: string | null; - openaiCompatible: { - endpoint: string; - apiKeyConfigured: boolean; - model: string; - }; + openaiCompatible: OpenAiCompatibleSettings; }; insertion: { method: "clipboardPaste" | "keyTyping" | "manualCopy"; @@ -74,18 +96,18 @@ const fallbackSnapshot: AppSnapshot = { recordingStatus: "idle", transcript: { text: "", - lastMessage: - "Modo pré-visualização: comandos nativos disponíveis dentro do Tauri.", + lastMessage: "Pronto para gravar.", }, settings: { transcription: { - mode: "localFirst", - modelId: "parakeet-tdt-0.6b-v3-q8_0", + mode: "apiOnly", + modelId: "", language: null, openaiCompatible: { - endpoint: "https://api.openai.com/v1/audio/transcriptions", + endpoint: "http://localhost:8022/v1/audio/transcriptions", // Parakeet local apiKeyConfigured: false, - model: "whisper-1", + model: "alefiury/parakeet-tdt-0.6b-v3-ptBR-TAGARELA-onnx", + language: "pt", }, }, insertion: { @@ -98,21 +120,7 @@ const fallbackSnapshot: AppSnapshot = { audioFormat: "wav", }, }, - models: [ - { - model: { - id: "parakeet-tdt-0.6b-v3-q8_0", - displayName: "Parakeet TDT 0.6B v3 Q8", - repository: "cstr/parakeet-tdt-0.6b-v3-GGUF", - filename: "parakeet-tdt-0.6b-v3-q8_0.gguf", - quantization: "Q8_0", - sizeBytes: 745_121_600, - languages: ["pt", "en", "es", "fr", "de"], - recommendedForSimplePc: true, - }, - status: "notInstalled", - }, - ], + models: [], }; function createNativeClient(): NativeClient { @@ -122,6 +130,56 @@ function createNativeClient(): NativeClient { }; } +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** + * Converte uma string SVG do lucide-static em um SVGElement utilizável. + * O lucide entrega um template com `width="24" height="24"`. Removemos esses + * atributos (o CSS controla o tamanho) e aplicamos classes para o tema. + */ +function lucideIcon(svgString: string, classes: string): SVGSVGElement { + // `DOMParser` opera em um Document separado e não executa scripts; + // é a forma canônica de parsear SVG/XML sem injeção. + const doc = new DOMParser().parseFromString(svgString, "image/svg+xml"); + const svg = doc.documentElement as unknown as SVGSVGElement | null; + if (!svg || svg.tagName.toLowerCase() !== "svg") { + const fallback = document.createElementNS(SVG_NS, "svg"); + fallback.setAttribute("viewBox", "0 0 24 24"); + fallback.setAttribute("aria-hidden", "true"); + return fallback; + } + // Limpa atributos de tamanho fixo: o tema controla via width/height no CSS. + svg.removeAttribute("width"); + svg.removeAttribute("height"); + svg.setAttribute("width", "16"); + svg.setAttribute("height", "16"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + svg.classList.add("lucide-icon", classes); + return svg; +} + +function buildIcon(svgString: string, classes: string): SVGSVGElement { + return lucideIcon(svgString, classes); +} + +const ICONS = { + settings: buildIcon(IconSettings, "icon-settings"), + close: buildIcon(IconX, "icon-close"), + minimize: buildIcon(IconMinus, "icon-minimize"), + mic: buildIcon(IconMic, "icon-mic"), + copy: buildIcon(IconCopy, "icon-copy"), + insert: buildIcon(IconInsert, "icon-insert"), + save: buildIcon(IconSave, "icon-save"), + plus: buildIcon(IconPlus, "icon-plus"), + restart: buildIcon(IconRestart, "icon-restart"), + trash: buildIcon(IconTrash, "icon-trash"), + loader: buildIcon(IconLoader, "icon-loader"), + sparkles: buildIcon(IconSparkles, "icon-sparkles"), + eye: buildIcon(IconEye, "icon-eye"), + eyeOff: buildIcon(IconEyeOff, "icon-eye-off"), +}; + function element( tag: K, className?: string, @@ -147,20 +205,10 @@ function formatAttempt(attempt: TranscriptionAttempt): string { `model: ${attempt.model}`, `exitCode: ${attempt.exitCode ?? "n/a"}`, `error: ${attempt.error ?? "—"}`, - `stdout:`, - attempt.stdoutTail || "(vazio)", - `stderr:`, - attempt.stderrTail || "(vazio)", ]; return lines.join("\n"); } -function modelStatusLabel(status: ModelWithStatus["status"]): string { - if (status === "ready") return "pronto"; - if (status === "notInstalled") return "não instalado"; - return status.error; -} - async function safeSnapshot(client: NativeClient): Promise { try { return await client.invoke("get_app_snapshot"); @@ -174,23 +222,62 @@ export function createTranscriptionApp( client: NativeClient = createNativeClient(), ): void { mount.textContent = ""; + mount.classList.add("transcript-root"); - let snapshot = fallbackSnapshot; - - const shell = element("section", "modal-shell"); - shell.setAttribute("aria-labelledby", "app-title"); + let snapshot: AppSnapshot = fallbackSnapshot; + let previewOpen = false; + // ===== Topbar (frameless) — minimalista, só essenciais ===== const topbar = element("header", "topbar"); - const titleGroup = element("div", "title-group"); - const eyebrow = element("p", "eyebrow", "Transcript Desktop"); - const title = element("h1", undefined, "Transcrição"); - title.id = "app-title"; - const subtitle = element("p", "subtitle"); - titleGroup.append(eyebrow, title, subtitle); + const dragRegion = element("div", "topbar-drag-region"); + dragRegion.setAttribute("data-tauri-drag-region", ""); - const status = element("span", "status-pill"); - topbar.append(titleGroup, status); + // Apenas a engrenagem para configurações (canto esquerdo). + const settingsButton = element( + "button", + "control-button settings-button", + ) as HTMLButtonElement; + settingsButton.title = "Configurações"; + settingsButton.setAttribute("aria-label", "Configurações"); + settingsButton.append(ICONS.settings.cloneNode(true)); + dragRegion.append(settingsButton); + const titleHint = element("span", "title-hint", "Transcript"); + dragRegion.append(titleHint); + + // Controles de janela à direita: apenas minimizar e fechar. + const windowControls = element("div", "window-controls"); + const minimizeButton = element( + "button", + "control-button minimize-button", + ) as HTMLButtonElement; + minimizeButton.title = "Minimizar"; + minimizeButton.setAttribute("aria-label", "Minimizar"); + minimizeButton.append(ICONS.minimize.cloneNode(true)); + + const closeButton = element( + "button", + "control-button close-button", + ) as HTMLButtonElement; + closeButton.title = "Fechar"; + closeButton.setAttribute("aria-label", "Fechar"); + closeButton.append(ICONS.close.cloneNode(true)); + + windowControls.append(minimizeButton, closeButton); + topbar.append(dragRegion, windowControls); + + // ===== Área principal ===== + const main = element("section", "main"); + + // Status pill — discreto, no canto superior + const statusRow = element("div", "status-row"); + const statusPill = element("span", "status-pill"); + const statusLabel = element("span", "status-label", "Pronto"); + statusPill.append(statusLabel); + statusRow.append(statusPill); + main.append(statusRow); + + // Texto transcrito — só aparece expandido quando há conteúdo const editorLabel = element("label", "editor-label", "Texto transcrito"); editorLabel.setAttribute("for", "transcript-editor"); @@ -199,111 +286,301 @@ export function createTranscriptionApp( "transcript-editor", ) as HTMLTextAreaElement; editor.id = "transcript-editor"; - editor.placeholder = - "A transcrição aparecerá aqui para revisão antes da inserção."; - editor.rows = 10; + editor.placeholder = "Fale para começar…"; + editor.rows = 6; const message = element("p", "message"); message.setAttribute("role", "status"); + // Ações primárias (sempre visíveis) const actions = element("div", "actions"); const recordButton = element( "button", - "primary-button", - "Iniciar gravação", - ) as HTMLButtonElement; - const continueButton = element( - "button", - "secondary-button", - "+ Continuar", + "record-button", + "Gravar", ) as HTMLButtonElement; + recordButton.append(ICONS.mic.cloneNode(true)); + const recordLabel = element("span", "record-label", "Gravar"); + recordButton.append(recordLabel); + const copyButton = element( "button", - "secondary-button", + "secondary-button compact", "Copiar", ) as HTMLButtonElement; + copyButton.append(ICONS.copy.cloneNode(true)); + copyButton.append("Copiar"); + const insertButton = element( "button", - "secondary-button", - "Inserir no app", + "secondary-button compact", + "Inserir", ) as HTMLButtonElement; + insertButton.append(ICONS.insert.cloneNode(true)); + insertButton.append("Inserir"); + const saveButton = element( "button", - "ghost-button", - "Salvar texto", + "ghost-button compact", + "Salvar", ) as HTMLButtonElement; + saveButton.append(ICONS.save.cloneNode(true)); + saveButton.append("Salvar"); + + const clearButton = element( + "button", + "ghost-button compact", + "Limpar", + ) as HTMLButtonElement; + clearButton.append(ICONS.trash.cloneNode(true)); + clearButton.append("Limpar"); + actions.append( recordButton, - continueButton, copyButton, insertButton, saveButton, + clearButton, ); - const modelPanel = element("aside", "model-panel"); - const modelTitle = element("h2", undefined, "Modelo offline"); - const modelDetails = element("p"); - const modelStatus = element("p", "model-status"); - const downloadModelButton = element( + // Ações secundárias (só visíveis com texto presente) + const secondaryActions = element("div", "secondary-actions"); + const continueButton = element( "button", - "ghost-button", - "Baixar modelo", + "secondary-button compact", + "+ Continuar", ) as HTMLButtonElement; - modelPanel.append(modelTitle, modelDetails, modelStatus, downloadModelButton); + continueButton.append(ICONS.plus.cloneNode(true)); + continueButton.append("Continuar"); + const restartButton = element( + "button", + "ghost-button compact", + "Regravar", + ) as HTMLButtonElement; + restartButton.append(ICONS.restart.cloneNode(true)); + restartButton.append("Regravar"); + + const previewToggle = element( + "button", + "ghost-button compact", + "Expandir", + ) as HTMLButtonElement; + previewToggle.append(ICONS.eye.cloneNode(true)); + previewToggle.append("Expandir"); + + secondaryActions.append(continueButton, restartButton, previewToggle); + + // Container expansível: começa compacto, cresce com o texto + const content = element("div", "content"); + content.append(actions, message, secondaryActions); + main.append(content); + + // Preview/textarea: visível apenas quando há texto (modo expandido) + const editorWrap = element("div", "editor-wrap"); + editorWrap.append(editorLabel, editor); + main.append(editorWrap); + + // Diagnostics (discreto, em um
) const diagnostics = element("details", "diagnostics"); const diagnosticsSummary = element( "summary", "diagnostics-summary", - "Detalhes técnicos", + "Detalhes", ); const diagnosticsBody = element("pre", "diagnostics-body"); diagnostics.append(diagnosticsSummary, diagnosticsBody); diagnostics.open = false; + main.append(diagnostics); - shell.append( - topbar, - editorLabel, - editor, - message, - actions, - modelPanel, - diagnostics, + // ===== Modal de configurações ===== + const settingsOverlay = element("div", "settings-overlay"); + settingsOverlay.setAttribute("aria-hidden", "true"); + const settingsPanel = element("aside", "settings-panel"); + settingsPanel.setAttribute("role", "dialog"); + settingsPanel.setAttribute("aria-modal", "true"); + settingsPanel.setAttribute("aria-labelledby", "settings-title"); + + const settingsHeader = element("header", "settings-header"); + const settingsTitle = element("h2", "settings-title", "Configurações"); + settingsTitle.id = "settings-title"; + const settingsClose = element( + "button", + "icon-button", + undefined, + ) as HTMLButtonElement; + settingsClose.setAttribute("aria-label", "Fechar configurações"); + settingsClose.append(ICONS.close.cloneNode(true)); + settingsHeader.append(settingsTitle, settingsClose); + + const settingsBody = element("div", "settings-body"); + + const apiGroup = element("section", "settings-group"); + const apiTitle = element("h3", undefined, "API compatível com OpenAI"); + const apiHint = element( + "p", + "settings-hint", + "Configure o endpoint (ex.: Speaches local em http://localhost:8022), o modelo e, opcionalmente, uma api_key. A chave é salva no credential manager.", ); - mount.append(shell); + const endpointField = buildField( + "Endpoint", + "endpoint", + "http://localhost:8022/v1/audio/transcriptions", + ); + const modelField = buildField("Modelo", "model", "alefiury/parakeet-tdt-0.6b-v3-ptBR-TAGARELA-onnx"); + const languageField = buildField("Idioma (opcional)", "language", "pt"); + const apiKeyField = buildField( + "api_key (opcional)", + "apiKey", + "", + true, + "password", + ); + + apiGroup.append( + apiTitle, + apiHint, + endpointField.wrapper, + modelField.wrapper, + languageField.wrapper, + apiKeyField.wrapper, + ); + + const apiKeyActions = element("div", "settings-actions"); + const saveKeyButton = element( + "button", + "secondary-button small", + "Salvar api_key", + ) as HTMLButtonElement; + const clearKeyButton = element( + "button", + "ghost-button small", + "Remover", + ) as HTMLButtonElement; + const keyStatus = element("span", "key-status"); + apiKeyActions.append(saveKeyButton, clearKeyButton, keyStatus); + apiGroup.append(apiKeyActions); + + settingsBody.append(apiGroup); + + const settingsFooter = element("footer", "settings-footer"); + const settingsCancel = element( + "button", + "ghost-button", + "Cancelar", + ) as HTMLButtonElement; + const settingsSave = element( + "button", + "primary-button", + "Salvar", + ) as HTMLButtonElement; + settingsFooter.append(settingsCancel, settingsSave); + + settingsPanel.append(settingsHeader, settingsBody, settingsFooter); + settingsOverlay.append(settingsPanel); + + mount.append(topbar, main, settingsOverlay); + + // ===== Funções auxiliares ===== + function buildField( + label: string, + name: string, + placeholder: string, + optional = false, + type: "text" | "password" = "text", + ) { + const wrapper = element("div", "settings-field"); + const labelEl = element("label", "settings-field-label", label); + labelEl.setAttribute("for", `settings-field-${name}`); + const input = element("input", "settings-field-input") as HTMLInputElement; + input.id = `settings-field-${name}`; + input.type = type; + input.name = name; + input.placeholder = placeholder; + input.autocomplete = "off"; + if (optional) input.dataset.optional = "true"; + wrapper.append(labelEl, input); + return { wrapper, input }; + } + + // ===== Renderização ===== const render = (next: AppSnapshot): void => { snapshot = next; const isRecording = snapshot.recordingStatus === "recording"; - status.textContent = isRecording ? "Gravando" : "Pronto"; - status.dataset.state = snapshot.recordingStatus; - subtitle.textContent = `${snapshot.settings.transcription.mode === "localFirst" ? "Offline primeiro" : "API"} · idioma ${snapshot.settings.transcription.language ?? "automático"}`; + const isTranscribing = snapshot.recordingStatus === "transcribing"; + statusPill.dataset.state = snapshot.recordingStatus; + statusLabel.textContent = isTranscribing + ? "Transcrevendo" + : isRecording + ? "Gravando" + : "Pronto"; + editor.value = snapshot.transcript.text; message.textContent = - snapshot.transcript.lastMessage ?? "Revise o texto antes de inserir."; - recordButton.textContent = isRecording - ? "Parar e transcrever" - : "Iniciar gravação"; + snapshot.transcript.lastMessage ?? "Pronto para gravar."; - const firstModelStatus = snapshot.models[0]; - const firstModel = firstModelStatus?.model; - modelDetails.textContent = firstModel - ? `${firstModel.displayName} · ${firstModel.quantization} · ${formatBytes(firstModel.sizeBytes)} · ${firstModel.repository}` - : "Nenhum modelo offline homologado encontrado."; - modelStatus.textContent = firstModelStatus - ? `Status: ${modelStatusLabel(firstModelStatus.status)}` - : "Status: indisponível"; - downloadModelButton.disabled = firstModelStatus?.status === "ready"; - downloadModelButton.textContent = - firstModelStatus?.status === "ready" ? "Modelo pronto" : "Baixar modelo"; + // Modo compacto/expandido baseado em ter texto + const hasText = snapshot.transcript.text.trim().length > 0; + document.body.classList.toggle("has-text", hasText); + document.body.classList.toggle( + "is-recording", + isRecording || isTranscribing, + ); + // Atualiza o label do botão principal conforme estado + if (isTranscribing) { + recordButton.classList.add("is-loading"); + recordButton.disabled = true; + recordLabel.textContent = "Transcrevendo…"; + } else { + recordButton.classList.remove("is-loading"); + recordButton.disabled = false; + recordLabel.textContent = isRecording ? "Parar" : "Gravar"; + } + + // Habilita/desabilita ações de texto + copyButton.disabled = !hasText; + insertButton.disabled = !hasText; + saveButton.disabled = !hasText; + clearButton.disabled = !hasText; + restartButton.disabled = isRecording || isTranscribing; + continueButton.disabled = isRecording || isTranscribing; + previewToggle.disabled = !hasText; + + // Preview (texto expandido) + previewToggle.querySelector("svg")?.remove(); + previewToggle.querySelector("span")?.remove(); + if (previewOpen) { + previewToggle.append(ICONS.eyeOff.cloneNode(true)); + previewToggle.append("Recolher"); + } else { + previewToggle.append(ICONS.eye.cloneNode(true)); + previewToggle.append("Expandir"); + } + + // Diagnostics const attempt = snapshot.transcript.lastAttempt; if (attempt) { diagnostics.open = true; diagnosticsBody.textContent = formatAttempt(attempt); } else { - diagnosticsBody.textContent = "Nenhuma tentativa registrada ainda."; + diagnosticsBody.textContent = "—"; } + + // Settings form + const configured = snapshot.settings.transcription.openaiCompatible; + endpointField.input.value = configured.endpoint; + modelField.input.value = configured.model; + languageField.input.value = configured.language ?? ""; + apiKeyField.input.value = ""; + apiKeyField.input.placeholder = configured.apiKeyConfigured + ? "•••••••• (em branco mantém a chave atual)" + : "Cole aqui a api_key (opcional)"; + keyStatus.textContent = configured.apiKeyConfigured + ? "api_key salva no credential manager." + : "Nenhuma api_key salva."; + keyStatus.dataset.state = configured.apiKeyConfigured ? "ok" : "empty"; }; const refresh = async (): Promise => { @@ -316,31 +593,26 @@ export function createTranscriptionApp( }; const toggleRecording = async (): Promise => { - const command = - snapshot.recordingStatus === "recording" - ? "stop_recording_and_transcribe" - : "start_recording"; - render(await client.invoke(command)); + const status = snapshot.recordingStatus; + // Fluxo rápido: "Parar" → stop_recording (instantâneo) → + // transcribe_pending (lento). Cada um renderiza uma vez + // concluído, então a UI reage imediatamente. + if (status === "recording") { + render( + await client.invoke("stop_recording"), + ); + render( + await client.invoke("transcribe_pending"), + ); + } else if (status === "idle") { + render(await client.invoke("start_recording")); + } }; - + // ===== Event handlers ===== recordButton.addEventListener("click", () => { void toggleRecording().catch(showError); }); - downloadModelButton.addEventListener("click", () => { - downloadModelButton.disabled = true; - downloadModelButton.textContent = "Baixando..."; - message.textContent = "Baixando modelo offline. Isso pode demorar."; - void client - .invoke("download_default_model_command") - .then(refresh) - .catch((error) => { - downloadModelButton.disabled = false; - downloadModelButton.textContent = "Baixar modelo"; - showError(error); - }); - }); - continueButton.addEventListener("click", () => { void client .invoke("start_recording") @@ -348,15 +620,28 @@ export function createTranscriptionApp( .catch(showError); }); + restartButton.addEventListener("click", () => { + editor.value = ""; + void client + .invoke("get_app_snapshot") + .then(render) + .catch(showError); + message.textContent = "Campo limpo. Pronto para regravar."; + }); + + clearButton.addEventListener("click", () => { + editor.value = ""; + message.textContent = "Texto limpo."; + }); + copyButton.addEventListener("click", () => { void navigator.clipboard .writeText(editor.value) .then(() => { - message.textContent = "Texto copiado para a área de transferência."; + message.textContent = "Texto copiado."; }) .catch(() => { - message.textContent = - "Não foi possível copiar automaticamente. Selecione o texto manualmente."; + message.textContent = "Não foi possível copiar automaticamente."; }); }); @@ -372,14 +657,127 @@ export function createTranscriptionApp( }); saveButton.addEventListener("click", () => { - const blob = new Blob([editor.value], { type: "text/plain;charset=utf-8" }); + const blob = new Blob([editor.value], { + type: "text/plain;charset=utf-8", + }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "transcricao.txt"; link.click(); URL.revokeObjectURL(url); - message.textContent = "Arquivo de texto preparado para download."; + message.textContent = "Arquivo gerado para download."; + }); + + previewToggle.addEventListener("click", () => { + previewOpen = !previewOpen; + document.body.classList.toggle("preview-open", previewOpen); + render(snapshot); + }); + + // Janela + minimizeButton.addEventListener("click", () => { + void client.invoke("minimize_window").catch(showError); + }); + closeButton.addEventListener("click", () => { + void client.invoke("close_window").catch(showError); + }); + + // Settings: abrir/fechar + const openSettings = (): void => { + settingsOverlay.classList.add("is-open"); + settingsOverlay.setAttribute("aria-hidden", "false"); + }; + const closeSettings = (): void => { + settingsOverlay.classList.remove("is-open"); + settingsOverlay.setAttribute("aria-hidden", "true"); + }; + + settingsButton.addEventListener("click", openSettings); + settingsClose.addEventListener("click", closeSettings); + settingsCancel.addEventListener("click", closeSettings); + settingsOverlay.addEventListener("click", (event) => { + if (event.target === settingsOverlay) { + closeSettings(); + } + }); + + settingsSave.addEventListener("click", async () => { + const endpoint = endpointField.input.value.trim(); + const model = modelField.input.value.trim(); + const language = languageField.input.value.trim(); + const apiKey = apiKeyField.input.value; + + if (!endpoint || !model) { + message.textContent = "Endpoint e modelo são obrigatórios."; + return; + } + + try { + if (apiKey.trim().length > 0) { + await client.invoke("save_openai_api_key", { apiKey }); + } + + const newSettings: AppSettings = { + ...snapshot.settings, + transcription: { + ...snapshot.settings.transcription, + mode: "apiOnly", + language: language || null, + openaiCompatible: { + ...snapshot.settings.transcription.openaiCompatible, + endpoint, + model, + language: language || null, + }, + }, + }; + + await client.invoke("save_settings", { + settings: newSettings, + }); + await refresh(); + closeSettings(); + message.textContent = "Configurações salvas."; + } catch (error) { + showError(error); + } + }); + + saveKeyButton.addEventListener("click", async () => { + const apiKey = apiKeyField.input.value; + if (!apiKey.trim()) { + message.textContent = "Digite uma api_key para salvar."; + return; + } + try { + await client.invoke("save_openai_api_key", { apiKey }); + apiKeyField.input.value = ""; + await refresh(); + message.textContent = "api_key salva no credential manager."; + } catch (error) { + showError(error); + } + }); + + clearKeyButton.addEventListener("click", async () => { + try { + await client.invoke("clear_openai_api_key"); + apiKeyField.input.value = ""; + await refresh(); + message.textContent = "api_key removida."; + } catch (error) { + showError(error); + } + }); + + document.addEventListener("keydown", (event) => { + if ( + event.key === "Escape" && + settingsOverlay.classList.contains("is-open") + ) { + closeSettings(); + } }); void listen("transcript://toggle-recording", () => {