"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; 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 { API_BASE_URL } from "@/lib/api-client"; import { getPatient } from "@/lib/patients"; // 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 } = useChat({ transport }); 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 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")}

{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; })} ))}
{promptInput}
{consentDialog}
); }