feat: build editable transcription modal
This commit is contained in:
1 parent
bfa96248aa
commit
5494b633ea
4 files changed
+501
-95
No files matched your search
+251
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user