"use client"; import { useCallback, useEffect, useRef, useState, useTransition } from "react"; import { ChevronDown, ImagePlus, Loader2, Mic, RefreshCw, Send, Square } from "lucide-react"; import { setAssistantModel } from "@/app/settings/assistant-actions"; 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; assistantModel: string | null; }; type PendingImage = { url: string; }; type LlmModelOption = { id: string; label: string; }; type ModelsResponse = { models: LlmModelOption[]; selectedModel: string; fallbackModel: string; degraded: boolean; }; 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, assistantModel }: 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 [modelOptions, setModelOptions] = useState([]); const [selectedModel, setSelectedModel] = useState(assistantModel ?? ""); const [fallbackModel, setFallbackModel] = useState(""); const [modelsDegraded, setModelsDegraded] = useState(false); const [modelsLoading, setModelsLoading] = useState(true); const [savingModel, startTransition] = useTransition(); 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(); }; }, []); const loadModels = useCallback(async (options?: { refresh?: boolean; signal?: AbortSignal }) => { if (options?.signal?.aborted) return; setModelsLoading(true); try { const params = new URLSearchParams(); if (options?.refresh) params.set("refresh", "1"); const query = params.size > 0 ? `?${params.toString()}` : ""; const response = await fetch(`/api/agent/models${query}`, { cache: "no-store", signal: options?.signal, }); if (!response.ok) throw new Error("Model discovery unavailable"); const payload = (await response.json()) as ModelsResponse; if (options?.signal?.aborted) return; setModelOptions(payload.models); setSelectedModel(payload.selectedModel); setFallbackModel(payload.fallbackModel); setModelsDegraded(payload.degraded); setError(null); } catch (err) { if (err instanceof Error && err.name === "AbortError") return; setModelsDegraded(true); setError("Model discovery unavailable"); } finally { if (!options?.signal?.aborted) setModelsLoading(false); } }, []); useEffect(() => { const controller = new AbortController(); queueMicrotask(() => { void loadModels({ signal: controller.signal }); }); return () => { controller.abort(); }; }, [loadModels]); 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); } } function changeModel(nextModel: string | null) { if (!nextModel) return; setSelectedModel(nextModel); setError(null); startTransition(async () => { try { await setAssistantModel(nextModel === fallbackModel ? null : nextModel); } catch (err) { setError(err instanceof Error ? err.message : "Could not save assistant model"); } }); } 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), model: selectedModel || undefined, 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, ...(result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}), }, ]); 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 && !savingModel && 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."}

{modelsDegraded ? (

Model discovery unavailable; using fallback.

) : null}
{modelOptions.length > 0 ? (
) : null} {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} {message.role === "assistant" && message.toolCalls && message.toolCalls.length > 0 ? (

{message.toolCalls.map((call) => `${call.name}→${call.status}`).join(" · ")}

) : null}
))} {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" />
); }