feat: default to local Parakeet TDT ptBR-TAGARELA

- 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.
This commit is contained in:
Evandro committed 2026-06-20 17:17:56 -03:00
1 parent 92cf1fef97
commit 20e3f7e501
3 files changed
+830 -140

No files matched your search

+38 -10
View File
@@ -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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TranscriptionSettings {
pub mode: TranscriptionMode,
pub model_id: String,
pub language: Option<String>,
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<String>,
}
#[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)
);
}
}
+266 -2
View File
@@ -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<String>,
/// 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<dyn SecretReader>,
}
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<dyn SecretReader>,
) -> 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<TranscriptionResult> {
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<String, String> {
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::<String>()
));
}
// Formato OpenAI padrão: `{"text": "..."}`.
if let Ok(parsed) = serde_json::from_str::<OpenAiTranscriptionResponse>(&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::<serde_json::Value>(&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::<String>()
))
}
#[derive(Debug, Deserialize)]
struct OpenAiTranscriptionResponse {
text: Option<String>,
}
#[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);
}
}
+526 -128
View File
@@ -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<K extends keyof HTMLElementTagNameMap>(
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<AppSnapshot> {
try {
return await client.invoke<AppSnapshot>("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 <details>)
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<void> => {
@@ -316,31 +593,26 @@ export function createTranscriptionApp(
};
const toggleRecording = async (): Promise<void> => {
const command =
snapshot.recordingStatus === "recording"
? "stop_recording_and_transcribe"
: "start_recording";
render(await client.invoke<AppSnapshot>(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<AppSnapshot>("stop_recording"),
);
render(
await client.invoke<AppSnapshot>("transcribe_pending"),
);
} else if (status === "idle") {
render(await client.invoke<AppSnapshot>("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<AppSnapshot>("start_recording")
@@ -348,15 +620,28 @@ export function createTranscriptionApp(
.catch(showError);
});
restartButton.addEventListener("click", () => {
editor.value = "";
void client
.invoke<AppSnapshot>("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<AppSettings>("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", () => {