"use client"; import { useEffect, useRef, useState } from "react"; import { ImagePlus, Loader2, Mic, Send, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { consumeAgentChatStream } from "../assistant-chat-stream"; import { clearAssistantChat, loadAssistantChat, saveAssistantChat, toClientChatMessage, type AssistantChatMessage, } from "../assistant-chat-storage"; import { useVoiceInput } from "./use-voice-input"; type Props = { configured: boolean; userId: string; assistantName: string; }; type PendingImage = { url: string; }; async function uploadAssistantImage(file: File): Promise { const formData = new FormData(); formData.append("file", file); const response = await fetch("/api/uploads?scope=assistant", { method: "POST", body: formData }); if (!response.ok) { const payload = (await response.json().catch(() => null)) as { error?: string } | null; throw new Error(payload?.error ?? "Image upload failed"); } const payload = (await response.json()) as { url: string }; return payload.url; } export function AssistantPanel({ configured, userId, assistantName }: Props) { const [messages, setMessages] = useState(() => loadAssistantChat(userId)); const [input, setInput] = useState(""); const [pendingImage, setPendingImage] = useState(null); const [uploadingImage, setUploadingImage] = useState(false); const [error, setError] = useState(null); const [isPending, setIsPending] = useState(false); const [activityLabel, setActivityLabel] = useState(null); const listRef = useRef(null); const abortRef = useRef(null); const imageInputRef = useRef(null); const { state: voiceState, toggleRecording } = useVoiceInput({ disabled: isPending || uploadingImage, onTranscript: (text) => { setInput((current) => (current.trim() ? `${current.trim()} ${text}` : text)); setError(null); }, onError: (message) => setError(message), }); useEffect(() => { saveAssistantChat(userId, messages); }, [messages, userId]); useEffect(() => { return () => { abortRef.current?.abort(); }; }, []); function scrollToBottom() { requestAnimationFrame(() => { const node = listRef.current; if (node) node.scrollTop = node.scrollHeight; }); } function clearChat() { abortRef.current?.abort(); setMessages([]); setInput(""); setPendingImage(null); setError(null); setActivityLabel(null); setIsPending(false); clearAssistantChat(userId); } async function handleImageSelect(event: React.ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; if (!file || isPending || uploadingImage) return; setUploadingImage(true); setError(null); try { const url = await uploadAssistantImage(file); setPendingImage({ url }); } catch (err) { setError(err instanceof Error ? err.message : "Image upload failed"); } finally { setUploadingImage(false); } } async function sendMessage() { const text = input.trim(); const hasImage = pendingImage !== null; if ((!text && !hasImage) || isPending || voiceState !== "idle") return; const content = text || "Help me with this image."; const userMessage: AssistantChatMessage = { role: "user", content, ...(pendingImage ? { imageUrl: pendingImage.url } : {}), }; const nextMessages: AssistantChatMessage[] = [...messages, userMessage]; setInput(""); setPendingImage(null); setError(null); setMessages(nextMessages); setIsPending(true); setActivityLabel(hasImage ? "Reading your photo…" : "Understanding your request…"); scrollToBottom(); abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; try { const response = await fetch("/api/agent/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: nextMessages.map(toClientChatMessage), stream: true, }), signal: controller.signal, }); const result = await consumeAgentChatStream(response, (event) => { if (event.type === "thinking" || event.type === "tool" || event.type === "responding") { setActivityLabel(event.label); scrollToBottom(); } }); if (!result.message.content) { throw new Error("Assistant returned an empty response"); } setMessages((current) => [ ...current, { role: "assistant", content: result.message.content }, ]); scrollToBottom(); } catch (err) { if (err instanceof Error && err.name === "AbortError") return; setError(err instanceof Error ? err.message : "Something went wrong"); } finally { setIsPending(false); setActivityLabel(null); abortRef.current = null; } } const showEmptyState = messages.length === 0 && !isPending; const inputDisabled = isPending || voiceState === "transcribing" || uploadingImage; const canSend = !isPending && voiceState === "idle" && !uploadingImage && (input.trim().length > 0 || pendingImage !== null); return (

{configured ? "Type, talk, or send a photo — I can update lists, calendar, notes, and more." : "Mock provider active — set LLM_BASE_URL for your homelab model."}

{messages.length > 0 ? ( ) : null}
{showEmptyState ? (

Try "add milk to the shopping list", tap the mic, or attach a photo of an appointment card.

) : (
{messages.map((message, index) => (
{message.role === "user" ? "You" : assistantName}
{message.imageUrl ? ( // User-uploaded assistant attachment preview ) : null} {message.content}
))} {isPending ? (
{assistantName}
{activityLabel ?? "Working…"}
) : null}
)}
{pendingImage ? (
Photo attached
) : null} {error ?

{error}

: null}
{ event.preventDefault(); void sendMessage(); }} > void handleImageSelect(event)} /> setInput(event.target.value)} placeholder={ voiceState === "recording" ? "Listening… tap mic to stop" : voiceState === "transcribing" ? "Transcribing…" : `Ask ${assistantName}…` } disabled={inputDisabled} aria-label={`Message for ${assistantName}`} className="h-9 min-w-0 flex-1" />
); }