"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { AlertTriangle } from "lucide-react"; import { nanoid } from "nanoid"; import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Conversation, ConversationContent, ConversationScrollButton, } from "@/components/ai-elements/conversation"; import { Message, MessageContent, MessageResponse, } from "@/components/ai-elements/message"; import { ChatInput } from "@/components/chat/chat-input"; import { ImportPreviewCard } from "@/components/chat/import-preview-card"; import { LabChartCard } from "@/components/chat/lab-chart-card"; import { PatientResult } from "@/components/chat/patient-cards"; import { Button } from "@/components/ui/button"; import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "@/components/ui/dialog"; import { DEFAULT_EFFORT, DEFAULT_MODEL_ID, type Effort, getModel, } from "@/lib/ai-models"; import type { TemetroUIMessage } from "@/lib/ai-chat"; import { getAiConfig } from "@/lib/ai-settings"; import { API_BASE_URL } from "@/lib/api-client"; import { getPatient } from "@/lib/patients"; import { notify } from "@/lib/toast"; // Trigger: `/patient 10293` or just `/10293` — a client-side fast-path that // pulls records instantly without the LLM (also works offline). const PATIENT_COMMAND = /^\/(?:patient\s+)?(\d+)$/i; export function ChatPanel() { const { t } = useTranslation(); const [model, setModel] = useState(DEFAULT_MODEL_ID); const [effort, setEffort] = useState(DEFAULT_EFFORT); // Veil consent: cloud models de-identify + send data externally. We ask once // per session before the first such send. const [consented, setConsented] = useState(false); const [consentOpen, setConsentOpen] = useState(false); const pendingSend = useRef(null); const transport = useMemo( () => new DefaultChatTransport({ api: `${API_BASE_URL}/api/chat`, credentials: "include", }), [], ); const { messages, setMessages, sendMessage, status, stop, error } = useChat({ transport }); // Seed the model + effort from the user's saved AI config so the chat uses the // provider they actually configured (e.g. their Gemini default), not a stale // hardcoded default. useEffect(() => { let cancelled = false; getAiConfig() .then((cfg) => { if (cancelled) return; setModel(cfg.mode === "local" ? "ollama" : cfg.defaultModel); setEffort(cfg.defaultEffort); }) .catch(() => { // Keep defaults; the chat still works and the backend falls back to any // configured provider. }); return () => { cancelled = true; }; }, []); // Pop a toast whenever a request errors, so failures are never silent. useEffect(() => { if (error) { notify.error(t("chat.error.title"), error.message || t("chat.error.body")); } }, [error, t]); const isCloudModel = (getModel(model)?.provider ?? "ollama") !== "ollama"; // Run the LLM agent for a message (after any consent gate). const runAgent = useCallback( (text: string) => { sendMessage({ text }, { body: { model, effort } }); }, [sendMessage, model, effort], ); const send = useCallback( async (text: string) => { const trimmed = text.trim(); if (!trimmed) return; // Fast-path: `/patient ` renders cards directly, no LLM. const match = trimmed.match(PATIENT_COMMAND); if (match) { const fileNumber = match[1]; const userId = nanoid(); setMessages((prev) => [ ...prev, { id: userId, role: "user", parts: [{ type: "text", text: trimmed }] }, ]); let patient = null; try { patient = await getPatient(fileNumber); } catch { patient = null; } setMessages((prev) => [ ...prev, { id: nanoid(), role: "assistant", parts: patient ? [{ type: "data-patientCard", data: patient }] : [ { type: "text", text: t("chat.patientNotFound", { fileNumber }), }, ], }, ]); return; } // Cloud model → ask for Veil consent once before sending externally. if (isCloudModel && !consented) { pendingSend.current = trimmed; setConsentOpen(true); return; } runAgent(trimmed); }, [consented, isCloudModel, runAgent, setMessages, t], ); const confirmConsent = useCallback(() => { setConsented(true); setConsentOpen(false); const text = pendingSend.current; pendingSend.current = null; if (text) runAgent(text); }, [runAgent]); // Opening a patient from the Patients page lands here as `/?patient=`. const searchParams = useSearchParams(); const requestedPatient = searchParams.get("patient"); const handledPatientRef = useRef(null); useEffect(() => { if (requestedPatient && handledPatientRef.current !== requestedPatient) { handledPatientRef.current = requestedPatient; send(`/patient ${requestedPatient}`); } }, [requestedPatient, send]); const promptInput = ( ); const errorAlert = error ? (

{t("chat.error.title")}

{error.message || t("chat.error.body")}

) : null; const consentDialog = ( {t("chat.consent.title")} {t("chat.consent.body", { provider: getModel(model)?.label ?? model, })}

{t("chat.consent.veilNote")}

); if (messages.length === 0) { return (

{t("chat.heading")}

{errorAlert} {promptInput}
{consentDialog}
); } return (
{messages.map((message) => ( {message.parts.map((part, i) => { const key = `${message.id}-${i}`; if (part.type === "text") { return message.role === "user" ? ( {part.text} ) : ( {part.text} ); } if (part.type === "data-patientCard") { return ( ); } if (part.type === "data-labCard") { return ; } if (part.type === "data-importPreview") { return ; } return null; })} ))}
{errorAlert} {promptInput}
{consentDialog}
); }