"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { AlertTriangle, ShieldCheck } 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 { ChainOfThought, ChainOfThoughtContent, ChainOfThoughtHeader, ChainOfThoughtStep, } from "@/components/ai-elements/chain-of-thought"; import { Conversation, ConversationContent, ConversationScrollButton, } from "@/components/ai-elements/conversation"; import { Message, MessageContent, MessageResponse, } from "@/components/ai-elements/message"; import { Shimmer } from "@/components/ai-elements/shimmer"; import { ActionPreviewCard } from "@/components/chat/action-preview-card"; 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 { AppointmentListCard, PrescriptionListCard, TaskListCard, } from "@/components/chat/record-list-card"; import { VeilConfirmation } from "@/components/chat/veil-confirmation"; import { Badge } from "@/components/ui/badge"; 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 — inline (no modal). `pendingConsent` // holds the message text waiting on that one-time approval. const [consented, setConsented] = useState(false); const [pendingConsent, setPendingConsent] = useState(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 Veil gate) on a given model. const runAgentWith = useCallback( (text: string, modelId: string) => { sendMessage({ text }, { body: { model: modelId, effort } }); }, [sendMessage, 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 → inline Veil consent once before sending externally. if (isCloudModel && !consented) { setPendingConsent(trimmed); return; } runAgentWith(trimmed, model); }, [consented, isCloudModel, model, runAgentWith, setMessages, t], ); // Veil gate actions. const confirmConsent = useCallback(() => { setConsented(true); const text = pendingConsent; setPendingConsent(null); if (text) runAgentWith(text, model); }, [pendingConsent, runAgentWith, model]); const useLocalInstead = useCallback(() => { setModel("ollama"); const text = pendingConsent; setPendingConsent(null); if (text) runAgentWith(text, "ollama"); }, [pendingConsent, runAgentWith]); const cancelConsent = useCallback(() => setPendingConsent(null), []); // 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 veilGate = pendingConsent ? ( ) : null; const errorAlert = error ? (

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

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

) : null; // Render one assistant/user message: a Chain-of-Thought trace built from any // `data-step` parts, then the rest of the parts (text + record cards) in order. const renderMessage = (message: TemetroUIMessage, isLast: boolean) => { const steps = message.parts.filter((p) => p.type === "data-step"); const isWorking = status === "submitted" || status === "streaming"; return ( {steps.length > 0 ? ( {t("chat.steps")} {steps.map((part, i) => ( ))} ) : null} {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 ; } if (part.type === "data-actionPreview") { return ; } if (part.type === "data-appointmentList") { return ( ); } if (part.type === "data-taskList") { return ; } if (part.type === "data-prescriptionList") { return ( ); } if (part.type === "data-veilNotice") { return ( {t("chat.veil.activeChip", { provider: part.data.provider })} ); } return null; })} ); }; // Show a "Thinking…" shimmer while a request is in flight and the assistant // hasn't produced visible prose yet (steps may still be streaming above it). const lastMessage = messages[messages.length - 1]; const lastHasText = lastMessage?.role === "assistant" && lastMessage.parts.some((p) => p.type === "text" && p.text.trim().length > 0); const showThinking = (status === "submitted" || status === "streaming") && !lastHasText; if (messages.length === 0) { return (

{t("chat.heading")}

{errorAlert} {veilGate} {promptInput}
); } return (
{messages.map((message, i) => renderMessage(message, i === messages.length - 1), )} {showThinking ? (
{t("chat.thinking")}
) : null}
{errorAlert} {veilGate} {promptInput}
); }