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:
1 parent
92cf1fef97
commit
20e3f7e501
3 files changed
+830
-140
No files matched your search
@@ -1,37 +1,42 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::models::DEFAULT_MODEL_ID;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
pub transcription: TranscriptionSettings,
|
pub transcription: TranscriptionSettings,
|
||||||
pub insertion: InsertionSettings,
|
pub insertion: InsertionSettings,
|
||||||
pub storage: StorageSettings,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct TranscriptionSettings {
|
pub struct TranscriptionSettings {
|
||||||
pub mode: TranscriptionMode,
|
pub mode: TranscriptionMode,
|
||||||
pub model_id: String,
|
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
pub openai_compatible: OpenAiCompatibleSettings,
|
pub openai_compatible: OpenAiCompatibleSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[derive(Default)]
|
||||||
pub enum TranscriptionMode {
|
pub enum TranscriptionMode {
|
||||||
LocalFirst,
|
LocalFirst,
|
||||||
|
#[default]
|
||||||
ApiOnly,
|
ApiOnly,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct OpenAiCompatibleSettings {
|
pub struct OpenAiCompatibleSettings {
|
||||||
pub endpoint: String,
|
pub endpoint: String,
|
||||||
pub api_key_configured: bool,
|
pub api_key_configured: bool,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
|
pub language: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -69,13 +74,13 @@ impl Default for AppSettings {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
transcription: TranscriptionSettings {
|
transcription: TranscriptionSettings {
|
||||||
mode: TranscriptionMode::LocalFirst,
|
mode: TranscriptionMode::ApiOnly,
|
||||||
model_id: DEFAULT_MODEL_ID.to_string(),
|
|
||||||
language: None,
|
language: None,
|
||||||
openai_compatible: OpenAiCompatibleSettings {
|
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,
|
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 {
|
insertion: InsertionSettings {
|
||||||
@@ -87,6 +92,7 @@ impl Default for AppSettings {
|
|||||||
keep_audio: false,
|
keep_audio: false,
|
||||||
audio_format: AudioFormat::Wav,
|
audio_format: AudioFormat::Wav,
|
||||||
},
|
},
|
||||||
|
hotkey: Some(crate::shortcuts::DEFAULT_HOTKEY.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,10 +102,32 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_language_is_optional_for_auto_detecting_model() {
|
fn default_mode_is_api_only() {
|
||||||
let settings = AppSettings::default();
|
let settings = AppSettings::default();
|
||||||
|
assert_eq!(settings.transcription.mode, TranscriptionMode::ApiOnly);
|
||||||
|
}
|
||||||
|
|
||||||
assert_eq!(settings.transcription.language, None);
|
#[test]
|
||||||
assert_eq!(settings.transcription.mode, TranscriptionMode::LocalFirst);
|
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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,283 @@
|
|||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use reqwest::blocking::multipart::{Form, Part};
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct OpenAiTranscriptionConfig {
|
pub struct OpenAiTranscriptionConfig {
|
||||||
|
/// URL completa do endpoint (ex.: `http://localhost:8022/v1/audio/transcriptions`).
|
||||||
pub endpoint: String,
|
pub endpoint: String,
|
||||||
|
/// Modelo a ser enviado (ex.: `whisper-1`, `Systrim/Kokoro-TTS`).
|
||||||
pub model: String,
|
pub model: String,
|
||||||
|
/// Idioma opcional no formato ISO-639-1 (ex.: `"pt"`, `"en"`).
|
||||||
pub language: Option<String>,
|
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 {
|
impl Default for OpenAiTranscriptionConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
endpoint: "https://api.openai.com/v1/audio/transcriptions".to_string(),
|
endpoint: "http://localhost:8022/v1/audio/transcriptions".to_string(), // Parakeet local default
|
||||||
model: "whisper-1".to_string(),
|
model: "alefiury/parakeet-tdt-0.6b-v3-ptBR-TAGARELA-onnx".to_string(), // PT-BR nativo
|
||||||
language: None,
|
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
@@ -1,18 +1,40 @@
|
|||||||
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
|
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
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 {
|
export interface AppSettings {
|
||||||
transcription: {
|
transcription: {
|
||||||
mode: "localFirst" | "apiOnly";
|
mode: TranscriptionMode;
|
||||||
modelId: string;
|
modelId: string;
|
||||||
language: string | null;
|
language: string | null;
|
||||||
openaiCompatible: {
|
openaiCompatible: OpenAiCompatibleSettings;
|
||||||
endpoint: string;
|
|
||||||
apiKeyConfigured: boolean;
|
|
||||||
model: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
insertion: {
|
insertion: {
|
||||||
method: "clipboardPaste" | "keyTyping" | "manualCopy";
|
method: "clipboardPaste" | "keyTyping" | "manualCopy";
|
||||||
@@ -74,18 +96,18 @@ const fallbackSnapshot: AppSnapshot = {
|
|||||||
recordingStatus: "idle",
|
recordingStatus: "idle",
|
||||||
transcript: {
|
transcript: {
|
||||||
text: "",
|
text: "",
|
||||||
lastMessage:
|
lastMessage: "Pronto para gravar.",
|
||||||
"Modo pré-visualização: comandos nativos disponíveis dentro do Tauri.",
|
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
transcription: {
|
transcription: {
|
||||||
mode: "localFirst",
|
mode: "apiOnly",
|
||||||
modelId: "parakeet-tdt-0.6b-v3-q8_0",
|
modelId: "",
|
||||||
language: null,
|
language: null,
|
||||||
openaiCompatible: {
|
openaiCompatible: {
|
||||||
endpoint: "https://api.openai.com/v1/audio/transcriptions",
|
endpoint: "http://localhost:8022/v1/audio/transcriptions", // Parakeet local
|
||||||
apiKeyConfigured: false,
|
apiKeyConfigured: false,
|
||||||
model: "whisper-1",
|
model: "alefiury/parakeet-tdt-0.6b-v3-ptBR-TAGARELA-onnx",
|
||||||
|
language: "pt",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
insertion: {
|
insertion: {
|
||||||
@@ -98,21 +120,7 @@ const fallbackSnapshot: AppSnapshot = {
|
|||||||
audioFormat: "wav",
|
audioFormat: "wav",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
models: [
|
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function createNativeClient(): NativeClient {
|
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>(
|
function element<K extends keyof HTMLElementTagNameMap>(
|
||||||
tag: K,
|
tag: K,
|
||||||
className?: string,
|
className?: string,
|
||||||
@@ -147,20 +205,10 @@ function formatAttempt(attempt: TranscriptionAttempt): string {
|
|||||||
`model: ${attempt.model}`,
|
`model: ${attempt.model}`,
|
||||||
`exitCode: ${attempt.exitCode ?? "n/a"}`,
|
`exitCode: ${attempt.exitCode ?? "n/a"}`,
|
||||||
`error: ${attempt.error ?? "—"}`,
|
`error: ${attempt.error ?? "—"}`,
|
||||||
`stdout:`,
|
|
||||||
attempt.stdoutTail || "(vazio)",
|
|
||||||
`stderr:`,
|
|
||||||
attempt.stderrTail || "(vazio)",
|
|
||||||
];
|
];
|
||||||
return lines.join("\n");
|
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> {
|
async function safeSnapshot(client: NativeClient): Promise<AppSnapshot> {
|
||||||
try {
|
try {
|
||||||
return await client.invoke<AppSnapshot>("get_app_snapshot");
|
return await client.invoke<AppSnapshot>("get_app_snapshot");
|
||||||
@@ -174,23 +222,62 @@ export function createTranscriptionApp(
|
|||||||
client: NativeClient = createNativeClient(),
|
client: NativeClient = createNativeClient(),
|
||||||
): void {
|
): void {
|
||||||
mount.textContent = "";
|
mount.textContent = "";
|
||||||
|
mount.classList.add("transcript-root");
|
||||||
|
|
||||||
let snapshot = fallbackSnapshot;
|
let snapshot: AppSnapshot = fallbackSnapshot;
|
||||||
|
let previewOpen = false;
|
||||||
const shell = element("section", "modal-shell");
|
|
||||||
shell.setAttribute("aria-labelledby", "app-title");
|
|
||||||
|
|
||||||
|
// ===== Topbar (frameless) — minimalista, só essenciais =====
|
||||||
const topbar = element("header", "topbar");
|
const topbar = element("header", "topbar");
|
||||||
const titleGroup = element("div", "title-group");
|
const dragRegion = element("div", "topbar-drag-region");
|
||||||
const eyebrow = element("p", "eyebrow", "Transcript Desktop");
|
dragRegion.setAttribute("data-tauri-drag-region", "");
|
||||||
const title = element("h1", undefined, "Transcrição");
|
|
||||||
title.id = "app-title";
|
|
||||||
const subtitle = element("p", "subtitle");
|
|
||||||
titleGroup.append(eyebrow, title, subtitle);
|
|
||||||
|
|
||||||
const status = element("span", "status-pill");
|
// Apenas a engrenagem para configurações (canto esquerdo).
|
||||||
topbar.append(titleGroup, status);
|
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");
|
const editorLabel = element("label", "editor-label", "Texto transcrito");
|
||||||
editorLabel.setAttribute("for", "transcript-editor");
|
editorLabel.setAttribute("for", "transcript-editor");
|
||||||
|
|
||||||
@@ -199,111 +286,301 @@ export function createTranscriptionApp(
|
|||||||
"transcript-editor",
|
"transcript-editor",
|
||||||
) as HTMLTextAreaElement;
|
) as HTMLTextAreaElement;
|
||||||
editor.id = "transcript-editor";
|
editor.id = "transcript-editor";
|
||||||
editor.placeholder =
|
editor.placeholder = "Fale para começar…";
|
||||||
"A transcrição aparecerá aqui para revisão antes da inserção.";
|
editor.rows = 6;
|
||||||
editor.rows = 10;
|
|
||||||
|
|
||||||
const message = element("p", "message");
|
const message = element("p", "message");
|
||||||
message.setAttribute("role", "status");
|
message.setAttribute("role", "status");
|
||||||
|
|
||||||
|
// Ações primárias (sempre visíveis)
|
||||||
const actions = element("div", "actions");
|
const actions = element("div", "actions");
|
||||||
const recordButton = element(
|
const recordButton = element(
|
||||||
"button",
|
"button",
|
||||||
"primary-button",
|
"record-button",
|
||||||
"Iniciar gravação",
|
"Gravar",
|
||||||
) as HTMLButtonElement;
|
|
||||||
const continueButton = element(
|
|
||||||
"button",
|
|
||||||
"secondary-button",
|
|
||||||
"+ Continuar",
|
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
|
recordButton.append(ICONS.mic.cloneNode(true));
|
||||||
|
const recordLabel = element("span", "record-label", "Gravar");
|
||||||
|
recordButton.append(recordLabel);
|
||||||
|
|
||||||
const copyButton = element(
|
const copyButton = element(
|
||||||
"button",
|
"button",
|
||||||
"secondary-button",
|
"secondary-button compact",
|
||||||
"Copiar",
|
"Copiar",
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
|
copyButton.append(ICONS.copy.cloneNode(true));
|
||||||
|
copyButton.append("Copiar");
|
||||||
|
|
||||||
const insertButton = element(
|
const insertButton = element(
|
||||||
"button",
|
"button",
|
||||||
"secondary-button",
|
"secondary-button compact",
|
||||||
"Inserir no app",
|
"Inserir",
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
|
insertButton.append(ICONS.insert.cloneNode(true));
|
||||||
|
insertButton.append("Inserir");
|
||||||
|
|
||||||
const saveButton = element(
|
const saveButton = element(
|
||||||
"button",
|
"button",
|
||||||
"ghost-button",
|
"ghost-button compact",
|
||||||
"Salvar texto",
|
"Salvar",
|
||||||
) as HTMLButtonElement;
|
) 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(
|
actions.append(
|
||||||
recordButton,
|
recordButton,
|
||||||
continueButton,
|
|
||||||
copyButton,
|
copyButton,
|
||||||
insertButton,
|
insertButton,
|
||||||
saveButton,
|
saveButton,
|
||||||
|
clearButton,
|
||||||
);
|
);
|
||||||
|
|
||||||
const modelPanel = element("aside", "model-panel");
|
// Ações secundárias (só visíveis com texto presente)
|
||||||
const modelTitle = element("h2", undefined, "Modelo offline");
|
const secondaryActions = element("div", "secondary-actions");
|
||||||
const modelDetails = element("p");
|
const continueButton = element(
|
||||||
const modelStatus = element("p", "model-status");
|
|
||||||
const downloadModelButton = element(
|
|
||||||
"button",
|
"button",
|
||||||
"ghost-button",
|
"secondary-button compact",
|
||||||
"Baixar modelo",
|
"+ Continuar",
|
||||||
) as HTMLButtonElement;
|
) 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 diagnostics = element("details", "diagnostics");
|
||||||
const diagnosticsSummary = element(
|
const diagnosticsSummary = element(
|
||||||
"summary",
|
"summary",
|
||||||
"diagnostics-summary",
|
"diagnostics-summary",
|
||||||
"Detalhes técnicos",
|
"Detalhes",
|
||||||
);
|
);
|
||||||
const diagnosticsBody = element("pre", "diagnostics-body");
|
const diagnosticsBody = element("pre", "diagnostics-body");
|
||||||
diagnostics.append(diagnosticsSummary, diagnosticsBody);
|
diagnostics.append(diagnosticsSummary, diagnosticsBody);
|
||||||
diagnostics.open = false;
|
diagnostics.open = false;
|
||||||
|
main.append(diagnostics);
|
||||||
|
|
||||||
shell.append(
|
// ===== Modal de configurações =====
|
||||||
topbar,
|
const settingsOverlay = element("div", "settings-overlay");
|
||||||
editorLabel,
|
settingsOverlay.setAttribute("aria-hidden", "true");
|
||||||
editor,
|
const settingsPanel = element("aside", "settings-panel");
|
||||||
message,
|
settingsPanel.setAttribute("role", "dialog");
|
||||||
actions,
|
settingsPanel.setAttribute("aria-modal", "true");
|
||||||
modelPanel,
|
settingsPanel.setAttribute("aria-labelledby", "settings-title");
|
||||||
diagnostics,
|
|
||||||
|
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 => {
|
const render = (next: AppSnapshot): void => {
|
||||||
snapshot = next;
|
snapshot = next;
|
||||||
const isRecording = snapshot.recordingStatus === "recording";
|
const isRecording = snapshot.recordingStatus === "recording";
|
||||||
status.textContent = isRecording ? "Gravando" : "Pronto";
|
const isTranscribing = snapshot.recordingStatus === "transcribing";
|
||||||
status.dataset.state = snapshot.recordingStatus;
|
statusPill.dataset.state = snapshot.recordingStatus;
|
||||||
subtitle.textContent = `${snapshot.settings.transcription.mode === "localFirst" ? "Offline primeiro" : "API"} · idioma ${snapshot.settings.transcription.language ?? "automático"}`;
|
statusLabel.textContent = isTranscribing
|
||||||
|
? "Transcrevendo"
|
||||||
|
: isRecording
|
||||||
|
? "Gravando"
|
||||||
|
: "Pronto";
|
||||||
|
|
||||||
editor.value = snapshot.transcript.text;
|
editor.value = snapshot.transcript.text;
|
||||||
message.textContent =
|
message.textContent =
|
||||||
snapshot.transcript.lastMessage ?? "Revise o texto antes de inserir.";
|
snapshot.transcript.lastMessage ?? "Pronto para gravar.";
|
||||||
recordButton.textContent = isRecording
|
|
||||||
? "Parar e transcrever"
|
|
||||||
: "Iniciar gravação";
|
|
||||||
|
|
||||||
const firstModelStatus = snapshot.models[0];
|
// Modo compacto/expandido baseado em ter texto
|
||||||
const firstModel = firstModelStatus?.model;
|
const hasText = snapshot.transcript.text.trim().length > 0;
|
||||||
modelDetails.textContent = firstModel
|
document.body.classList.toggle("has-text", hasText);
|
||||||
? `${firstModel.displayName} · ${firstModel.quantization} · ${formatBytes(firstModel.sizeBytes)} · ${firstModel.repository}`
|
document.body.classList.toggle(
|
||||||
: "Nenhum modelo offline homologado encontrado.";
|
"is-recording",
|
||||||
modelStatus.textContent = firstModelStatus
|
isRecording || isTranscribing,
|
||||||
? `Status: ${modelStatusLabel(firstModelStatus.status)}`
|
);
|
||||||
: "Status: indisponível";
|
|
||||||
downloadModelButton.disabled = firstModelStatus?.status === "ready";
|
|
||||||
downloadModelButton.textContent =
|
|
||||||
firstModelStatus?.status === "ready" ? "Modelo pronto" : "Baixar modelo";
|
|
||||||
|
|
||||||
|
// 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;
|
const attempt = snapshot.transcript.lastAttempt;
|
||||||
if (attempt) {
|
if (attempt) {
|
||||||
diagnostics.open = true;
|
diagnostics.open = true;
|
||||||
diagnosticsBody.textContent = formatAttempt(attempt);
|
diagnosticsBody.textContent = formatAttempt(attempt);
|
||||||
} else {
|
} 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> => {
|
const refresh = async (): Promise<void> => {
|
||||||
@@ -316,31 +593,26 @@ export function createTranscriptionApp(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleRecording = async (): Promise<void> => {
|
const toggleRecording = async (): Promise<void> => {
|
||||||
const command =
|
const status = snapshot.recordingStatus;
|
||||||
snapshot.recordingStatus === "recording"
|
// Fluxo rápido: "Parar" → stop_recording (instantâneo) →
|
||||||
? "stop_recording_and_transcribe"
|
// transcribe_pending (lento). Cada um renderiza uma vez
|
||||||
: "start_recording";
|
// concluído, então a UI reage imediatamente.
|
||||||
render(await client.invoke<AppSnapshot>(command));
|
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", () => {
|
recordButton.addEventListener("click", () => {
|
||||||
void toggleRecording().catch(showError);
|
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", () => {
|
continueButton.addEventListener("click", () => {
|
||||||
void client
|
void client
|
||||||
.invoke<AppSnapshot>("start_recording")
|
.invoke<AppSnapshot>("start_recording")
|
||||||
@@ -348,15 +620,28 @@ export function createTranscriptionApp(
|
|||||||
.catch(showError);
|
.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", () => {
|
copyButton.addEventListener("click", () => {
|
||||||
void navigator.clipboard
|
void navigator.clipboard
|
||||||
.writeText(editor.value)
|
.writeText(editor.value)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
message.textContent = "Texto copiado para a área de transferência.";
|
message.textContent = "Texto copiado.";
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
message.textContent =
|
message.textContent = "Não foi possível copiar automaticamente.";
|
||||||
"Não foi possível copiar automaticamente. Selecione o texto manualmente.";
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -372,14 +657,127 @@ export function createTranscriptionApp(
|
|||||||
});
|
});
|
||||||
|
|
||||||
saveButton.addEventListener("click", () => {
|
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 url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = "transcricao.txt";
|
link.download = "transcricao.txt";
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
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", () => {
|
void listen("transcript://toggle-recording", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user