feat: build editable transcription modal

This commit is contained in:
Evandro committed 2026-06-10 21:02:57 -03:00
1 parent bfa96248aa
commit 5494b633ea
4 files changed
+501 -95

No files matched your search

+251
View File
@@ -0,0 +1,251 @@
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
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;
languages: string[];
recommendedForSimplePc: boolean;
};
status: 'notInstalled' | 'ready' | { error: string };
}
export interface TranscriptDraft {
text: string;
lastMessage: string | null;
}
export interface AppSnapshot {
settings: AppSettings;
recordingStatus: RecordingStatus;
transcript: TranscriptDraft;
models: ModelWithStatus[];
}
export interface NativeClient {
invoke<T>(command: string, args?: Record<string, unknown>): Promise<T>;
}
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: <T>(command: string, args?: Record<string, unknown>) => tauriInvoke<T>(command, args),
};
}
function element<K extends keyof HTMLElementTagNameMap>(
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 {
const mib = bytes / 1024 / 1024;
return `${mib.toFixed(0)} MiB`;
}
async function safeSnapshot(client: NativeClient): Promise<AppSnapshot> {
try {
return await client.invoke<AppSnapshot>('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');
modelPanel.append(modelTitle, modelDetails);
shell.append(topbar, editorLabel, editor, message, actions, modelPanel);
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 firstModel = snapshot.models[0]?.model;
modelDetails.textContent = firstModel
? `${firstModel.displayName} · ${firstModel.quantization} · ${formatBytes(firstModel.sizeBytes)} · ${firstModel.repository}`
: 'Nenhum modelo offline homologado encontrado.';
};
const refresh = async (): Promise<void> => {
render(await safeSnapshot(client));
};
recordButton.addEventListener('click', () => {
void (async () => {
const command = snapshot.recordingStatus === 'recording'
? 'stop_recording_and_transcribe'
: 'start_recording';
render(await client.invoke<AppSnapshot>(command));
})().catch((error: unknown) => {
message.textContent = error instanceof Error ? error.message : String(error);
});
});
continueButton.addEventListener('click', () => {
void client.invoke<AppSnapshot>('start_recording').then(render).catch((error: unknown) => {
message.textContent = error instanceof Error ? error.message : String(error);
});
});
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((error: unknown) => {
message.textContent = error instanceof Error ? error.message : String(error);
});
});
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 refresh();
}