"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, type FileUIPart, type ToolUIPart, } from "ai"; import { AlertTriangle, Brain, ChevronDown, ShieldCheck, X } 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 { Attachment, AttachmentInfo, AttachmentPreview, Attachments, } from "@/components/ai-elements/attachments"; import { ChainOfThought, ChainOfThoughtContent, ChainOfThoughtHeader, ChainOfThoughtStep, } from "@/components/ai-elements/chain-of-thought"; import { Conversation, ConversationContent, ConversationScrollButton, } from "@/components/ai-elements/conversation"; import { Message, MessageContent } from "@/components/ai-elements/message"; import { CitedResponse, hasCitationMarkers, SourcesFooter, } from "@/components/chat/message-citations"; import { Queue, QueueItem, QueueItemAction, QueueItemActions, QueueItemContent, QueueItemIndicator, QueueList, } from "@/components/ai-elements/queue"; import { Shimmer } from "@/components/ai-elements/shimmer"; import { Suggestion, Suggestions } from "@/components/ai-elements/suggestion"; import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput, } from "@/components/ai-elements/tool"; import { ActionPreviewCard } from "@/components/chat/action-preview-card"; import { AiSetupNotice } from "@/components/chat/ai-setup-notice"; import { AnalyticsCard } from "@/components/chat/analytics-card"; import { BatchActionPreviewCard } from "@/components/chat/batch-action-preview-card"; import { ChatHistoryPanel } from "@/components/chat/chat-history-panel"; import { ChatInput } from "@/components/chat/chat-input"; import { ClinicCard } from "@/components/chat/clinic-card"; import { InventoryListCard } from "@/components/chat/inventory-list-card"; import { ImportPreviewCard } from "@/components/chat/import-preview-card"; import { LabChartCard } from "@/components/chat/lab-chart-card"; import { PatientResult } from "@/components/chat/patient-cards"; import { RecordGraph } from "@/components/graph/record-graph"; import { AppointmentListCard, PrescriptionListCard, TaskListCard, } from "@/components/chat/record-list-card"; import { VeilConfirmation } from "@/components/chat/veil-confirmation"; import { Badge } from "@/components/ui/badge"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { DEFAULT_EFFORT, DEFAULT_MODEL_ID, type Effort, getModel, } from "@/lib/ai-models"; import { type ChatMode, DEFAULT_MODE } from "@/lib/chat-modes"; import type { ActionPreviewData, TemetroUIMessage } from "@/lib/ai-chat"; import { getThread, notifyThreadsChanged, saveThread, } from "@/lib/ai-chat-history"; import { getAiConfig } from "@/lib/ai-settings"; 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; // Read a File into a FileUIPart (data URL). The backend extracts text-like // content for the model; images/PDFs are read directly by vision providers. function fileToPart(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve({ type: "file", mediaType: file.type || "application/octet-stream", filename: file.name, url: reader.result as string, }); reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); }); } export function ChatPanel() { const { t } = useTranslation(); const [model, setModel] = useState(DEFAULT_MODEL_ID); const [effort, setEffort] = useState(DEFAULT_EFFORT); // The clinician-facing "situation" mode (Chat / Analysis / Graph). The model // itself comes from Settings → AI; this shapes what the assistant does. const [mode, setMode] = useState(DEFAULT_MODE); // 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<{ text: string; files: File[]; } | null>(null); // Claude-style message queue: messages submitted while the assistant is busy // (or waiting on the Veil gate) wait here and auto-send when it goes idle. const [queued, setQueued] = useState<{ text: string; files: File[] }[]>([]); // Persisted conversation: a client-owned thread id (a fresh one per new chat), // saved to the server after each exchange so history survives reloads. const [threadId, setThreadId] = useState(() => nanoid()); const threadIdRef = useRef(threadId); threadIdRef.current = threadId; // Skip the auto-save that would otherwise fire right after loading a thread // (which would needlessly bump it to the top of the history). const justLoadedRef = useRef(false); const transport = useMemo( () => new DefaultChatTransport({ api: `${API_BASE_URL}/api/chat`, credentials: "include", }), [], ); const { messages, setMessages, sendMessage, status, stop, error } = useChat({ transport }); // Mark a proposal/import card as committed or discarded by stamping the data // part, so it persists through re-render and conversation reload (and can't be // submitted twice). `partIndex < 0` marks every action-preview part in the // message (used by the batched card). const resolveProposal = useCallback( (messageId: string, partIndex: number, resolution: "added" | "discarded") => { setMessages((prev) => prev.map((m) => { if (m.id !== messageId) return m; const parts = m.parts.map((p, idx) => { const isTarget = partIndex < 0 ? p.type === "data-actionPreview" : idx === partIndex; if (!isTarget) return p; const data = (p as { data?: Record }).data; return data ? { ...p, data: { ...data, resolved: resolution } } : p; }) as typeof m.parts; return { ...m, parts }; }), ); }, [setMessages], ); // 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; }; }, []); // Surface errors inline (and dismissible) instead of as a toast, so a failure // stays visible until acknowledged and isn't duplicated. Reset the dismissed // flag whenever a fresh error arrives. const [errorDismissed, setErrorDismissed] = useState(false); useEffect(() => { if (error) setErrorDismissed(false); }, [error]); 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( async (text: string, modelId: string, files: File[] = []) => { const fileParts = await Promise.all(files.map(fileToPart)); sendMessage( { text, files: fileParts }, { body: { model: modelId, effort, mode, threadId: threadIdRef.current } }, ); }, [sendMessage, effort, mode], ); const send = useCallback( async (text: string, files: File[] = []) => { const trimmed = text.trim(); if (!trimmed && files.length === 0) return; // Busy or awaiting the Veil gate → queue and auto-send when idle. if (status === "submitted" || status === "streaming" || pendingConsent) { setQueued((q) => [...q, { text: trimmed, files }]); 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 ? [ mode === "graph" ? { type: "data-recordGraph", data: 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({ text: trimmed, files }); return; } void runAgentWith(trimmed, model, files); }, [ consented, isCloudModel, mode, model, pendingConsent, runAgentWith, setMessages, status, t, ], ); // Drain the queue one message at a time whenever the chat returns to idle. useEffect(() => { if (status !== "ready" || pendingConsent || queued.length === 0) return; const [next, ...rest] = queued; setQueued(rest); if (next) void send(next.text, next.files); }, [status, pendingConsent, queued, send]); // Veil gate actions. const confirmConsent = useCallback(() => { setConsented(true); const pending = pendingConsent; setPendingConsent(null); if (pending) void runAgentWith(pending.text, model, pending.files); }, [pendingConsent, runAgentWith, model]); const useLocalInstead = useCallback(() => { setModel("ollama"); const pending = pendingConsent; setPendingConsent(null); if (pending) void runAgentWith(pending.text, "ollama", pending.files); }, [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]); // Open a saved thread from `/?thread=` (sidebar history); a bare `/` starts // a fresh chat. Driven by the URL so the sidebar links and "New chat" work. const requestedThread = searchParams.get("thread"); useEffect(() => { if (requestedThread) { if (requestedThread === threadIdRef.current) return; // already open let active = true; getThread(requestedThread) .then((thread) => { if (!active) return; justLoadedRef.current = true; setThreadId(thread.id); setMessages( thread.messages.map( (m) => ({ id: nanoid(), role: m.role, parts: m.parts, }) as TemetroUIMessage, ), ); }) .catch(() => { /* missing/forbidden thread → leave the current chat as-is */ }); return () => { active = false; }; } // No ?thread → fresh chat (e.g. after "New chat"). setThreadId(nanoid()); setMessages([]); }, [requestedThread, setMessages]); // Auto-save the conversation a moment after it settles (covers both LLM and // the `/patient` fast path). Skips the redundant save right after a load. useEffect(() => { if (messages.length === 0) return; if (status === "submitted" || status === "streaming") return; if (justLoadedRef.current) { justLoadedRef.current = false; return; } const id = setTimeout(() => { const firstUser = messages.find((m) => m.role === "user"); const textPart = firstUser?.parts.find((p) => p.type === "text") as | { text?: string } | undefined; const title = (textPart?.text ?? "").trim().slice(0, 60) || t("chat.history.untitled"); saveThread(threadIdRef.current, messages, title) .then(notifyThreadsChanged) .catch(() => { /* a failed save shouldn't disrupt the chat */ }); }, 800); return () => clearTimeout(id); }, [messages, status, t]); const promptInput = ( ); const veilGate = pendingConsent ? ( ) : null; const errorAlert = error && !errorDismissed ? (

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

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

) : null; // Starter prompts shown on the empty state, each tied to an existing tool. const suggestions = [ t("chat.suggestions.schedule"), t("chat.suggestions.tasks"), t("chat.suggestions.prescriptions"), t("chat.suggestions.import"), ]; const queuePanel = queued.length > 0 ? ( {t("chat.queue.label", { count: queued.length })} {queued.map((q, i) => (
{q.text || t("chat.queue.attachmentsOnly", { count: q.files.length })} setQueued((prev) => prev.filter((_, j) => j !== i)) } >
))}
) : null; // Veil runs once per conversation, so the "Veil active" chip should only show // on the first assistant message that carries a veilNotice — not every turn. const firstVeilMessageId = messages.find((m) => m.parts.some((p) => p.type === "data-veilNotice"), )?.id; // 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"; // When the agent proposes many records at once (e.g. an imported file), // collapse them into one batched approval instead of a card per record. const actionPreviews = message.parts.filter( (p) => p.type === "data-actionPreview", ); const firstActionPreviewIdx = message.parts.findIndex( (p) => p.type === "data-actionPreview", ); // Attachments the clinician uploaded — rendered once as a chip group. const fileParts = message.parts.filter((p) => p.type === "file"); const firstFileIdx = message.parts.findIndex((p) => p.type === "file"); // Citable sources the agent retrieved for this message; the model references // them inline via [[src:id]] markers (rendered as chips). When it emits no // markers, a sources footer still attributes the retrieved records. const sources = message.parts .filter((p) => p.type === "data-source") .map((p) => p.data); const hasInlineCitations = message.parts.some( (p) => p.type === "text" && hasCitationMarkers(p.text), ); 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 === "reasoning") { return ( {isWorking && isLast ? ( {t("chat.reasoning")} ) : ( t("chat.reasoning") )} {part.text} ); } if (part.type.startsWith("tool-")) { const tp = part as ToolUIPart; return ( ); } if (part.type === "text") { return message.role === "user" ? ( {part.text} ) : ( ); } if (part.type === "file") { // Render the whole message's files as one chip group, once. if (i !== firstFileIdx) return null; return ( {fileParts.map((fp, fi) => ( ))} ); } if (part.type === "data-patientCard") { return ( ); } if (part.type === "data-recordGraph") { return (
{part.data.name} {t("chat.graphCard.label")}
); } if (part.type === "data-labCard") { return ; } if (part.type === "data-importPreview") { return ( resolveProposal(message.id, i, r)} /> ); } if (part.type === "data-actionPreview") { if (actionPreviews.length >= 2) { // Render the batch once (at the first proposal), skip the rest. if (i !== firstActionPreviewIdx) return null; return ( (p as { data: ActionPreviewData }).data, )} key={key} // -1 marks every action-preview part in this message. onResolved={(r) => resolveProposal(message.id, -1, r)} /> ); } return ( resolveProposal(message.id, i, r)} /> ); } if (part.type === "data-appointmentList") { return ( ); } if (part.type === "data-taskList") { return ; } if (part.type === "data-prescriptionList") { return ( ); } if (part.type === "data-inventoryList") { return ; } if (part.type === "data-clinicCard") { return ; } if (part.type === "data-analyticsCard") { return ; } if (part.type === "data-veilNotice") { // Only the first veilNotice in the whole conversation renders. if (message.id !== firstVeilMessageId) return null; return ( {t("chat.veil.activeChip", { provider: part.data.provider })} ); } return null; })} {/* Provenance footer: shown when the model cited records but placed no inline markers, so retrieved sources are always attributed. */} {sources.length > 0 && !hasInlineCitations && ( )}
); }; // 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} {/* Setup heads-up when no AI provider is configured. */} {promptInput} {suggestions.map((s) => ( ))}
); } return (
{messages.map((message, i) => renderMessage(message, i === messages.length - 1), )} {showThinking ? (
{t("chat.thinking")}
) : null}
{errorAlert} {veilGate} {queuePanel} {/* Also warn mid-conversation when no AI provider is configured, so failing replies have a visible cause and a fix. */} {promptInput}
); }