Files
transcript/src/app.ts
T

301 lines
7.8 KiB
TypeScript

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;
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));
};
const showError = (error: unknown): void => {
message.textContent = error instanceof Error ? error.message : String(error);
};
const toggleRecording = async (): Promise<void> => {
const command =
snapshot.recordingStatus === "recording"
? "stop_recording_and_transcribe"
: "start_recording";
render(await client.invoke<AppSnapshot>(command));
};
recordButton.addEventListener("click", () => {
void toggleRecording().catch(showError);
});
continueButton.addEventListener("click", () => {
void client
.invoke<AppSnapshot>("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();
}