import { invoke as tauriInvoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; export type RecordingStatus = "idle" | "recording"; export interface AppSettings { transcription: { mode: "localFirst" | "apiOnly"; modelId: string; language: string | null; openaiCompatible: { endpoint: string; apiKeyConfigured: boolean; model: string; }; }; insertion: { method: "clipboardPaste" | "keyTyping" | "manualCopy"; restoreClipboard: boolean; waylandManualFallback: boolean; }; storage: { keepAudio: boolean; audioFormat: "wav" | "ogg" | "mp3"; }; } export interface ModelWithStatus { model: { id: string; displayName: string; repository: string; filename: string; quantization: string; sizeBytes: number; downloadUrl?: string; languages: string[]; recommendedForSimplePc: boolean; }; status: "notInstalled" | "ready" | { error: string }; localPath?: string | null; } export interface TranscriptionAttempt { stage: string; audioPath: string; audioSizeBytes: number; sidecar: string; model: string; exitCode: number | null; stdoutTail: string; stderrTail: string; error: string | null; } export interface TranscriptDraft { text: string; lastMessage: string | null; lastAttempt?: TranscriptionAttempt; } export interface AppSnapshot { settings: AppSettings; recordingStatus: RecordingStatus; transcript: TranscriptDraft; models: ModelWithStatus[]; } export interface NativeClient { invoke(command: string, args?: Record): Promise; } const fallbackSnapshot: AppSnapshot = { recordingStatus: "idle", transcript: { text: "", lastMessage: "Modo pré-visualização: comandos nativos disponíveis dentro do Tauri.", }, settings: { transcription: { mode: "localFirst", modelId: "parakeet-tdt-0.6b-v3-q8_0", language: null, openaiCompatible: { endpoint: "https://api.openai.com/v1/audio/transcriptions", apiKeyConfigured: false, model: "whisper-1", }, }, insertion: { method: "clipboardPaste", restoreClipboard: true, waylandManualFallback: true, }, storage: { keepAudio: false, 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", }, ], }; function createNativeClient(): NativeClient { return { invoke: (command: string, args?: Record) => tauriInvoke(command, args), }; } function element( tag: K, className?: string, text?: string, ): HTMLElementTagNameMap[K] { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(2)} MB`; } function formatAttempt(attempt: TranscriptionAttempt): string { const lines = [ `stage: ${attempt.stage}`, `audio: ${attempt.audioPath} (${formatBytes(attempt.audioSizeBytes)})`, `sidecar: ${attempt.sidecar}`, `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"); } catch { return fallbackSnapshot; } } export function createTranscriptionApp( mount: HTMLElement, client: NativeClient = createNativeClient(), ): void { mount.textContent = ""; let snapshot = fallbackSnapshot; const shell = element("section", "modal-shell"); shell.setAttribute("aria-labelledby", "app-title"); 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 status = element("span", "status-pill"); topbar.append(titleGroup, status); const editorLabel = element("label", "editor-label", "Texto transcrito"); editorLabel.setAttribute("for", "transcript-editor"); const editor = element( "textarea", "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; const message = element("p", "message"); message.setAttribute("role", "status"); const actions = element("div", "actions"); const recordButton = element( "button", "primary-button", "Iniciar gravação", ) as HTMLButtonElement; const continueButton = element( "button", "secondary-button", "+ Continuar", ) as HTMLButtonElement; const copyButton = element( "button", "secondary-button", "Copiar", ) as HTMLButtonElement; const insertButton = element( "button", "secondary-button", "Inserir no app", ) as HTMLButtonElement; const saveButton = element( "button", "ghost-button", "Salvar texto", ) as HTMLButtonElement; actions.append( recordButton, continueButton, copyButton, insertButton, saveButton, ); 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( "button", "ghost-button", "Baixar modelo", ) as HTMLButtonElement; modelPanel.append(modelTitle, modelDetails, modelStatus, downloadModelButton); const diagnostics = element("details", "diagnostics"); const diagnosticsSummary = element( "summary", "diagnostics-summary", "Detalhes técnicos", ); const diagnosticsBody = element("pre", "diagnostics-body"); diagnostics.append(diagnosticsSummary, diagnosticsBody); diagnostics.open = false; shell.append(topbar, editorLabel, editor, message, actions, modelPanel, diagnostics); mount.append(shell); 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"}`; 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"; 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"; const attempt = snapshot.transcript.lastAttempt; if (attempt) { diagnostics.open = true; diagnosticsBody.textContent = formatAttempt(attempt); } else { diagnosticsBody.textContent = "Nenhuma tentativa registrada ainda."; } }; const refresh = async (): Promise => { render(await safeSnapshot(client)); }; const showError = (error: unknown): void => { message.textContent = error instanceof Error ? error.message : String(error); }; const toggleRecording = async (): Promise => { const command = snapshot.recordingStatus === "recording" ? "stop_recording_and_transcribe" : "start_recording"; render(await client.invoke(command)); }; 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") .then(render) .catch(showError); }); copyButton.addEventListener("click", () => { void navigator.clipboard .writeText(editor.value) .then(() => { message.textContent = "Texto copiado para a área de transferência."; }) .catch(() => { message.textContent = "Não foi possível copiar automaticamente. Selecione o texto manualmente."; }); }); insertButton.addEventListener("click", () => { void client .invoke<{ message: string }>("request_insert_text", { text: editor.value, }) .then((result) => { message.textContent = result.message; }) .catch(showError); }); saveButton.addEventListener("click", () => { 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."; }); void listen("transcript://toggle-recording", () => { void toggleRecording().catch(showError); }).catch(() => { // Browser preview and tests do not expose the Tauri event bridge. }); void refresh(); }