Files
temetro/frontend/components/chat/chat-panel.tsx
T
Khalid Abdi 31d86bb5dd frontend: wire chat to the real agent + lab charts + import approval
Replace the mock chat with the live backend agent:
- chat-panel uses @ai-sdk/react useChat against /api/chat (credentials included),
  passing the selected model + effort per send. The `/patient <file#>` fast-path
  is kept as an instant client-side shortcut.
- Renders the agent's streamed data parts: patientCard → record cards
  (PatientResult), labCard → new LabChartCard (visx area chart with a high/low
  flag badge in the top-right corner + recent values), importPreview →
  ImportPreviewCard (the human approval gate: review counts/issues, then commit
  via POST /api/ai/import — nothing is written until approved).
- Veil consent: a one-time dialog before the first send to a cloud model,
  explaining that identifiers are de-identified before leaving the clinic.
- Attached text files (csv/json/txt…) now include their content in the message
  so the agent can parse an export for import.

Shared chat message/data-part types in lib/ai-chat.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:44:20 +03:00

248 lines
7.7 KiB
TypeScript

"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<string>(DEFAULT_MODEL_ID);
const [effort, setEffort] = useState<Effort>(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<string | null>(null);
const transport = useMemo(
() =>
new DefaultChatTransport<TemetroUIMessage>({
api: `${API_BASE_URL}/api/chat`,
credentials: "include",
}),
[],
);
const { messages, setMessages, sendMessage, status, stop } =
useChat<TemetroUIMessage>({ 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 <file#>` 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=<file#>`.
const searchParams = useSearchParams();
const requestedPatient = searchParams.get("patient");
const handledPatientRef = useRef<string | null>(null);
useEffect(() => {
if (requestedPatient && handledPatientRef.current !== requestedPatient) {
handledPatientRef.current = requestedPatient;
send(`/patient ${requestedPatient}`);
}
}, [requestedPatient, send]);
const promptInput = (
<ChatInput
effort={effort}
model={model}
onEffortChange={setEffort}
onModelChange={setModel}
onStop={stop}
onSubmit={send}
status={status}
/>
);
const consentDialog = (
<Dialog onOpenChange={setConsentOpen} open={consentOpen}>
<DialogPopup>
<DialogHeader>
<DialogTitle>{t("chat.consent.title")}</DialogTitle>
<DialogDescription>
{t("chat.consent.body", {
provider: getModel(model)?.label ?? model,
})}
</DialogDescription>
</DialogHeader>
<DialogPanel>
<p className="text-sm text-muted-foreground">
{t("chat.consent.veilNote")}
</p>
</DialogPanel>
<DialogFooter>
<Button onClick={() => setConsentOpen(false)} variant="outline">
{t("chat.consent.cancel")}
</Button>
<Button onClick={confirmConsent}>{t("chat.consent.confirm")}</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
);
if (messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-4">
<div className="flex w-full max-w-3xl flex-col items-center gap-10">
<h1 className="text-center text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
{t("chat.heading")}
</h1>
{promptInput}
</div>
{consentDialog}
</div>
);
}
return (
<div className="flex flex-1 flex-col overflow-hidden">
<Conversation>
<ConversationContent className="mx-auto w-full max-w-3xl">
{messages.map((message) => (
<Message from={message.role} key={message.id}>
<MessageContent className="w-full">
{message.parts.map((part, i) => {
const key = `${message.id}-${i}`;
if (part.type === "text") {
return message.role === "user" ? (
<span className="whitespace-pre-wrap" key={key}>
{part.text}
</span>
) : (
<MessageResponse key={key}>{part.text}</MessageResponse>
);
}
if (part.type === "data-patientCard") {
return (
<PatientResult
fileNumber={part.data.fileNumber}
key={key}
patient={part.data}
status="ready"
/>
);
}
if (part.type === "data-labCard") {
return <LabChartCard data={part.data} key={key} />;
}
if (part.type === "data-importPreview") {
return <ImportPreviewCard data={part.data} key={key} />;
}
return null;
})}
</MessageContent>
</Message>
))}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="mx-auto w-full max-w-3xl px-4 pb-4">{promptInput}</div>
{consentDialog}
</div>
);
}