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"\)/);
+});