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