feat: wire model download UI and native docs

This commit is contained in:
Evandro committed 2026-06-11 01:18:40 -03:00
1 parent 0c39063150
commit 8f754b3f00
8 files changed
+218 -152

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev
- name: Setup Node.js
uses: actions/setup-node@v4
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- name: Install Tauri Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev
- name: Setup Node.js
uses: actions/setup-node@v4
+6 -6
View File
@@ -3,11 +3,11 @@ name: Release
on:
push:
tags:
- 'v*.*.*'
- "v*.*.*"
workflow_dispatch:
inputs:
tag:
description: 'Tag de release, por exemplo v0.1.0'
description: "Tag de release, por exemplo v0.1.0"
required: true
type: string
@@ -20,9 +20,9 @@ jobs:
matrix:
include:
- platform: windows-latest
args: ''
args: ""
- platform: ubuntu-22.04
args: ''
args: ""
runs-on: ${{ matrix.platform }}
@@ -34,7 +34,7 @@ jobs:
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -60,7 +60,7 @@ jobs:
with:
tagName: ${{ github.event.inputs.tag || github.ref_name }}
releaseName: Transcript ${{ github.event.inputs.tag || github.ref_name }}
releaseBody: 'Build automatizado do Transcript para Windows e Linux.'
releaseBody: "Build automatizado do Transcript para Windows e Linux."
releaseDraft: false
prerelease: false
args: ${{ matrix.args }}
+23 -9
View File
@@ -4,26 +4,28 @@ Aplicativo desktop de transcrição para Windows e Linux, construído com Tauri
## Estado atual
Este repositório contém o MVP estrutural:
Este repositório contém um MVP nativo funcional:
- app Tauri 2 funcional;
- modal editável em TypeScript sem framework pesado;
- backend Rust modular;
- catálogo do modelo `cstr/parakeet-tdt-0.6b-v3-GGUF` em Q8;
- contratos para CrispASR sidecar, API OpenAI-compatible, gravação e inserção de texto;
- catálogo e download do modelo `cstr/parakeet-tdt-0.6b-v3-GGUF` em Q8;
- captura real de microfone em WAV via CPAL;
- execução do sidecar CrispASR/Parakeet;
- hotkey global `Ctrl+Shift+Space`;
- inserção híbrida por clipboard + paste automático no Windows/X11 quando disponível;
- fallback manual no Wayland;
- tray/system tray básico;
- CI, build e release via GitHub Actions.
Ainda não implementa captura real de áudio, hotkey global, download real do modelo ou injeção nativa em outros aplicativos. Essas integrações já têm módulos dedicados para implementação incremental.
## Arquitetura
```text
src/ Frontend TypeScript/CSS
src-tauri/src/
audio/ Estado de gravação MVP
audio/ Captura de microfone e gravação WAV
transcription/ Contratos, CrispASR sidecar e API compatível com OpenAI
injection/ Contratos/fallback de inserção
injection/ Clipboard, paste automático e fallback manual
models/ Catálogo do modelo offline
settings/ Configuração padrão
tray.rs Menu da bandeja do sistema
@@ -44,10 +46,22 @@ A regra de design é manter arquivos pequenos, responsabilidades claras e zero o
O modelo não deve ser embutido no instalador principal. A estratégia recomendada é instalar o app leve e baixar o GGUF pelo gerenciador de modelos.
## Sidecar CrispASR
O app chama por padrão o binário `parakeet-main` com:
```bash
parakeet-main -m <modelo.gguf> -f <audio.wav> -t <threads>
```
Para o modelo v3, o projeto CrispASR recomenda compilar o target `parakeet-main` no branch `parakeet`. O app limita automaticamente o número de threads a no máximo 4 para manter compatibilidade com PCs simples.
Enquanto o binário não estiver no `PATH` ou empacotado como sidecar, a transcrição local retorna erro claro após a gravação.
## Compatibilidade Windows/Linux
- Windows: alvo principal para inserção automática futura via APIs Win32.
- Linux X11: alvo de automação completa quando possível.
- Windows: clipboard + tentativa de `Ctrl+V` via `SendKeys`.
- Linux X11: clipboard + tentativa de `Ctrl+V` via `xdotool` quando instalado.
- Linux Wayland: suporte parcial por limitação de segurança dos compositors. O fallback seguro é copiar o texto e orientar o usuário a colar manualmente.
## Comandos locais
@@ -17,10 +17,10 @@
- Modify: `src-tauri/src/ui_commands.rs`
- Modify: `src-tauri/Cargo.toml`
- [ ] Add URL de download do Hugging Face e resolução de diretório local.
- [ ] Add validação por existência e tamanho mínimo/esperado.
- [ ] Add comando `download_default_model` com escrita em arquivo temporário e rename atômico.
- [ ] Testar status notInstalled/ready sem baixar o arquivo real.
- [x] Add URL de download do Hugging Face e resolução de diretório local.
- [x] Add validação por existência e tamanho mínimo/esperado.
- [x] Add comando `download_default_model` com escrita em arquivo temporário e rename atômico.
- [x] Testar status notInstalled/ready sem baixar o arquivo real.
## Task 2: Captura real de áudio
@@ -30,10 +30,10 @@
- Modify: `src-tauri/src/ui_commands.rs`
- Modify: `src-tauri/Cargo.toml`
- [ ] Implementar gravador CPAL que escreve WAV via hound.
- [ ] Guardar stream ativo no estado para manter gravação viva.
- [ ] Stop finaliza WAV e retorna caminho.
- [ ] Manter teste unitário de máquina de estados.
- [x] Implementar gravador CPAL que escreve WAV via hound.
- [x] Manter stream vivo em thread de gravação e guardar handles seguros no estado.
- [x] Stop finaliza WAV e retorna caminho.
- [x] Manter teste unitário de máquina de estados.
## Task 3: Execução CrispASR
@@ -42,10 +42,10 @@
- Modify: `src-tauri/src/ui_commands.rs`
- Modify: `src-tauri/tauri.conf.json`
- [ ] Resolver binário via variável/env/config e depois sidecar empacotado.
- [ ] Executar `crispasr -m <model> -f <audio>`.
- [ ] Capturar stdout/stderr e retornar erro claro.
- [ ] Parsear texto simples ou JSON quando disponível.
- [x] Resolver binário padrão `parakeet-main` para o modelo v3.
- [x] Executar `parakeet-main -m <model> -f <audio> -t <threads>`.
- [x] Capturar stdout/stderr e retornar erro claro.
- [x] Parsear stdout como texto simples; JSON fica para evolução futura se o sidecar for configurado para isso.
## Task 4: Hotkey global
@@ -53,9 +53,9 @@
- Modify: `src-tauri/Cargo.toml`
- Modify: `src-tauri/src/lib.rs`
- [ ] Adicionar `tauri-plugin-global-shortcut`.
- [ ] Registrar `Ctrl+Alt+Space` no setup.
- [ ] Ao pressionar, mostrar/focar janela e emitir evento `hotkey-toggle-recording` para a UI.
- [x] Adicionar `tauri-plugin-global-shortcut`.
- [x] Registrar `Ctrl+Shift+Space` no setup.
- [x] Ao pressionar, emitir evento `transcript://toggle-recording` para a UI.
## Task 5: Inserção/cópia cross-platform
@@ -64,10 +64,10 @@
- Modify: `src-tauri/src/ui_commands.rs`
- Modify: `src-tauri/Cargo.toml`
- [ ] Adicionar clipboard backend com `arboard`.
- [ ] Implementar `request_insert_text` que sempre coloca o texto no clipboard.
- [ ] Retornar mensagens por plataforma/display server.
- [ ] Manter Wayland como parcial/manual.
- [x] Adicionar clipboard backend com `arboard`.
- [x] Implementar `request_insert_text` que sempre coloca o texto no clipboard.
- [x] Retornar mensagens por plataforma/display server.
- [x] Manter Wayland como parcial/manual.
## Task 6: UI, CI e docs
@@ -77,10 +77,10 @@
- Modify: `.github/workflows/*.yml`
- Modify: `README.md`
- [ ] Mostrar status de modelo e botão de download.
- [ ] Reagir ao evento de hotkey.
- [ ] Atualizar CI Linux com `libasound2-dev`.
- [ ] Rodar verificação final completa.
- [x] Mostrar status de modelo e botão de download.
- [x] Reagir ao evento de hotkey.
- [x] Atualizar CI Linux com `libasound2-dev`.
- [x] Rodar verificação final completa.
## Verification
+10 -6
View File
@@ -118,7 +118,7 @@ impl TextInjector for FallbackInjector {
fn current_display_server() -> DisplayServer {
#[cfg(target_os = "windows")]
{
return DisplayServer::Windows;
DisplayServer::Windows
}
#[cfg(target_os = "linux")]
@@ -127,14 +127,18 @@ fn current_display_server() -> DisplayServer {
.unwrap_or_default()
.to_lowercase();
if session_type == "wayland" || std::env::var_os("WAYLAND_DISPLAY").is_some() {
return DisplayServer::Wayland;
}
if session_type == "x11" || std::env::var_os("DISPLAY").is_some() {
return DisplayServer::X11;
DisplayServer::Wayland
} else if session_type == "x11" || std::env::var_os("DISPLAY").is_some() {
DisplayServer::X11
} else {
DisplayServer::Unknown
}
}
DisplayServer::Unknown
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
DisplayServer::Unknown
}
}
fn copy_to_clipboard(text: &str) -> Result<String, String> {
+37 -2
View File
@@ -33,10 +33,12 @@ export interface ModelWithStatus {
filename: string;
quantization: string;
sizeBytes: number;
downloadUrl?: string;
languages: string[];
recommendedForSimplePc: boolean;
};
status: "notInstalled" | "ready" | { error: string };
localPath?: string | null;
}
export interface TranscriptDraft {
@@ -123,6 +125,12 @@ function formatBytes(bytes: number): string {
return `${mib.toFixed(0)} MiB`;
}
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");
@@ -205,7 +213,13 @@ export function createTranscriptionApp(
const modelPanel = element("aside", "model-panel");
const modelTitle = element("h2", undefined, "Modelo offline");
const modelDetails = element("p");
modelPanel.append(modelTitle, modelDetails);
const modelStatus = element("p", "model-status");
const downloadModelButton = element(
"button",
"ghost-button",
"Baixar modelo",
) as HTMLButtonElement;
modelPanel.append(modelTitle, modelDetails, modelStatus, downloadModelButton);
shell.append(topbar, editorLabel, editor, message, actions, modelPanel);
mount.append(shell);
@@ -223,10 +237,17 @@ export function createTranscriptionApp(
? "Parar e transcrever"
: "Iniciar gravação";
const firstModel = snapshot.models[0]?.model;
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 refresh = async (): Promise<void> => {
@@ -249,6 +270,20 @@ export function createTranscriptionApp(
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")
+117 -104
View File
@@ -1,198 +1,211 @@
:root {
color: #15120f;
background: #f4efe7;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
color: #15120f;
background: #f4efe7;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
radial-gradient(circle at 15% 10%, rgba(122, 79, 56, 0.18), transparent 24rem),
linear-gradient(135deg, #f7f1e8 0%, #ece4da 100%);
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
radial-gradient(
circle at 15% 10%,
rgba(122, 79, 56, 0.18),
transparent 24rem
),
linear-gradient(135deg, #f7f1e8 0%, #ece4da 100%);
}
button,
textarea,
input,
select {
font: inherit;
font: inherit;
}
button {
border: 0;
cursor: pointer;
border: 0;
cursor: pointer;
}
button:focus-visible,
textarea:focus-visible {
outline: 3px solid rgba(122, 79, 56, 0.35);
outline-offset: 2px;
outline: 3px solid rgba(122, 79, 56, 0.35);
outline-offset: 2px;
}
#app {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1rem;
min-height: 100vh;
display: grid;
place-items: center;
padding: 1rem;
}
.modal-shell {
width: min(100%, 680px);
display: grid;
gap: 1rem;
padding: 1.1rem;
border: 1px solid rgba(21, 18, 15, 0.12);
border-radius: 1.25rem;
background: rgba(255, 252, 247, 0.9);
box-shadow: 0 24px 80px rgba(54, 38, 26, 0.16);
width: min(100%, 680px);
display: grid;
gap: 1rem;
padding: 1.1rem;
border: 1px solid rgba(21, 18, 15, 0.12);
border-radius: 1.25rem;
background: rgba(255, 252, 247, 0.9);
box-shadow: 0 24px 80px rgba(54, 38, 26, 0.16);
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.title-group {
display: grid;
gap: 0.35rem;
display: grid;
gap: 0.35rem;
}
.eyebrow,
h1,
h2,
p {
margin: 0;
margin: 0;
}
.eyebrow {
color: #7a4f38;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #7a4f38;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
color: #15120f;
font-size: clamp(1.9rem, 8vw, 3.4rem);
line-height: 0.95;
letter-spacing: -0.06em;
color: #15120f;
font-size: clamp(1.9rem, 8vw, 3.4rem);
line-height: 0.95;
letter-spacing: -0.06em;
}
.subtitle,
.message,
.model-panel p {
color: #665e55;
line-height: 1.5;
color: #665e55;
line-height: 1.5;
}
.status-pill {
flex: 0 0 auto;
padding: 0.4rem 0.7rem;
border-radius: 999px;
color: #145a3c;
background: rgba(31, 143, 95, 0.13);
font-size: 0.82rem;
font-weight: 700;
flex: 0 0 auto;
padding: 0.4rem 0.7rem;
border-radius: 999px;
color: #145a3c;
background: rgba(31, 143, 95, 0.13);
font-size: 0.82rem;
font-weight: 700;
}
.status-pill[data-state="recording"] {
color: #8a241d;
background: rgba(201, 62, 48, 0.14);
color: #8a241d;
background: rgba(201, 62, 48, 0.14);
}
.editor-label {
color: #312923;
font-size: 0.86rem;
font-weight: 700;
color: #312923;
font-size: 0.86rem;
font-weight: 700;
}
.transcript-editor {
width: 100%;
resize: vertical;
min-height: 14rem;
padding: 0.95rem;
border: 1px solid rgba(21, 18, 15, 0.14);
border-radius: 0.9rem;
color: #15120f;
background: #fffdfa;
line-height: 1.55;
width: 100%;
resize: vertical;
min-height: 14rem;
padding: 0.95rem;
border: 1px solid rgba(21, 18, 15, 0.14);
border-radius: 0.9rem;
color: #15120f;
background: #fffdfa;
line-height: 1.55;
}
.message {
min-height: 1.5rem;
font-size: 0.92rem;
min-height: 1.5rem;
font-size: 0.92rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.primary-button,
.secondary-button,
.ghost-button {
min-height: 2.5rem;
padding: 0.65rem 0.9rem;
border-radius: 0.8rem;
font-weight: 750;
min-height: 2.5rem;
padding: 0.65rem 0.9rem;
border-radius: 0.8rem;
font-weight: 750;
}
.primary-button {
color: #fffaf5;
background: #15120f;
color: #fffaf5;
background: #15120f;
}
.secondary-button {
color: #15120f;
background: #eadfd2;
color: #15120f;
background: #eadfd2;
}
.ghost-button {
color: #6a4a36;
background: transparent;
border: 1px solid rgba(106, 74, 54, 0.22);
color: #6a4a36;
background: transparent;
border: 1px solid rgba(106, 74, 54, 0.22);
}
button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.model-panel {
display: grid;
gap: 0.35rem;
padding: 0.85rem;
border-radius: 0.9rem;
background: rgba(234, 223, 210, 0.46);
display: grid;
gap: 0.55rem;
padding: 0.85rem;
border-radius: 0.9rem;
background: rgba(234, 223, 210, 0.46);
}
.model-panel h2 {
font-size: 0.95rem;
font-size: 0.95rem;
}
.model-status {
font-weight: 700;
}
@media (max-width: 520px) {
#app {
padding: 0.5rem;
}
#app {
padding: 0.5rem;
}
.modal-shell {
border-radius: 1rem;
}
.modal-shell {
border-radius: 1rem;
}
.topbar {
display: grid;
}
.topbar {
display: grid;
}
.actions > button {
flex: 1 1 100%;
}
.actions > button {
flex: 1 1 100%;
}
}