385 lines
10 KiB
TypeScript
385 lines
10 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;
|
|
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<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 {
|
|
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<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");
|
|
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<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);
|
|
});
|
|
|
|
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<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();
|
|
}
|