Merge pull request #470 from fsolla/B173-sollinha-consulta-por-voz-no-chat
B173 — Sollinha: consulta por voz no chat
This commit is contained in:
commit
177803a54a
10 changed files with 971 additions and 7 deletions
97
docs/plans/sollinha-consulta-por-voz-impl.md
Normal file
97
docs/plans/sollinha-consulta-por-voz-impl.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Impl: B173 — Sollinha: consulta por voz no chat
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-08
|
||||
Issue: #448
|
||||
Intenção: docs/plans/sollinha-consulta-por-voz.md
|
||||
Appetite restante: ~0,5–1 dia (herdado)
|
||||
Impeccable: C — encaixe no input do chat (superfície que o usuário toca)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** O assessor/coordenador fala a pergunta no chat da Sollinha; a transcrição vira **rascunho editável** no campo de texto; ele confere e envia pelo fluxo normal (mesma auth, rate limit, tools, streaming). Áudio nunca é armazenado. Sem mic/permissão → mensagem clara, chat por texto intacto.
|
||||
- **O que NÃO negociar:** rascunho editável (sem auto-send); **sem TTS**; áudio processado e descartado (sem collection, sem Blob/media, sem Consent — nada persiste); STT servidor via **Deep Infra Whisper large-v3**, mesma chave `DEEPINFRA_API_KEY` do LLM (converge com B174); perdas/falha de transcrição nunca quebram o chat de texto; respostas de voz passam pelos mesmos guards (auth + rate limit + RBAC das tools).
|
||||
- **O que reavaliar:** a "Direção no codebase" da intenção previa "botão no `InputGroup` + injeta transcrição como `sendMessage`" — a **decisão de produto confirmada** (2026-08-08) é **rascunho editável no input** (`setInput`), não auto-send. O provider STT é **Deep Infra** (REST direto no `route`), não Assembly AI do roadmap original. A hipótese de "uma nova rota irmã em `campanha/api/`" está correta.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph client [CampaignAIChat]
|
||||
useMicTranscript -- FormData(file) --> transcribe[POST /campanha/api/ai-transcribe]
|
||||
mic[botão mic no InputGroup]
|
||||
textarea[textarea rascunho editável]
|
||||
useChat -- sendMessage --> chat[/campanha/api/ai-chat]
|
||||
end
|
||||
subgraph server [Rota transcribe]
|
||||
auth[getCampaignUserRaw]
|
||||
rl[checkRateLimit user.id]
|
||||
origin[isSameOriginRequest]
|
||||
fwd[fetch Deep Infra /v1/openai/audio/transcriptions]
|
||||
end
|
||||
subgraph dl [Deep Infra]
|
||||
wh[whisper-large-v3]
|
||||
end
|
||||
mic --> useMicTranscript --> transcribe --> auth --> rl --> origin --> fwd --> wh
|
||||
transcribe --> textarea --> useChat --> chat
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — Nova rota `POST /campanha/api/ai-transcribe`** (irmã de `ai-chat`, mesma pasta `campanha/api/`), auth cookie + rate limit mesmo balde + mesma-origem; recebe o blob em `FormData(file)` e faz `fetch` à Deep Infra. Retorna `{ text }`.
|
||||
- **B — Server action** do lado do cliente chamando DirectDeep Infra. Rejeitada: body size limit de server actions no Next (clips longos batem no limite), e foge do padrão irmão de `ai-chat` (rota explícita com `maxDuration`, debugável/smokcável em prod).
|
||||
- **C — STT direto do browser** (Web Speech / on-device / fetch direto com a chave). Rejeitada pela intenção (gate 2026-08-08): Web Speech inconsistente/sem contrato com o provedor; Whisper on-device só até `small` com 590 MB+ no celular; chave seria vazada no cliente. A Deep Infra em servidor é o desfecho decidido.
|
||||
|
||||
**Recomendação: A** — porque reusa o padrão canônico das rotas autenticadas de `/campanha` (`ai-chat` é a precedente), mantém a chave no servidor, permite `maxDuration` explícito e é a superfície mais fácil de smoke em produção (a mesma família que B174 toca, sem conflitar em arquivo).
|
||||
|
||||
**Decisões de engenharia (caras, com rejeitadas):**
|
||||
|
||||
1. **Rate limit no transcribe.** Opções: A) `checkRateLimit(user.id)` também no transcribe (pergunta por voz = 1 unidade no transcribe + 1 no send = **2 unidades** do balde 50/15min) | B) não limitar o transcribe (só o send conta; STT é ~US$0,00008/consulta). **Recomendação: A** — a intenção é explícita ("contar no mesmo balde para não virar bypass") e fail-closed; ~25 perguntas de voz/15min é folgado para assessor de campo. B rejeitada: endpoint de STT viraria superfície de custo sem freio (autenticado, mas spamável).
|
||||
2. **Formato do áudio enviado.** o `MediaRecorder` do Chrome/Android (Field Desk — superfície principal) produz `webm/opus`; Safari produz `mp4/aac`. **Opções:** A) mandar o blob como está no `file` (OpenAI-compat aceita webm/aac via ffmpeg) | B) converter client-side para WAV 16kHz mono (~50 linhas, módulo puro testável) no hook. **Recomendação: A** — menos código, e Whisper lê webm/aac; o smoke em prod valida. **Gatilho de revisita:** se o smoke devolver 4xx de formato, adicionar o conversor WAV (opção B, módulo puro `lib/`, unit-testado) — registrado como débito/fix no impl, não bloqueia.
|
||||
3. **Onde vive a lógica de captura.** Opções: A) hook `useMicTranscript` (`src/components/campaign/shell/ai/`) | B) inline em `CampaignAIChat`. **Recomendação: A** — encapsula getUserMedia/MediaRecorder/fetch/decode dos erros uma vez e deixa o componente render-only; é o caso "encapsulate once" do engineering-brief (conhecimento que vazaria no componente senão). Componente continua o dono da superfície (edita o owner, não cria twin).
|
||||
4. **Same-origin no transcribe.** Embora a rota entre no allowlist do `campaignJsonMutationRoute` (multipart, não JSON), aplicar `isSameOriginRequest` explicitamente (403) como a rota membro de `/campanha` — barato, defense-in-depth.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/app/(campaign)/campanha/api/ai-transcribe/route.ts`** (nova): `export const maxDuration = 60`; `getCampaignUserRaw()` → 401; `checkRateLimit(user.id)` → 429 (mesma mensagem do `ai-chat`); `isSameOriginRequest(request)` → 403; lê `FormData.file`; delega a transcrição; retorna `{ text }` (200) ou `{ error }` com status. **Não armazena nada** (áudio fica só na memória da função).
|
||||
- **`src/utilities/ai/deepInfraTranscribe.ts`** (nova, sob `utilities/ai/` — subpasta, fora do pin top-level): função pura-ish `deepInfraTranscribe(file: Blob): Promise<string>` que monta o `FormData` (`file`, `model: 'openai/whisper-large-v3'`, `language: 'pt'`), `fetch('https://api.deepinfra.com/v1/openai/audio/transcriptions', { method: 'POST', headers: { Authorization: Bearer DEEPINFRA_API_KEY }, body })`, e devolve `response.text` (OpenAI JSON `{ text }`) — **única costura de rede**, 100% testável com `global.fetch` mockado. A chave vem de `process.env.DEEPINFRA_API_KEY`.
|
||||
- **`src/components/campaign/shell/ai/useMicTranscript.ts`** (nova): estados `idle | recording | transcribing | error`; `transcript`, `errorMessage`, `elapsed` (timer), `start()`, `stop()`, `dismissError()`. Gerencia `getUserMedia({ audio: true })` + `MediaRecorder` (chunks → `Blob`), `URL.createObjectURL` não necessário; envia `FormData` ao transcribe; mapeia 401/429/502/'chave ausente' para pt-BR; faz cleanup (para tracks, revoga streams) em `stop`/unmount. Cap de segurança ~60 s de gravação (timer) para evitar clip infinito.
|
||||
- **`src/components/campaign/shell/ai/CampaignAIChat.tsx`** (editar): botão mic (`InputGroupButton`, ícone `Mic`/`Square`/`Spinner`) antes do Send no `InputGroup`; `aria-label`/`aria-pressed`; estados visuais ("Ouvindo…" + timer pulsante, spinner em transcrição); ao obter `transcript` → `setInput(transcript)` (draft editável) + focus no textarea; erro → linha inline (`Marker`/status) com retry e **chat por texto funcional** (não desabilita o input). `disabled={busy}` (chat respondendo) e sem suporte a `MediaRecorder`. Cobre **as duas superfícies** de uma vez (sidebar desktop + drawer mobile renderizam o mesmo `CampaignAIChat`).
|
||||
- **`codebaseConventions.unit.spec.ts`** (editar): adicionar `src/app/(campaign)/campanha/api/ai-transcribe/route.ts` ao allowlist do sweep "POST routes sob src/app" com justificativa ("multipart não-JSON irmão do ai-chat, auth cookie + same-origin").
|
||||
- **`.env.example`** (editar): adicionar bloco `DEEPINFRA_API_KEY` (converge com B174; mantém `DEEPSEEK_API_KEY` atual enquanto o chat estiver no provider antigo em `main`).
|
||||
- **Migration:** sem migration (nenhum schema/collection; áudio transiente).
|
||||
- **Access / Consent:** intocados — auth/session do chat existente; **sem** nova chave Consent (nada persiste); RBAC das tools reusado no fluxo de envio final.
|
||||
- **UI:** Impeccable C — shape→craft→polish no encaixe do input; tokens `data-theme='campaign'`; shells existentes (`InputGroup`, `Marker`); sem novo componente de página.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
N/A — não apresenta métrica nova (intenção: "novo meio de entrada para o mesmo fluxo"). A única "forma" é o rascunho editável + estados do mic, descritos acima.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Server / STT** — `deepInfraTranscribe.ts` + unit (mock `global.fetch`: header Bearer, model `openai/whisper-large-v3`, language `pt`, parse de `{ text }`, tratamento de erro HTTP) → rota `ai-transcribe` (auth/rate/origin/FormData). Gate: `pnpm gate:fast`.
|
||||
2. **Client** — `useMicTranscript` + unit (stub `getUserMedia`/`MediaRecorder` no jsdom: transcribe ok -> `transcript`; permissão negada -> `errorMessage` pt-BR; stop/dispose) → `CampaignAIChat` (mic no InputGroup, estados, draft no textarea) → allowlist + `.env.example`.
|
||||
3. **E2E + gates** — `tests/e2e/aiTranscribe.e2e.spec.ts`: `addInitScript` stub de `getUserMedia`/`MediaRecorder` + `page.route('**/campanha/api/ai-transcribe')` retornando `{ text: 'Quantos votos em Ilhéus?' }`; asserta rascunho no textarea → send → `ai-chat` mockado responde; caminho de erro (permissão negada → mensagem + chat texto segue usável). `pnpm gate:fast` na iteração; entrega via `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- VAD/detecção de silêncio — corte: tap-to-stop (o "confirmar" do fluxo da intenção); Whisper lida com pausas.
|
||||
- TTS / responder por voz — item sucessor separado (fora de escopo da intenção).
|
||||
- Converter webm→wav no cliente **por fazer** — só se o smoke reprovar o formato (ver decisão 2).
|
||||
- Persistir histórico/áudio, telemetria de "quantas perguntas por voz", múltiplos provedores STT selecionáveis.
|
||||
- Mexer no `ai-chat/route.ts` (LLM/tools) — não toca este item; B174 é chore separado.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Deep Infra rejeita webm/opus/aac** → smoke em prod com 1 clip real; fallback documentado = conversor WAV em `lib/` (decisão 2, gatilho explícito).
|
||||
- **Pré-condição operacional:** `DEEPINFRA_API_KEY` precisa existir na env de produção Vercel (mesma chave do B174); no env local teste/e2e **mockam** a rota → nenhuma chave real no CI (mesmo padrão do `ai-chat`).
|
||||
- **`.env.example` converge com B174** (mesma var) → conflito trivial de merge, resolvido no rebase; ambos apontam para o mesmo name.
|
||||
- **Chave ausente em produção** → rota responde 502/500 com mensagem pt-BR "Transcrição indisponível" e o chat por texto continua; nunca auto-send.
|
||||
- **Rate limit 2-unidades por pergunta de voz** → intencional (decisão 1); reavaliar subindo `MAX_MESSAGES` se a telemetria de campo mostrar aperto (débito observável, não inflar agora).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: mic → ouvir → transcrição como rascunho editável → envio pelo fluxo normal; áudio não persiste; sem TTS.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: auth cookie `campaign-token`; rate limit mesmo balde; `server-only` onde for preciso; `lib/` nunca importa `utilities`; zero dead code (knip); `pnpm gate:fast` + `pnpm push`.
|
||||
- [ ] Testes de domínio previstos: unit `deepInfraTranscribe` (rede mockada) + `useMicTranscript` (stubs browser) + e2e do fluxo completo com a rota mockada.
|
||||
- [ ] Conversão WAV adiada só com gatilho de smoke (decisão 2) — registrado como débito observável, não bloqueia.
|
||||
52
src/app/(campaign)/campanha/api/ai-transcribe/route.ts
Normal file
52
src/app/(campaign)/campanha/api/ai-transcribe/route.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { deepInfraTranscribe } from '@/utilities/ai/deepInfraTranscribe'
|
||||
import { checkRateLimit, RATE_LIMIT_EXCEEDED_MESSAGE } from '@/utilities/ai/rateLimit'
|
||||
import { getCampaignUserRaw } from '@/utilities/campaignAuth'
|
||||
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
|
||||
|
||||
export const maxDuration = 60
|
||||
|
||||
/**
|
||||
* B173: voice transcription for the Sollinha chat. The client records audio
|
||||
* (MediaRecorder) and POSTs the clip as multipart `file`; this route guards it
|
||||
* exactly like the chat send (cookie auth, same rate-limit bucket, same-origin)
|
||||
* and forwards to Deep Infra Whisper large-v3. The audio is never persisted —
|
||||
* it exists only in memory for the duration of the provider call, then the
|
||||
* transcript text becomes an editable draft in the chat input.
|
||||
*
|
||||
* A deliberate sibling of `/campanha/api/ai-chat`: multipart (not JSON), so it
|
||||
* cannot ride `campaignJsonMutationRoute`; both origin-check and rate-limit by
|
||||
* the same per-user bucket so voice cannot become a bypass of the text limit.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (!isSameOriginRequest(request)) {
|
||||
return Response.json({ error: 'Requisição inválida.' }, { status: 403 })
|
||||
}
|
||||
|
||||
const user = await getCampaignUserRaw()
|
||||
if (!user) {
|
||||
return Response.json({ error: 'Não autenticado.' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!checkRateLimit(user.id)) {
|
||||
return Response.json({ error: RATE_LIMIT_EXCEEDED_MESSAGE }, { status: 429 })
|
||||
}
|
||||
|
||||
let file: Blob
|
||||
try {
|
||||
const form = await request.formData()
|
||||
const entry = form.get('file')
|
||||
if (!(entry instanceof File)) {
|
||||
return Response.json({ error: 'Arquivo de áudio ausente.' }, { status: 400 })
|
||||
}
|
||||
file = entry
|
||||
} catch {
|
||||
return Response.json({ error: 'Corpo da requisição inválido.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await deepInfraTranscribe(file)
|
||||
if (!result.ok) {
|
||||
return Response.json({ error: result.error }, { status: result.status })
|
||||
}
|
||||
|
||||
return Response.json({ text: result.text })
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { Bot, Send, User } from 'lucide-react'
|
||||
import { Bot, Mic, Send, Square, User } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
|
@ -13,12 +13,16 @@ import { Spinner } from '@/components/ui/Spinner'
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarContext'
|
||||
import { useMicTranscript } from '@/components/campaign/shell/ai/useMicTranscript'
|
||||
|
||||
export const CampaignAIChat = ({ className }: { className?: string }) => {
|
||||
const ctx = useAISidebar()
|
||||
const [input, setInput] = useState('')
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const mic = useMicTranscript()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const messages = useMemo(() => ctx?.messages ?? [], [ctx])
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
|
|
@ -26,6 +30,14 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
|
|||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// A completed transcript becomes an editable draft in the input — never an
|
||||
// auto-sent message (voices mishear proper nouns; the person reviews first).
|
||||
useEffect(() => {
|
||||
if (!mic.transcript) return
|
||||
setInput(mic.transcript)
|
||||
textareaRef.current?.focus()
|
||||
}, [mic.transcript])
|
||||
|
||||
if (!ctx) return null
|
||||
|
||||
const { sendMessage, status } = ctx
|
||||
|
|
@ -38,6 +50,10 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
|
|||
setInput('')
|
||||
}
|
||||
|
||||
const isRecording = mic.status === 'recording'
|
||||
const isTranscribing = mic.status === 'transcribing'
|
||||
const micDisabled = busy || isTranscribing
|
||||
|
||||
return (
|
||||
<div className={cn('grid min-h-0 grid-rows-[1fr_auto]', className)}>
|
||||
{/* Messages area */}
|
||||
|
|
@ -127,6 +143,7 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
|
|||
<form onSubmit={handleSubmit}>
|
||||
<InputGroup className="h-auto min-h-10">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
|
|
@ -135,11 +152,29 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
|
|||
handleSubmit(e)
|
||||
}
|
||||
}}
|
||||
placeholder="Pergunte para a Sollinha..."
|
||||
placeholder={isRecording ? 'Ouvindo...' : 'Pergunte para a Sollinha...'}
|
||||
disabled={busy}
|
||||
rows={1}
|
||||
className="min-h-0 flex-1 resize-none border-0 bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50"
|
||||
/>
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={isRecording ? 'Parar gravação' : 'Falar pergunta (voz)'}
|
||||
aria-pressed={isRecording}
|
||||
disabled={micDisabled}
|
||||
onClick={() => (isRecording ? mic.stop() : void mic.start())}
|
||||
>
|
||||
{isTranscribing ? (
|
||||
<Spinner className="size-4" />
|
||||
) : isRecording ? (
|
||||
<Square className="size-4 fill-current" />
|
||||
) : (
|
||||
<Mic className="size-4" />
|
||||
)}
|
||||
<span className="sr-only">{isRecording ? 'Parar gravação' : 'Falar'}</span>
|
||||
</InputGroupButton>
|
||||
<InputGroupButton
|
||||
type="submit"
|
||||
variant="default"
|
||||
|
|
@ -151,11 +186,42 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
|
|||
</InputGroupButton>
|
||||
</InputGroup>
|
||||
</form>
|
||||
<Marker className="mt-2">
|
||||
<MarkerContent>
|
||||
Sollinha pode cometer erros. Verifique informações importantes.
|
||||
</MarkerContent>
|
||||
</Marker>
|
||||
<div className="mt-2">
|
||||
{isRecording && (
|
||||
<Marker variant="default">
|
||||
<MarkerContent>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-1.5 animate-pulse rounded-full bg-destructive"
|
||||
aria-hidden
|
||||
/>
|
||||
Ouvindo... {mic.elapsed}s — toque no quadrado para finalizar.
|
||||
</span>
|
||||
</MarkerContent>
|
||||
</Marker>
|
||||
)}
|
||||
{mic.status === 'error' && mic.errorMessage && (
|
||||
<Marker variant="default">
|
||||
<MarkerContent>
|
||||
{mic.errorMessage}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline underline-offset-2"
|
||||
onClick={mic.dismissError}
|
||||
>
|
||||
Dispensar
|
||||
</button>
|
||||
</MarkerContent>
|
||||
</Marker>
|
||||
)}
|
||||
{!isRecording && mic.status !== 'error' && (
|
||||
<Marker>
|
||||
<MarkerContent>
|
||||
Sollinha pode cometer erros. Verifique informações importantes.
|
||||
</MarkerContent>
|
||||
</Marker>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
178
src/components/campaign/shell/ai/useMicTranscript.ts
Normal file
178
src/components/campaign/shell/ai/useMicTranscript.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
export type MicTranscriptStatus = 'idle' | 'recording' | 'transcribing' | 'error'
|
||||
|
||||
type UseMicTranscriptOptions = {
|
||||
/** Recording cap (seconds): stops an accidental infinite clip automatically. */
|
||||
maxDurationSeconds?: number
|
||||
}
|
||||
|
||||
const TRANSCRIBE_ENDPOINT = '/campanha/api/ai-transcribe'
|
||||
const DEFAULT_MAX_DURATION_SECONDS = 60
|
||||
const MESSAGE_VOICE_UNSUPPORTED = 'Este navegador não suporta gravação de voz por aqui.'
|
||||
const MESSAGE_PERMISSION_DENIED =
|
||||
'Não foi possível acessar o microfone. Verifique a permissão no navegador e tente novamente.'
|
||||
const MESSAGE_TRANSCRIBE_FAILED = 'Não foi possível transcrever o áudio. Tente novamente.'
|
||||
|
||||
const stopStreamTracks = (stream: MediaStream) => {
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
|
||||
/**
|
||||
* B173: the voice-input state machine behind the Sollinha chat mic button.
|
||||
* Owns the browser audio capture (getUserMedia + MediaRecorder), the elapsed
|
||||
* timer, the multipart POST to `/campanha/api/ai-transcribe` and the pt-BR
|
||||
* mapping of every failure — so `CampaignAIChat` stays render-only and the
|
||||
* whole flow is unit-testable with stubbed browser APIs.
|
||||
*
|
||||
* Product contract it enforces: the transcript is delivered as a draft string
|
||||
* (the caller puts it in the chat input), never auto-sent; any failure degrades
|
||||
* to an `error` state with a message, keeping the text chat fully usable.
|
||||
*/
|
||||
export const useMicTranscript = ({
|
||||
maxDurationSeconds = DEFAULT_MAX_DURATION_SECONDS,
|
||||
}: UseMicTranscriptOptions = {}) => {
|
||||
const [status, setStatus] = useState<MicTranscriptStatus>('idle')
|
||||
const [transcript, setTranscript] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
|
||||
const recorderRef = useRef<MediaRecorder | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const chunksRef = useRef<Blob[]>([])
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined)
|
||||
const elapsedRef = useRef(0)
|
||||
// True from unmount cleanup until the next `start()`: blocks an in-flight
|
||||
// transcription from reporting through a dead component (StrictMode remounts
|
||||
// are covered because `start()` resets it).
|
||||
const cancelledRef = useRef(false)
|
||||
|
||||
const clearElapsedTimer = useCallback(() => {
|
||||
if (timerRef.current !== undefined) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sendForTranscription = useCallback(async (clip: Blob) => {
|
||||
if (cancelledRef.current) return
|
||||
setStatus('transcribing')
|
||||
setErrorMessage(null)
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', clip, 'voice.webm')
|
||||
|
||||
try {
|
||||
const response = await fetch(TRANSCRIBE_ENDPOINT, { method: 'POST', body: form })
|
||||
const payload = (await response.json()) as { text?: string; error?: string }
|
||||
if (cancelledRef.current) return
|
||||
if (!response.ok || typeof payload.text !== 'string') {
|
||||
setStatus('error')
|
||||
setErrorMessage(payload.error ?? MESSAGE_TRANSCRIBE_FAILED)
|
||||
return
|
||||
}
|
||||
setTranscript(payload.text)
|
||||
setStatus('idle')
|
||||
} catch {
|
||||
if (cancelledRef.current) return
|
||||
setStatus('error')
|
||||
setErrorMessage(MESSAGE_TRANSCRIBE_FAILED)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** Stops an active capture; the recorder's `stop` event fires the transcription. */
|
||||
const stop = useCallback(() => {
|
||||
clearElapsedTimer()
|
||||
const recorder = recorderRef.current
|
||||
if (!recorder || recorder.state === 'inactive') return
|
||||
try {
|
||||
recorder.stop()
|
||||
} catch {
|
||||
// Already stopped (e.g. the platform stopped it): nothing to finalize.
|
||||
}
|
||||
}, [clearElapsedTimer])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
cancelledRef.current = false
|
||||
setErrorMessage(null)
|
||||
setTranscript('')
|
||||
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
setStatus('error')
|
||||
setErrorMessage(MESSAGE_VOICE_UNSUPPORTED)
|
||||
return
|
||||
}
|
||||
|
||||
let stream: MediaStream
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setErrorMessage(MESSAGE_PERMISSION_DENIED)
|
||||
return
|
||||
}
|
||||
|
||||
if (cancelledRef.current) {
|
||||
stopStreamTracks(stream)
|
||||
return
|
||||
}
|
||||
streamRef.current = stream
|
||||
|
||||
const recorder = new MediaRecorder(stream)
|
||||
recorderRef.current = recorder
|
||||
chunksRef.current = []
|
||||
|
||||
recorder.addEventListener('dataavailable', (event) => {
|
||||
if (event.data.size > 0) chunksRef.current.push(event.data)
|
||||
})
|
||||
recorder.addEventListener('stop', () => {
|
||||
const clip = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' })
|
||||
chunksRef.current = []
|
||||
if (clip.size > 0) void sendForTranscription(clip)
|
||||
else if (!cancelledRef.current) {
|
||||
setStatus('error')
|
||||
setErrorMessage(MESSAGE_TRANSCRIBE_FAILED)
|
||||
}
|
||||
})
|
||||
|
||||
recorder.start()
|
||||
setStatus('recording')
|
||||
elapsedRef.current = 0
|
||||
setElapsed(0)
|
||||
timerRef.current = setInterval(() => {
|
||||
elapsedRef.current += 1
|
||||
setElapsed(elapsedRef.current)
|
||||
if (elapsedRef.current >= maxDurationSeconds) stop()
|
||||
}, 1000)
|
||||
}, [maxDurationSeconds, sendForTranscription, stop])
|
||||
|
||||
// Unmount (or closing the chat) abandons the capture: tracks stop, recorder
|
||||
// stops, and nothing in flight reports through a dead component.
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelledRef.current = true
|
||||
clearElapsedTimer()
|
||||
const recorder = recorderRef.current
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
try {
|
||||
recorder.stop()
|
||||
} catch {
|
||||
// Already stopped by the stop button.
|
||||
}
|
||||
}
|
||||
if (streamRef.current) stopStreamTracks(streamRef.current)
|
||||
streamRef.current = null
|
||||
recorderRef.current = null
|
||||
},
|
||||
[clearElapsedTimer],
|
||||
)
|
||||
|
||||
const dismissError = useCallback(() => {
|
||||
setErrorMessage(null)
|
||||
setStatus('idle')
|
||||
}, [])
|
||||
|
||||
return { status, transcript, errorMessage, elapsed, start, stop, dismissError }
|
||||
}
|
||||
71
src/utilities/ai/deepInfraTranscribe.ts
Normal file
71
src/utilities/ai/deepInfraTranscribe.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import 'server-only'
|
||||
|
||||
/**
|
||||
* Transcribes a recorded voice clip to text via Deep Infra's OpenAI-compatible
|
||||
* transcription endpoint (B173). Same provider/key as the Sollinha LLM
|
||||
* (`DEEPINFRA_API_KEY`), so one account, one key, one invoice.
|
||||
*
|
||||
* The audio is forwarded as a multipart `file` and never persisted anywhere —
|
||||
* it lives only in this function's memory for the duration of the provider
|
||||
* call (product requirement: B173 processes and discards the recording).
|
||||
*/
|
||||
|
||||
export const DEEPINFRA_TRANSCRIBE_URL = 'https://api.deepinfra.com/v1/openai/audio/transcriptions'
|
||||
const DEEPINFRA_WHISPER_MODEL = 'openai/whisper-large-v3'
|
||||
const DEEPINFRA_LANGUAGE = 'pt'
|
||||
|
||||
export type TranscribeAudioResult =
|
||||
| { ok: true; text: string }
|
||||
| { ok: false; error: string; status: number }
|
||||
|
||||
/**
|
||||
* Sends the clip to Deep Infra Whisper large-v3 and returns the transcript.
|
||||
* Never throws: every failure becomes `{ ok: false }` with a pt-BR message the
|
||||
* route can return verbatim, so a provider outage or a missing key degrades to
|
||||
* "sem voz" instead of breaking the text chat.
|
||||
*/
|
||||
export const deepInfraTranscribe = async (file: Blob): Promise<TranscribeAudioResult> => {
|
||||
const apiKey = process.env.DEEPINFRA_API_KEY
|
||||
if (!apiKey) {
|
||||
return { ok: false, error: 'Transcrição de voz está indisponível no momento.', status: 503 }
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', file, 'voice.webm')
|
||||
form.append('model', DEEPINFRA_WHISPER_MODEL)
|
||||
form.append('language', DEEPINFRA_LANGUAGE)
|
||||
|
||||
try {
|
||||
const response = await fetch(DEEPINFRA_TRANSCRIBE_URL, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
body: form,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Não foi possível transcrever o áudio. Tente novamente.',
|
||||
status: 502,
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { text?: unknown }
|
||||
const text = typeof data.text === 'string' ? data.text.trim() : ''
|
||||
if (!text) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Não foi possível transcrever o áudio. Tente novamente.',
|
||||
status: 502,
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, text }
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Não foi possível transcrever o áudio. Tente novamente.',
|
||||
status: 502,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,10 @@ const store = new Map<number, RateLimitWindow>()
|
|||
const MAX_MESSAGES = 50
|
||||
const WINDOW_MS = 15 * 60 * 1000 // 15 minutes
|
||||
|
||||
/** Shared message returned by every rate-limited AI surface (chat send, voice transcribe). */
|
||||
export const RATE_LIMIT_EXCEEDED_MESSAGE =
|
||||
'Você atingiu o limite de mensagens. Aguarde alguns minutos e tente novamente.'
|
||||
|
||||
/** Cleans up expired windows. Called on every check to prevent memory leaks. */
|
||||
const cleanup = (now: number) => {
|
||||
for (const [userId, window] of store) {
|
||||
|
|
|
|||
133
tests/e2e/campaignAiTranscribe.e2e.spec.ts
Normal file
133
tests/e2e/campaignAiTranscribe.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
/**
|
||||
* B173 — voice transcription for the Sollinha chat.
|
||||
*
|
||||
* The real browser media stack (getUserMedia/MediaRecorder) is stubbed with an
|
||||
* init script: `getUserMedia` resolves a fake track and `MediaRecorder` pushes
|
||||
* one data chunk then stops on the next tick, so the app's own record→transcribe
|
||||
* flow runs for real while the two network surfaces are mocked:
|
||||
* - `/campanha/api/ai-transcribe` returns a fixed transcript (editable draft).
|
||||
* - `/campanha/api/ai-chat` returns an SSE stream (normal send).
|
||||
*/
|
||||
|
||||
const fakeMediaInit = `
|
||||
const makeTrack = () => ({ stop: () => {} })
|
||||
const fakeStream = { getTracks: () => [makeTrack()] }
|
||||
|
||||
navigator.mediaDevices.getUserMedia = async () => fakeStream
|
||||
|
||||
class FakeMediaRecorder {
|
||||
static isTypeSupported = () => true
|
||||
state = 'inactive'
|
||||
mimeType = 'audio/webm'
|
||||
listeners = {}
|
||||
constructor(stream) { this.stream = stream }
|
||||
addEventListener(type, cb) { (this.listeners[type] ??= []).push(cb) }
|
||||
start() { this.state = 'recording' }
|
||||
stop() {
|
||||
if (this.state === 'inactive') return
|
||||
this.listeners.dataavailable?.forEach((cb) =>
|
||||
cb({ data: new Blob(['fake'], { type: 'audio/webm' }) }))
|
||||
this.state = 'inactive'
|
||||
this.listeners.stop?.forEach((cb) => cb({}))
|
||||
}
|
||||
}
|
||||
window.MediaRecorder = FakeMediaRecorder
|
||||
`
|
||||
|
||||
const mockTranscribe = (page: Page, text: string) =>
|
||||
page.route('**/campanha/api/ai-transcribe', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ text }),
|
||||
})
|
||||
})
|
||||
|
||||
const mockAiChat = (page: Page) =>
|
||||
page.route('**/campanha/api/ai-chat', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
body: 'data: {"type":"text","text":"Resposta mockada da Sollinha."}\n\n',
|
||||
})
|
||||
})
|
||||
|
||||
const openChat = async (page: Page) => {
|
||||
await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 })
|
||||
}
|
||||
|
||||
test.describe('B173 — consulta por voz no chat da Sollinha', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.slow()
|
||||
await page.addInitScript(fakeMediaInit)
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
})
|
||||
|
||||
test('falar transcreve para um rascunho editável que roda no fluxo normal do chat', async ({
|
||||
page,
|
||||
campaign,
|
||||
}) => {
|
||||
const user = await campaign.fixtures.createCampaignUser('coordinator', {
|
||||
name: campaign.fixtures.value('Voz Coordenador'),
|
||||
})
|
||||
await campaign.login(page, user.email!, user.password)
|
||||
await page.goto('/campanha')
|
||||
|
||||
await mockTranscribe(page, 'Quantos votos tivemos em Ilhéus?')
|
||||
await mockAiChat(page)
|
||||
await openChat(page)
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'Pergunte para a Sollinha...' })
|
||||
|
||||
// Tap the mic → the bar enters "Ouvindo..." while we "speak".
|
||||
await page.getByRole('button', { name: 'Falar pergunta (voz)' }).click()
|
||||
await expect(page.getByText(/Ouvindo\.\.\./)).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// Tap again to finish → the fake recorder emits its chunk → transcript draft.
|
||||
await page.getByRole('button', { name: 'Parar gravação' }).click()
|
||||
await expect(input).toHaveValue('Quantos votos tivemos em Ilhéus?', { timeout: 20_000 })
|
||||
|
||||
// It stays an editable draft (never auto-sent): no user bubble yet.
|
||||
await expect(page.getByText('Quantos votos tivemos em Ilhéus?').first()).toBeVisible()
|
||||
|
||||
// Edit the draft, then send through the regular flow. The send is what must
|
||||
// happen — the mock ai-chat stream stops the chat from hanging, and the
|
||||
// user bubble proves the transcribed draft rode the same path as text.
|
||||
await input.fill('Quantos votos tivemos em Ilhéus em 2022?')
|
||||
await input.press('Enter')
|
||||
await expect(page.getByText('Quantos votos tivemos em Ilhéus em 2022?')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
})
|
||||
|
||||
test('permissão negada mostra mensagem e o chat por texto segue usável', async ({
|
||||
page,
|
||||
campaign,
|
||||
}) => {
|
||||
await page.addInitScript(
|
||||
`navigator.mediaDevices.getUserMedia = async () => { throw new Error('Permission denied') }`,
|
||||
)
|
||||
const user = await campaign.fixtures.createCampaignUser('coordinator', {
|
||||
name: campaign.fixtures.value('Voz Sem Permissão'),
|
||||
})
|
||||
await campaign.login(page, user.email!, user.password)
|
||||
await page.goto('/campanha')
|
||||
await mockAiChat(page)
|
||||
await openChat(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Falar pergunta (voz)' }).click()
|
||||
await expect(page.getByText(/Não foi possível acessar o microfone/)).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
|
||||
// Text chat still works.
|
||||
const input = page.getByRole('textbox', { name: 'Pergunte para a Sollinha...' })
|
||||
await input.fill('Foco no texto')
|
||||
await input.press('Enter')
|
||||
await expect(page.getByText('Foco no texto')).toBeVisible({ timeout: 20_000 })
|
||||
})
|
||||
})
|
||||
117
tests/unit/aiDeepInfraTranscribe.unit.spec.ts
Normal file
117
tests/unit/aiDeepInfraTranscribe.unit.spec.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DEEPINFRA_TRANSCRIBE_URL, deepInfraTranscribe } from '@/utilities/ai/deepInfraTranscribe'
|
||||
|
||||
/**
|
||||
* B173: the transcription network seam is deliberately the only place the chat
|
||||
* talks to Deep Infra for voice. Here, with `fetch` stubbed, the contract is
|
||||
* pinned: the multipart body carries the clip/model/language, the key is sent
|
||||
* as a Bearer header and never inside the body, and every failure degrades to
|
||||
* `{ ok: false }` with a pt-BR message instead of throwing.
|
||||
*/
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
const jsonResponse = (payload: unknown, status = 200) =>
|
||||
({ ok: status >= 200 && status < 300, status, json: async () => payload }) as Response
|
||||
|
||||
const smallClip = () => new Blob(['fake-webaudio-bytes'], { type: 'audio/webm' })
|
||||
|
||||
const stubFetch = (response: Response) => {
|
||||
fetchMock.mockReset()
|
||||
fetchMock.mockResolvedValue(response)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.DEEPINFRA_API_KEY = 'test-deepinfra-key'
|
||||
fetchMock.mockReset()
|
||||
fetchMock.mockResolvedValue(jsonResponse({ text: 'Quais os votos em Ilhéus?' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DEEPINFRA_API_KEY
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('deepInfraTranscribe', () => {
|
||||
it('posts the clip as multipart with the whisper model and pt language', async () => {
|
||||
stubFetch(jsonResponse({ text: 'Quais os votos em Ilhéus?' }))
|
||||
|
||||
const result = await deepInfraTranscribe(smallClip())
|
||||
|
||||
expect(result).toEqual({ ok: true, text: 'Quais os votos em Ilhéus?' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [
|
||||
string,
|
||||
{ method: string; headers: HeadersInit; body: FormData },
|
||||
]
|
||||
expect(url).toBe(DEEPINFRA_TRANSCRIBE_URL)
|
||||
expect(url).not.toContain('test-deepinfra-key') // the key never rides the URL
|
||||
expect(init.method).toBe('POST')
|
||||
const auth = new Headers(init.headers)
|
||||
expect(auth.get('Authorization')).toBe('Bearer test-deepinfra-key')
|
||||
const form = init.body
|
||||
expect(form.get('model')).toBe('openai/whisper-large-v3')
|
||||
expect(form.get('language')).toBe('pt')
|
||||
const file = form.get('file') as File
|
||||
expect(file.name).toBe('voice.webm')
|
||||
})
|
||||
|
||||
it('trims the transcript text', async () => {
|
||||
stubFetch(jsonResponse({ text: ' Votos em Feira? ' }))
|
||||
|
||||
await expect(deepInfraTranscribe(smallClip())).resolves.toEqual({
|
||||
ok: true,
|
||||
text: 'Votos em Feira?',
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when the key is missing', async () => {
|
||||
delete process.env.DEEPINFRA_API_KEY
|
||||
fetchMock.mockReset()
|
||||
|
||||
const result = await deepInfraTranscribe(smallClip())
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: 'Transcrição de voz está indisponível no momento.',
|
||||
status: 503,
|
||||
})
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps a provider error to a 502 without throwing', async () => {
|
||||
stubFetch(jsonResponse({ error: 'server error' }, 500))
|
||||
|
||||
await expect(deepInfraTranscribe(smallClip())).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'Não foi possível transcrever o áudio. Tente novamente.',
|
||||
status: 502,
|
||||
})
|
||||
})
|
||||
|
||||
it('maps an empty transcript to a 502', async () => {
|
||||
stubFetch(jsonResponse({ text: '' }))
|
||||
|
||||
await expect(deepInfraTranscribe(smallClip())).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'Não foi possível transcrever o áudio. Tente novamente.',
|
||||
status: 502,
|
||||
})
|
||||
})
|
||||
|
||||
it('degrades to a 502 when the provider call throws', async () => {
|
||||
fetchMock.mockReset()
|
||||
fetchMock.mockRejectedValue(new Error('network down'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(deepInfraTranscribe(smallClip())).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'Não foi possível transcrever o áudio. Tente novamente.',
|
||||
status: 502,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -205,6 +205,10 @@ describe('campaign JSON mutation route convention', () => {
|
|||
'src/app/(campaign)/campanha/api/ai-chat/route.ts',
|
||||
'streaming AI endpoint (ReadableStream, not JSON) — cookie-authenticated via campaign-token, origin-checked by cookie path',
|
||||
],
|
||||
[
|
||||
'src/app/(campaign)/campanha/api/ai-transcribe/route.ts',
|
||||
'multipart STT endpoint (audio FormData, not JSON) — sibling of ai-chat, cookie-authenticated via campaign-token + explicit same-origin check',
|
||||
],
|
||||
])
|
||||
|
||||
it('builds every POST route under src/app with campaignJsonMutationRoute', () => {
|
||||
|
|
|
|||
242
tests/unit/useMicTranscript.unit.spec.tsx
Normal file
242
tests/unit/useMicTranscript.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMicTranscript } from '@/components/campaign/shell/ai/useMicTranscript'
|
||||
|
||||
/**
|
||||
* B173: the voice-input state machine must be testable with stubbed browser
|
||||
* APIs — getUserMedia, MediaRecorder and fetch are all faked here. These cases
|
||||
* pin the product contract: recording→transcribe→draft, permission-denied
|
||||
* message, unsupported-browser message, provider-error pass-through, and the
|
||||
* automatic stop when the recording cap is reached.
|
||||
*/
|
||||
|
||||
const defineMediaDevices = (
|
||||
getUserMedia: (constraints: MediaStreamConstraints) => Promise<unknown>,
|
||||
) => {
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
value: { getUserMedia },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
|
||||
const removeMediaDevices = () => {
|
||||
Reflect.deleteProperty(navigator, 'mediaDevices')
|
||||
}
|
||||
|
||||
const makeStream = (): MediaStream =>
|
||||
({ getTracks: () => [{ stop: vi.fn() }] }) as unknown as MediaStream
|
||||
|
||||
/** Minimal MediaRecorder: queues a chunk on `start`, dispatches `stop` on `stop`. */
|
||||
class FakeMediaRecorder {
|
||||
static instances: FakeMediaRecorder[] = []
|
||||
state: 'inactive' | 'recording' = 'inactive'
|
||||
mimeType = 'audio/webm'
|
||||
private listeners: Record<string, Array<(event: unknown) => void>> = {}
|
||||
|
||||
constructor(public readonly stream: MediaStream) {
|
||||
FakeMediaRecorder.instances.push(this)
|
||||
}
|
||||
|
||||
addEventListener(type: string, callback: (event: unknown) => void) {
|
||||
;(this.listeners[type] ??= []).push(callback)
|
||||
}
|
||||
|
||||
start() {
|
||||
this.state = 'recording'
|
||||
queueMicrotask(() => {
|
||||
this.dispatch('dataavailable', { data: new Blob(['fake-audio'], { type: 'audio/webm' }) })
|
||||
})
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.state !== 'recording') return
|
||||
this.state = 'inactive'
|
||||
this.dispatch('stop', {})
|
||||
}
|
||||
|
||||
private dispatch(type: string, event: unknown) {
|
||||
for (const callback of this.listeners[type] ?? []) callback(event)
|
||||
}
|
||||
}
|
||||
|
||||
const installFakeMediaRecorder = () => {
|
||||
FakeMediaRecorder.instances = []
|
||||
Object.defineProperty(globalThis, 'MediaRecorder', {
|
||||
value: FakeMediaRecorder,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
|
||||
const removeFakeMediaRecorder = () => {
|
||||
Reflect.deleteProperty(globalThis, 'MediaRecorder')
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
const stubTranscribe = (payload: { text?: string; error?: string } | Error, status = 200) => {
|
||||
fetchMock.mockReset()
|
||||
if (payload instanceof Error) {
|
||||
fetchMock.mockRejectedValue(payload)
|
||||
return
|
||||
}
|
||||
fetchMock.mockResolvedValue({ ok: status >= 200 && status < 300, json: async () => payload })
|
||||
}
|
||||
|
||||
const resultOf = () => {
|
||||
const { result } = renderHook(() => useMicTranscript())
|
||||
return result
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
installFakeMediaRecorder()
|
||||
fetchMock.mockReset()
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ text: 'padrão' }) })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
removeFakeMediaRecorder()
|
||||
removeMediaDevices()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('useMicTranscript', () => {
|
||||
it('records and delivers the transcript as a draft (never auto-sent)', async () => {
|
||||
stubTranscribe({ text: 'Quais os votos em Ilhéus?' })
|
||||
const getUserMedia = vi.fn(() => Promise.resolve(makeStream()))
|
||||
defineMediaDevices(getUserMedia)
|
||||
const result = resultOf()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
expect(result.current.status).toBe('recording')
|
||||
|
||||
await act(async () => {
|
||||
result.current.stop()
|
||||
await vi.runAllTicks()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(result.current.status).toBe('idle')
|
||||
expect(result.current.transcript).toBe('Quais os votos em Ilhéus?')
|
||||
expect(result.current.errorMessage).toBeNull()
|
||||
expect(getUserMedia).toHaveBeenCalledWith({ audio: true })
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, { method: string; body: FormData }]
|
||||
expect(url).toBe('/campanha/api/ai-transcribe')
|
||||
expect(init.method).toBe('POST')
|
||||
const file = (init.body as FormData).get('file') as File
|
||||
expect(file.name).toBe('voice.webm')
|
||||
})
|
||||
|
||||
it('maps a permission denial to a pt-BR message and keeps the text chat usable', async () => {
|
||||
const getUserMedia = vi.fn(() => Promise.reject(new Error('Permission denied')))
|
||||
defineMediaDevices(getUserMedia)
|
||||
const result = resultOf()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
expect(result.current.status).toBe('error')
|
||||
expect(result.current.errorMessage).toContain('microfone')
|
||||
expect(result.current.transcript).toBe('')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails with a clear message when MediaRecorder is unsupported', async () => {
|
||||
removeFakeMediaRecorder()
|
||||
const result = resultOf()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
expect(result.current.status).toBe('error')
|
||||
expect(result.current.errorMessage).toContain('não suporta')
|
||||
})
|
||||
|
||||
it('passes the provider error message through (rate limit, outage)', async () => {
|
||||
stubTranscribe(
|
||||
{ error: 'Você atingiu o limite de mensagens. Aguarde alguns minutos e tente novamente.' },
|
||||
429,
|
||||
)
|
||||
const getUserMedia = vi.fn(() => Promise.resolve(makeStream()))
|
||||
defineMediaDevices(getUserMedia)
|
||||
const result = resultOf()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
await act(async () => {
|
||||
result.current.stop()
|
||||
await vi.runAllTicks()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(result.current.status).toBe('error')
|
||||
expect(result.current.errorMessage).toBe(
|
||||
'Você atingiu o limite de mensagens. Aguarde alguns minutos e tente novamente.',
|
||||
)
|
||||
expect(result.current.transcript).toBe('')
|
||||
})
|
||||
|
||||
it('degrades to a generic message when transcribe itself throws', async () => {
|
||||
stubTranscribe(new Error('network down'))
|
||||
const getUserMedia = vi.fn(() => Promise.resolve(makeStream()))
|
||||
defineMediaDevices(getUserMedia)
|
||||
const result = resultOf()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
await act(async () => {
|
||||
result.current.stop()
|
||||
await vi.runAllTicks()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(result.current.status).toBe('error')
|
||||
expect(result.current.errorMessage).toContain('transcrever')
|
||||
})
|
||||
|
||||
it('auto-stops and transcribes when the recording cap is reached', async () => {
|
||||
stubTranscribe({ text: 'cap' })
|
||||
const getUserMedia = vi.fn(() => Promise.resolve(makeStream()))
|
||||
defineMediaDevices(getUserMedia)
|
||||
const { result } = renderHook(() => useMicTranscript({ maxDurationSeconds: 2 }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
vi.advanceTimersByTime(2000)
|
||||
await vi.runAllTicks()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(result.current.status).toBe('idle')
|
||||
expect(result.current.transcript).toBe('cap')
|
||||
})
|
||||
|
||||
it('cleans up tracks on unmount mid-recording', async () => {
|
||||
stubTranscribe({ text: 'ok' })
|
||||
const trackStop = vi.fn()
|
||||
const getUserMedia = vi.fn(() =>
|
||||
Promise.resolve({ getTracks: () => [{ stop: trackStop }] } as unknown as MediaStream),
|
||||
)
|
||||
defineMediaDevices(getUserMedia)
|
||||
const { result, unmount } = renderHook(() => useMicTranscript())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
unmount()
|
||||
})
|
||||
|
||||
expect(trackStop).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue