iara-pwa/src/components/Chat.tsx

344 lines
11 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import {
AssistantRuntimeProvider,
ComposerPrimitive,
ThreadPrimitive,
useExternalStoreRuntime,
} from "@assistant-ui/react";
import { ApprovalCard } from "./ApprovalCard";
import { itemToMessage, type Item } from "../lib/messages";
import { loadSettings, type Settings } from "../lib/settings";
import { recordAudio, speak, transcribe } from "../lib/voice";
import { WsClient, type ApprovalDecision, type ChatEvent } from "../lib/ws";
function nextId(): string {
return `m-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
interface ChatProps {
onEditSettings: () => void;
}
export function Chat({ onEditSettings }: ChatProps) {
const settings = loadSettings();
const wsRef = useRef<WsClient | null>(null);
const [items, setItems] = useState<Item[]>([]);
const [running, setRunning] = useState(false);
const [status, setStatus] = useState<"connecting" | "open" | "closed" | "reconnecting">("connecting");
const [recording, setRecording] = useState(false);
const [voiceOut, setVoiceOut] = useState(false);
const voiceOutRef = useRef(voiceOut);
voiceOutRef.current = voiceOut;
const stopRecordingRef = useRef<(() => void) | null>(null);
const handleEvent = (ev: ChatEvent, s: Settings) => {
switch (ev.type) {
case "chunk":
setItems((prev) => {
const last = prev[prev.length - 1];
if (last?.kind === "assistant") {
return [...prev.slice(0, -1), { ...last, text: last.text + (ev.content ?? "") }];
}
return [...prev, { kind: "assistant", id: nextId(), text: ev.content ?? "" }];
});
break;
case "chunk_reset":
setItems((prev) =>
prev[prev.length - 1]?.kind === "assistant" ? prev.slice(0, -1) : prev,
);
break;
case "thinking":
setItems((prev) => [
...prev.filter((i) => i.kind !== "thinking"),
{ kind: "thinking", id: nextId(), text: ev.content ?? "" },
]);
break;
case "tool_call":
setItems((prev) => [
...prev.filter((i) => i.kind !== "thinking"),
{ kind: "tool", id: nextId(), toolName: ev.name, args: ev.args },
]);
break;
case "tool_result":
setItems((prev) => {
const idx = [...prev].reverse().findIndex((i) => i.kind === "tool");
if (idx < 0) return prev;
const i = prev.length - 1 - idx;
const t = prev[i] as Extract<Item, { kind: "tool" }>;
return [...prev.slice(0, i), { ...t, output: ev.output }, ...prev.slice(i + 1)];
});
break;
case "done": {
setItems((prev) => {
const last = prev[prev.length - 1];
const text = ev.full_response ?? (last?.kind === "assistant" ? last.text : "");
if (s.ttsUrl && voiceOutRef.current && text) {
speak(s.ttsUrl, text).catch(() => {});
}
if (last?.kind === "assistant") return [...prev.slice(0, -1), { ...last, text }];
if (text) return [...prev, { kind: "assistant", id: nextId(), text }];
return prev;
});
setRunning(false);
break;
}
case "approval_request":
setItems((prev) => [
...prev,
{
kind: "approval",
id: nextId(),
requestId: ev.request_id,
tool: ev.tool,
summary: ev.arguments_summary ?? "",
timeoutSecs: ev.timeout_secs,
},
]);
setRunning(false);
break;
case "error":
setItems((prev) => [
...prev,
{
kind: "assistant",
id: nextId(),
text: `Erro: ${ev.message ?? ev.code ?? "desconhecido"}`,
},
]);
setRunning(false);
break;
case "aborted":
setRunning(false);
break;
default:
break;
}
};
useEffect(() => {
const ws = new WsClient({
url: wsUrlOf(settings),
protocols: ["zeroclaw.v1", `bearer.${settings.token}`],
onStatus: setStatus,
onEvent: (ev) => handleEvent(ev, settings),
});
wsRef.current = ws;
ws.connect();
return () => ws.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleApproval = (requestId: string, decision: ApprovalDecision) => {
wsRef.current?.sendApproval(requestId, decision);
setItems((prev) =>
prev.map((i) => (i.kind === "approval" && i.requestId === requestId ? { ...i, decision } : i)),
);
};
const sendText = (text: string) => {
setItems((prev) => [...prev, { kind: "user", id: nextId(), text }]);
setRunning(true);
wsRef.current?.sendMessage(text);
};
const toggleMic = () => {
if (recording) {
stopRecordingRef.current?.();
setRecording(false);
return;
}
if (!settings.sttUrl) {
setItems((prev) => [
...prev,
{
kind: "assistant",
id: nextId(),
text: "Configure a URL de STT (voz → texto) nas configurações para usar o microfone.",
},
]);
return;
}
setRecording(true);
stopRecordingRef.current = recordAudio(
async (blob) => {
setRecording(false);
try {
const text = await transcribe(settings.sttUrl, blob);
if (text) sendText(text);
} catch (err) {
setItems((prev) => [
...prev,
{ kind: "assistant", id: nextId(), text: `Falha na transcrição: ${String(err)}` },
]);
}
},
(err) => {
setRecording(false);
setItems((prev) => [
...prev,
{ kind: "assistant", id: nextId(), text: `Microfone indisponível: ${String(err)}` },
]);
},
);
};
const messages = useMemo(() => items.map(itemToMessage), [items]);
const runtime = useExternalStoreRuntime({
messages,
isRunning: running,
convertMessage: (m) => m,
onNew: async (message) => {
const text = message.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("")
.trim();
if (!text) return;
sendText(text);
},
});
const statusLabel =
status === "open"
? "conectado"
: status === "connecting"
? "conectando…"
: status === "reconnecting"
? "reconectando…"
: "desconectado";
const renderItem = (item: Item) => {
if (item.kind === "user") {
return (
<div key={item.id} className="flex justify-end">
<div className="max-w-[80%] rounded-2xl rounded-br-sm bg-teal-800 px-3 py-2 text-sm text-teal-50">
{item.text}
</div>
</div>
);
}
if (item.kind === "thinking") {
return (
<details key={item.id} className="text-xs text-slate-500">
<summary>Raciocínio</summary>
<p className="mt-1 whitespace-pre-wrap">{item.text}</p>
</details>
);
}
if (item.kind === "tool") {
return (
<div
key={item.id}
className="my-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 font-mono text-[11px] text-slate-400"
>
⚙ {item.toolName}
{item.output !== undefined && <span className="text-teal-400"> ✓</span>}
</div>
);
}
if (item.kind === "approval") {
return (
<div key={item.id}>
<ApprovalCard
tool={item.tool}
summary={item.summary}
timeoutSecs={item.timeoutSecs}
decision={item.decision}
onDecision={(d) => handleApproval(item.requestId, d)}
/>
</div>
);
}
return (
<div key={item.id} className="flex justify-start">
<div className="max-w-[85%] whitespace-pre-wrap rounded-2xl rounded-bl-sm bg-slate-800 px-3 py-2 text-sm text-slate-100">
{item.text}
</div>
</div>
);
};
return (
<div className="mx-auto flex h-dvh max-w-3xl flex-col">
<header className="flex items-center justify-between border-b border-slate-800 px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-teal-300">Iara</span>
<span
className={`h-2 w-2 rounded-full ${status === "open" ? "bg-emerald-400" : "bg-slate-600"}`}
title={statusLabel}
/>
<span className="text-xs text-slate-500">{statusLabel}</span>
</div>
<div className="flex items-center gap-2">
<button
className={`rounded px-2 py-1 text-xs font-medium ${
voiceOut ? "bg-teal-700 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
}`}
onClick={() => setVoiceOut((v) => !v)}
title="Ler respostas em voz alta (requer URL de TTS)"
>
🔊
</button>
<button
className="rounded bg-slate-800 px-2 py-1 text-xs font-medium text-slate-300 hover:bg-slate-700"
onClick={onEditSettings}
>
Configurações
</button>
</div>
</header>
<AssistantRuntimeProvider runtime={runtime}>
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col">
<ThreadPrimitive.Viewport className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{items.length === 0 && (
<div className="flex h-full items-center justify-center text-sm text-slate-600">
Fale com a Iara — pergunte, peça para criar eventos, tarefas, notas e e-mails.
</div>
)}
<div className="space-y-3">{items.map(renderItem)}</div>
</ThreadPrimitive.Viewport>
<footer className="border-t border-slate-800 px-4 py-2">
<ComposerPrimitive.Root className="flex items-center gap-2">
<ComposerPrimitive.Input
placeholder="Escreva sua mensagem…"
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 outline-none placeholder:text-slate-600 focus:border-teal-600"
/>
<ComposerPrimitive.Send asChild disabled={status !== "open"}>
<button
className="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-500 disabled:opacity-40"
title="Enviar"
>
Enviar
</button>
</ComposerPrimitive.Send>
<button
className={`flex items-center gap-1 rounded-full px-3 py-2 text-sm font-medium ${
recording
? "bg-red-600 text-white"
: "bg-slate-800 text-slate-200 hover:bg-slate-700"
}`}
onClick={toggleMic}
title="Falar com a Iara (requer URL de STT)"
>
{recording ? "● Gravando" : "🎙"}
</button>
</ComposerPrimitive.Root>
</footer>
</ThreadPrimitive.Root>
</AssistantRuntimeProvider>
</div>
);
}
function wsUrlOf(s: Settings): string {
const base = s.gatewayUrl.trim().replace(/\/+$/, "");
const params = new URLSearchParams({ agent: s.agent.trim() || "main" });
const name = s.sessionName.trim();
if (name) params.set("name", name);
if (s.token.trim()) params.set("token", s.token.trim());
return `${base}?${params.toString()}`;
}