From 4b2bd2e37f7f619cb4b2ac5e47a0d71d1595312a Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:11:47 -0300 Subject: [PATCH] feat(dashboard): Faro chat with push-to-talk voice on the Conductor panel --- changelog.d/features/conductor-voice.md | 1 + .../conductor/ConductorPageClient.tsx | 4 + .../dashboard/conductor/FaroChat.tsx | 272 ++++++++++++++++++ src/i18n/messages/en.json | 21 +- src/i18n/messages/pt-BR.json | 21 +- tests/unit/conductor-faro-chat.test.ts | 39 +++ 6 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 changelog.d/features/conductor-voice.md create mode 100644 src/app/(dashboard)/dashboard/conductor/FaroChat.tsx create mode 100644 tests/unit/conductor-faro-chat.test.ts diff --git a/changelog.d/features/conductor-voice.md b/changelog.d/features/conductor-voice.md new file mode 100644 index 0000000000..215b358ddd --- /dev/null +++ b/changelog.d/features/conductor-voice.md @@ -0,0 +1 @@ +- feat(dashboard): Faro chat with voice on the Conductor panel — text via `/api/conductor/ask` (server-side proxy to the spokesperson; hub credential never reaches the browser; `pending` → Sim/Não confirmation buttons) and a guaranteed push-to-talk voice cycle (MediaRecorder → `/api/v1/audio/transcriptions` → ask → `/api/v1/audio/speech` playback), with operator-configurable STT/TTS models diff --git a/src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx b/src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx index 1bf1899bb6..e23b888185 100644 --- a/src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx +++ b/src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx @@ -11,6 +11,8 @@ import { useTranslations } from "next-intl"; import { Badge, Card, ConfirmModal, DataTable, EmptyState, Modal } from "@/shared/components"; +import FaroChat from "./FaroChat"; + interface FleetRunner { id: string; name: string; @@ -174,6 +176,8 @@ export default function ConductorPageClient() { )} + + setDetail(null)} title={t("detailTitle")} size="lg"> {detail && (
diff --git a/src/app/(dashboard)/dashboard/conductor/FaroChat.tsx b/src/app/(dashboard)/dashboard/conductor/FaroChat.tsx new file mode 100644 index 0000000000..d1406d4663 --- /dev/null +++ b/src/app/(dashboard)/dashboard/conductor/FaroChat.tsx @@ -0,0 +1,272 @@ +"use client"; + +/** + * Faro chat with voice (Conductor PRD RF4). Text: input → /api/conductor/ask + * (server-side proxy — the hub credential never reaches the browser). When the + * answer carries `pending`, Faro is asking for confirmation: the Sim/Não + * buttons just send "sim"/"não" — the safety gate lives in Faro's engine. + * + * Voice (guaranteed cycle, PRD RF4): push-to-talk → MediaRecorder → + * POST /api/v1/audio/transcriptions (multipart) → text → /ask → response → + * POST /api/v1/audio/speech → play the returned audio blob. STT/TTS models are + * operator-configurable (provider/model of THIS OmniRoute install), persisted + * in localStorage. + */ + +import { useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; + +import { Badge, Card } from "@/shared/components"; + +interface ChatMessage { + role: "user" | "faro"; + text: string; +} + +type VoiceState = "idle" | "listening" | "thinking" | "speaking"; + +const STT_KEY = "conductor.sttModel"; +const TTS_KEY = "conductor.ttsModel"; + +function safeGet(key: string, fallback: string): string { + try { + return localStorage.getItem(key) || fallback; + } catch { + return fallback; + } +} + +async function errorMessageOf(res: Response, fallback: string): Promise { + try { + const data = await res.json(); + return data?.error?.message ?? fallback; + } catch { + return fallback; + } +} + +export default function FaroChat() { + const t = useTranslations("conductor"); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [pending, setPending] = useState(false); + const [busy, setBusy] = useState(false); + const [voice, setVoice] = useState("idle"); + const [speak, setSpeak] = useState(false); + const [sttModel, setSttModel] = useState("openai/whisper-1"); + const [ttsModel, setTtsModel] = useState("openai/tts-1"); + const [err, setErr] = useState(""); + const recorderRef = useRef(null); + const logRef = useRef(null); + + useEffect(() => { + setSttModel(safeGet(STT_KEY, "openai/whisper-1")); + setTtsModel(safeGet(TTS_KEY, "openai/tts-1")); + }, []); + useEffect(() => { + logRef.current?.scrollTo({ top: logRef.current.scrollHeight }); + }, [messages]); + + const persistModels = (stt: string, tts: string) => { + setSttModel(stt); + setTtsModel(tts); + try { + localStorage.setItem(STT_KEY, stt); + localStorage.setItem(TTS_KEY, tts); + } catch { + // modo privado: segue só em memória + } + }; + + const playAnswer = async (text: string) => { + setVoice("speaking"); + try { + const res = await fetch("/api/v1/audio/speech", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: ttsModel, input: text }), + }); + if (!res.ok) { + setErr(await errorMessageOf(res, t("ttsFailed"))); + return; + } + const url = URL.createObjectURL(await res.blob()); + const audio = new Audio(url); + await audio.play().catch(() => undefined); + audio.onended = () => URL.revokeObjectURL(url); + } finally { + setVoice("idle"); + } + }; + + const send = async (message: string, viaVoice = false) => { + const clean = message.trim(); + if (!clean || busy) return; + setErr(""); + setBusy(true); + setVoice("thinking"); + setMessages((m) => [...m, { role: "user", text: clean }]); + setInput(""); + try { + const res = await fetch("/api/conductor/ask", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: clean }), + }); + if (!res.ok) { + setErr(await errorMessageOf(res, t("faroOffline"))); + return; + } + const data = await res.json(); + setMessages((m) => [...m, { role: "faro", text: data.text }]); + setPending(Boolean(data.pending)); + if (viaVoice && speak && data.text) await playAnswer(data.text); + } catch { + setErr(t("faroOffline")); + } finally { + setBusy(false); + setVoice((v) => (v === "thinking" ? "idle" : v)); + } + }; + + const startRecording = async () => { + setErr(""); + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream); + const chunks: Blob[] = []; + recorder.ondataavailable = (e) => e.data.size > 0 && chunks.push(e.data); + recorder.onstop = async () => { + stream.getTracks().forEach((track) => track.stop()); + setVoice("thinking"); + const blob = new Blob(chunks, { type: recorder.mimeType || "audio/webm" }); + const form = new FormData(); + form.append("model", sttModel); + form.append("file", new File([blob], "faro-ptt.webm", { type: blob.type })); + try { + // multipart: sem header manual — o browser define o boundary + const res = await fetch("/api/v1/audio/transcriptions", { method: "POST", body: form }); + if (!res.ok) { + setErr(await errorMessageOf(res, t("sttFailed"))); + setVoice("idle"); + return; + } + const data = await res.json(); + if (data.text) await send(data.text, true); + else setVoice("idle"); + } catch { + setErr(t("sttFailed")); + setVoice("idle"); + } + }; + recorderRef.current = recorder; + recorder.start(); + setVoice("listening"); + } catch { + setErr(t("micDenied")); + } + }; + + const stopRecording = () => { + if (recorderRef.current?.state === "recording") recorderRef.current.stop(); + recorderRef.current = null; + }; + + const voiceLabel: Record = { + idle: t("voiceIdle"), + listening: t("voiceListening"), + thinking: t("voiceThinking"), + speaking: t("voiceSpeaking"), + }; + + return ( + +
+
+ {messages.length === 0 &&

{t("faroEmpty")}

} + {messages.map((m, i) => ( +
+ + {m.text} + +
+ ))} +
+ + {err && {err}} + {pending && ( +
+ {t("faroPending")} + + +
+ )} + +
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void send(input); + }} + disabled={busy} + /> + + + +
+ +
+ {t("voiceModels")} +
+ + +
+
+
+
+ ); +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index facf291e6c..9c28a43f1b 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -12116,6 +12116,25 @@ "cancelConfirmMessage": "The hub will abort the execution on the runner. This cannot be undone.", "cancelFailed": "The hub refused the cancellation", "close": "Close", - "error": "Error" + "error": "Error", + "faroTitle": "Faro — fleet spokesperson", + "faroSubtitle": "Chat and voice, anchored in real fleet events. Destructive commands always ask for confirmation.", + "faroEmpty": "Ask about the fleet — e.g. \"how is the fleet?\"", + "faroPending": "Faro is asking for confirmation", + "faroPlaceholder": "talk to Faro…", + "faroSend": "send", + "faroOffline": "Faro (spokesperson) is offline", + "yes": "Yes", + "no": "No", + "pushToTalk": "Hold to talk", + "speakAnswers": "speak answers", + "voiceModels": "Voice models (provider/model of this OmniRoute)", + "voiceIdle": "talk", + "voiceListening": "listening…", + "voiceThinking": "thinking…", + "voiceSpeaking": "speaking…", + "sttFailed": "Transcription failed (check the STT model/provider)", + "ttsFailed": "Speech synthesis failed (check the TTS model/provider)", + "micDenied": "Microphone unavailable or permission denied" } } diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 80e39669b8..d6b48d5e0f 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -12116,6 +12116,25 @@ "cancelConfirmMessage": "O hub aborta a execução no runner. Não dá para desfazer.", "cancelFailed": "O hub recusou o cancelamento", "close": "Fechar", - "error": "Erro" + "error": "Erro", + "faroTitle": "Faro — porta-voz da frota", + "faroSubtitle": "Chat e voz, ancorados nos eventos reais da frota. Comandos destrutivos sempre pedem confirmação.", + "faroEmpty": "Pergunte sobre a frota — ex.: \"como está a frota?\"", + "faroPending": "O Faro está pedindo confirmação", + "faroPlaceholder": "fale com o Faro…", + "faroSend": "enviar", + "faroOffline": "Faro (porta-voz) fora do ar", + "yes": "Sim", + "no": "Não", + "pushToTalk": "Segure para falar", + "speakAnswers": "falar as respostas", + "voiceModels": "Modelos de voz (provider/model deste OmniRoute)", + "voiceIdle": "falar", + "voiceListening": "ouvindo…", + "voiceThinking": "pensando…", + "voiceSpeaking": "falando…", + "sttFailed": "Transcrição falhou (confira modelo/provider de STT)", + "ttsFailed": "Síntese de voz falhou (confira modelo/provider de TTS)", + "micDenied": "Microfone indisponível ou permissão negada" } } diff --git a/tests/unit/conductor-faro-chat.test.ts b/tests/unit/conductor-faro-chat.test.ts new file mode 100644 index 0000000000..91fe7bd4d5 --- /dev/null +++ b/tests/unit/conductor-faro-chat.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +// Invariantes de segurança/UX do chat com voz (componentes React: vitest-ui advisory + dashboard-typecheck). +const CHAT = "src/app/(dashboard)/dashboard/conductor/FaroChat.tsx"; + +function src(): string { + return fs.readFileSync(path.join(process.cwd(), CHAT), "utf8"); +} + +test("client fala SÓ com o OmniRoute: /api/conductor/ask + /api/v1/audio/* (nunca Faro/hub direto)", () => { + const s = src(); + assert.match(s, /"use client"/); + assert.match(s, /\/api\/conductor\/ask/); + assert.match(s, /\/api\/v1\/audio\/transcriptions/); + assert.match(s, /\/api\/v1\/audio\/speech/); + assert.ok(!s.includes(":7920"), "endereço do Faro nunca no client"); + assert.ok(!s.includes("CONDUCTOR_"), "nenhuma env do Conductor no client"); +}); + +test("pending do Faro → botões Sim/Não que enviam 'sim'/'não' (trava de confirmação é do motor do Faro)", () => { + const s = src(); + assert.match(s, /pending/); + assert.match(s, /"sim"/); + assert.match(s, /"não"/); +}); + +test("voz: push-to-talk com MediaRecorder/getUserMedia; STT multipart sem Content-Type manual; TTS via Blob com revoke", () => { + const s = src(); + assert.match(s, /navigator\.mediaDevices\.getUserMedia/); + assert.match(s, /MediaRecorder/); + assert.match(s, /FormData\(\)/); + assert.ok(!/audio\/transcriptions[\s\S]{0,300}content-type/i.test(s), "multipart deixa o browser definir o boundary"); + assert.match(s, /URL\.createObjectURL/); + assert.match(s, /URL\.revokeObjectURL/); + assert.match(s, /useTranslations\("conductor"\)/); +});