mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(dashboard): Faro chat with push-to-talk voice on the Conductor panel
This commit is contained in:
1
changelog.d/features/conductor-voice.md
Normal file
1
changelog.d/features/conductor-voice.md
Normal file
@@ -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
|
||||
@@ -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() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<FaroChat />
|
||||
|
||||
<Modal isOpen={detail !== null} onClose={() => setDetail(null)} title={t("detailTitle")} size="lg">
|
||||
{detail && (
|
||||
<div className="space-y-4 text-sm">
|
||||
|
||||
272
src/app/(dashboard)/dashboard/conductor/FaroChat.tsx
Normal file
272
src/app/(dashboard)/dashboard/conductor/FaroChat.tsx
Normal file
@@ -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<string> {
|
||||
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<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [voice, setVoice] = useState<VoiceState>("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<MediaRecorder | null>(null);
|
||||
const logRef = useRef<HTMLDivElement>(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<VoiceState, string> = {
|
||||
idle: t("voiceIdle"),
|
||||
listening: t("voiceListening"),
|
||||
thinking: t("voiceThinking"),
|
||||
speaking: t("voiceSpeaking"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={t("faroTitle")} subtitle={t("faroSubtitle")}>
|
||||
<div className="space-y-3">
|
||||
<div ref={logRef} className="max-h-72 overflow-y-auto space-y-2 text-sm">
|
||||
{messages.length === 0 && <p className="text-text-muted text-xs">{t("faroEmpty")}</p>}
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={m.role === "user" ? "text-right" : "text-left"}>
|
||||
<span
|
||||
className={
|
||||
m.role === "user"
|
||||
? "inline-block rounded px-2 py-1 bg-primary/10"
|
||||
: "inline-block rounded px-2 py-1 bg-black/5 dark:bg-white/10 whitespace-pre-wrap"
|
||||
}
|
||||
>
|
||||
{m.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{err && <Badge variant="error">{err}</Badge>}
|
||||
{pending && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="warning" dot>{t("faroPending")}</Badge>
|
||||
<button type="button" className="text-sm underline" onClick={() => void send("sim")}>
|
||||
{t("yes")}
|
||||
</button>
|
||||
<button type="button" className="text-sm underline" onClick={() => void send("não")}>
|
||||
{t("no")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="flex-1 rounded border border-black/10 dark:border-white/10 bg-transparent px-2 py-1 text-sm"
|
||||
placeholder={t("faroPlaceholder")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void send(input);
|
||||
}}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button type="button" className="text-sm underline" onClick={() => void send(input)} disabled={busy}>
|
||||
{t("faroSend")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`text-sm px-2 py-1 rounded ${voice === "listening" ? "bg-red-500/20" : "bg-black/5 dark:bg-white/10"}`}
|
||||
title={t("pushToTalk")}
|
||||
aria-pressed={voice === "listening"}
|
||||
onMouseDown={() => void startRecording()}
|
||||
onMouseUp={stopRecording}
|
||||
onMouseLeave={stopRecording}
|
||||
onTouchStart={() => void startRecording()}
|
||||
onTouchEnd={stopRecording}
|
||||
>
|
||||
🎙 {voiceLabel[voice]}
|
||||
</button>
|
||||
<label className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<input type="checkbox" checked={speak} onChange={(e) => setSpeak(e.target.checked)} />
|
||||
{t("speakAnswers")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details className="text-xs text-text-muted">
|
||||
<summary>{t("voiceModels")}</summary>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<label className="flex-1">
|
||||
STT
|
||||
<input
|
||||
className="w-full rounded border border-black/10 dark:border-white/10 bg-transparent px-2 py-1"
|
||||
value={sttModel}
|
||||
onChange={(e) => persistModels(e.target.value, ttsModel)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-1">
|
||||
TTS
|
||||
<input
|
||||
className="w-full rounded border border-black/10 dark:border-white/10 bg-transparent px-2 py-1"
|
||||
value={ttsModel}
|
||||
onChange={(e) => persistModels(sttModel, e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
39
tests/unit/conductor-faro-chat.test.ts
Normal file
39
tests/unit/conductor-faro-chat.test.ts
Normal file
@@ -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"\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user