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>
This commit is contained in:
Khalid Abdi
2026-06-13 18:44:20 +03:00
parent ff37b555b0
commit 31d86bb5dd
9 changed files with 564 additions and 143 deletions
+19 -3
View File
@@ -55,7 +55,7 @@ export function ChatInput({
const canSend =
(value.trim().length > 0 || files.length > 0) && !isGenerating;
const submit = useCallback(() => {
const submit = useCallback(async () => {
const trimmed = value.trim();
if ((!trimmed && files.length === 0) || isGenerating) {
return;
@@ -64,8 +64,24 @@ export function ChatInput({
if (trimmed) {
parts.push(trimmed);
}
if (files.length > 0) {
parts.push(`[Attached: ${files.map((file) => file.name).join(", ")}]`);
// Include the text of attached files so the agent can parse them (e.g. a
// database export to import). Read text-like files; cap each so a huge file
// can't blow the context. Binary files are referenced by name only.
for (const file of files) {
const textLike =
/\.(csv|tsv|json|txt|md|xml|ndjson|tab)$/i.test(file.name) ||
file.type.startsWith("text/") ||
file.type === "application/json";
if (textLike) {
try {
const content = (await file.text()).slice(0, 200_000);
parts.push(`--- File: ${file.name} ---\n${content}`);
} catch {
parts.push(`[Attached: ${file.name}]`);
}
} else {
parts.push(`[Attached: ${file.name}]`);
}
}
onSubmit(parts.join("\n\n"));
setValue("");
+168 -128
View File
@@ -1,9 +1,10 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { nanoid } from "nanoid";
import type { ChatStatus } from "ai";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -17,110 +18,124 @@ import {
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 { DEFAULT_EFFORT, DEFAULT_MODEL_ID, type Effort } from "@/lib/ai-models";
import { getPatient, type Patient } from "@/lib/patients";
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";
type ChatMessage =
| { id: string; role: "user"; text: string }
| { id: string; role: "assistant"; kind: "text"; text: string }
| {
id: string;
role: "assistant";
kind: "patient";
fileNumber: string;
status: "loading" | "ready" | "not-found";
patient?: Patient;
};
// Trigger: `/patient 10293` or just `/10293` pulls up records.
// 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 [messages, setMessages] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<ChatStatus>("ready");
// Model + effort selected in the input; travels with each send (wired to the
// backend agent in a later phase). Defaults come from the shared catalog.
const [model, setModel] = useState<string>(DEFAULT_MODEL_ID);
const [effort, setEffort] = useState<Effort>(DEFAULT_EFFORT);
const send = useCallback(async (text: string) => {
const trimmed = text.trim();
if (!trimmed) {
return;
}
// 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);
setMessages((prev) => [
...prev,
{ id: nanoid(), role: "user", text: trimmed },
]);
const transport = useMemo(
() =>
new DefaultChatTransport<TemetroUIMessage>({
api: `${API_BASE_URL}/api/chat`,
credentials: "include",
}),
[],
);
const match = trimmed.match(PATIENT_COMMAND);
const { messages, setMessages, sendMessage, status, stop } =
useChat<TemetroUIMessage>({ transport });
// Patient lookup: append a loading card set, then fill it in once the
// (mock) record comes back.
if (match) {
const fileNumber = match[1];
const resultId = nanoid();
setStatus("submitted");
setMessages((prev) => [
...prev,
{
id: resultId,
role: "assistant",
kind: "patient",
fileNumber,
status: "loading",
},
]);
const isCloudModel = (getModel(model)?.provider ?? "ollama") !== "ollama";
let patient: Patient | null = null;
try {
patient = await getPatient(fileNumber);
} catch {
// Network / auth errors fall through as "not found"; a 401 will have
// already redirected to /login via the API client.
patient = null;
// 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;
}
setMessages((prev) =>
prev.map((message) =>
message.id === resultId &&
message.role === "assistant" &&
message.kind === "patient"
? {
...message,
status: patient ? "ready" : "not-found",
patient: patient ?? undefined,
}
: message
)
);
setStatus("ready");
return;
}
// UI-only: mock assistant reply for anything that isn't a command.
setStatus("submitted");
window.setTimeout(() => {
setStatus("streaming");
setMessages((prev) => [
...prev,
{
id: nanoid(),
role: "assistant",
kind: "text",
text: `This is a **UI-only preview** of temetro. Try \`/patient 10293\` to pull up a patient's records.\n\nYou asked:\n\n> ${trimmed}`,
},
]);
window.setTimeout(() => setStatus("ready"), 250);
}, 500);
}, []);
// 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 handleStop = useCallback(() => setStatus("ready"), []);
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#>`;
// run the lookup once on arrival.
// 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);
@@ -137,12 +152,38 @@ export function ChatPanel() {
model={model}
onEffortChange={setEffort}
onModelChange={setModel}
onStop={handleStop}
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">
@@ -152,6 +193,7 @@ export function ChatPanel() {
</h1>
{promptInput}
</div>
{consentDialog}
</div>
);
}
@@ -160,48 +202,46 @@ export function ChatPanel() {
<div className="flex flex-1 flex-col overflow-hidden">
<Conversation>
<ConversationContent className="mx-auto w-full max-w-3xl">
{messages.map((message) => {
if (message.role === "assistant" && message.kind === "patient") {
return (
<Message from="assistant" key={message.id}>
<MessageContent className="w-full">
<PatientResult
fileNumber={message.fileNumber}
onPatientUpdated={(updated) =>
setMessages((prev) =>
prev.map((m) =>
m.id === message.id &&
m.role === "assistant" &&
m.kind === "patient"
? { ...m, patient: updated, status: "ready" }
: m
)
)
}
patient={message.patient}
status={message.status}
/>
</MessageContent>
</Message>
);
}
return (
<Message from={message.role} key={message.id}>
<MessageContent>
{message.role === "user" ? (
<span className="whitespace-pre-wrap">{message.text}</span>
) : (
<MessageResponse>{message.text}</MessageResponse>
)}
</MessageContent>
</Message>
);
})}
{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>
);
}
@@ -0,0 +1,116 @@
"use client";
import { AlertTriangle, Check, Database, X } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import type { ImportPreviewData } from "@/lib/ai-chat";
import { commitImport } from "@/lib/ai-settings";
import { notify } from "@/lib/toast";
type Status = "pending" | "committing" | "done" | "rejected";
// The human approval gate for the migration import. The agent proposes records
// (dry run, nothing written); the clinician reviews counts + issues here and
// must approve before anything is inserted via POST /api/ai/import.
export function ImportPreviewCard({ data }: { data: ImportPreviewData }) {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>("pending");
const [result, setResult] = useState<{ created: number; failed: number } | null>(
null,
);
const approve = async () => {
setStatus("committing");
try {
const res = await commitImport(data.valid);
setResult({ created: res.created.length, failed: res.failed.length });
setStatus("done");
notify.success(
t("chat.importCard.importedTitle"),
t("chat.importCard.importedBody", { count: res.created.length }),
);
} catch {
setStatus("pending");
notify.error(
t("chat.importCard.failedTitle"),
t("chat.importCard.failedBody"),
);
}
};
return (
<Card className="w-full gap-3 p-4">
<div className="flex items-center gap-2">
<Database className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">{t("chat.importCard.title")}</span>
</div>
<div className="flex flex-wrap gap-4 text-sm">
<span>
{t("chat.importCard.ready")}{" "}
<strong className="tabular-nums">{data.valid.length}</strong>
</span>
{data.invalid.length > 0 ? (
<span className="flex items-center gap-1 text-warning-foreground">
<AlertTriangle className="size-3.5" />
{t("chat.importCard.skipped")}{" "}
<strong className="tabular-nums">{data.invalid.length}</strong>
</span>
) : null}
<span className="text-muted-foreground">
{t("chat.importCard.total")}{" "}
<span className="tabular-nums">{data.total}</span>
</span>
</div>
{data.invalid.length > 0 ? (
<ul className="space-y-1 rounded-lg bg-muted/50 p-3 text-xs text-muted-foreground">
{data.invalid.slice(0, 5).map((issue) => (
<li key={issue.index}>
{t("chat.importCard.row", { index: issue.index + 1 })}:{" "}
{issue.errors.join("; ")}
</li>
))}
</ul>
) : null}
{status === "done" && result ? (
<p className="flex items-center gap-1.5 text-sm text-foreground">
<Check className="size-4" />
{t("chat.importCard.importedBody", { count: result.created })}
{result.failed > 0
? ` · ${t("chat.importCard.failedCount", { count: result.failed })}`
: ""}
</p>
) : status === "rejected" ? (
<p className="text-sm text-muted-foreground">
{t("chat.importCard.rejectedNote")}
</p>
) : (
<div className="flex items-center gap-2">
<Button
disabled={status === "committing" || data.valid.length === 0}
onClick={approve}
size="sm"
>
{status === "committing"
? t("chat.importCard.importing")
: t("chat.importCard.approve", { count: data.valid.length })}
</Button>
<Button
disabled={status === "committing"}
onClick={() => setStatus("rejected")}
size="sm"
variant="outline"
>
<X className="size-4" />
{t("chat.importCard.reject")}
</Button>
</div>
)}
</Card>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { ArrowDown, ArrowUp } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Area, AreaChart } from "@/components/charts/area-chart";
import { Grid } from "@/components/charts/grid";
import { ChartTooltip } from "@/components/charts/tooltip";
import { XAxis } from "@/components/charts/x-axis";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import type { LabCardData } from "@/lib/ai-chat";
import type { LabFlag } from "@/lib/patients";
import { cn } from "@/lib/utils";
type BadgeVariant = "default" | "secondary" | "destructive" | "outline";
const labFlagVariant: Record<LabFlag, BadgeVariant> = {
normal: "outline",
low: "secondary",
high: "secondary",
critical: "destructive",
};
// Plot the headline lab series. labTrend.points are most-recent-last numbers; we
// synthesise evenly spaced dates ending today purely so the date x-axis spaces
// the readings — the values are what matter.
function toChartData(points: number[]): { date: Date; value: number }[] {
const today = new Date();
return points.map((value, i) => {
const date = new Date(today);
date.setDate(today.getDate() - (points.length - 1 - i));
return { date, value };
});
}
// The flag of the most recent reading of the headline lab, for the corner badge.
function headlineFlag(data: LabCardData): LabFlag | null {
const matching = data.labs.filter((l) => l.name === data.labTrend.label);
const last = matching[matching.length - 1] ?? data.labs[data.labs.length - 1];
return last?.flag ?? null;
}
export function LabChartCard({ data }: { data: LabCardData }) {
const { t } = useTranslation();
const chartData = useMemo(
() => toChartData(data.labTrend.points),
[data.labTrend.points],
);
const flag = headlineFlag(data);
const latest = data.labTrend.points[data.labTrend.points.length - 1];
return (
<Card className="w-full gap-3 p-4">
<div className="flex items-start justify-between gap-2">
<div className="space-y-0.5">
<span className="text-sm font-medium text-foreground">
{data.name}
</span>
<p className="text-xs text-muted-foreground">
{data.labTrend.label} · {data.labTrend.unit}
</p>
</div>
{/* High/low indicator, top-right corner. */}
{flag && flag !== "normal" ? (
<Badge className="gap-1" variant={labFlagVariant[flag]}>
{flag === "low" ? (
<ArrowDown className="size-3" />
) : (
<ArrowUp className="size-3" />
)}
{t(`chat.labCard.flags.${flag}`)}
</Badge>
) : (
<span className="font-semibold text-foreground tabular-nums">
{latest}
</span>
)}
</div>
<AreaChart aspectRatio="2 / 1" data={chartData}>
<Grid horizontal />
<Area dataKey="value" fill="var(--chart-line-primary)" />
<XAxis tickMode="data" />
<ChartTooltip showDatePill={false} />
</AreaChart>
{data.labs.length > 0 ? (
<ul className="space-y-1.5">
{data.labs.slice(-6).map((lab, i) => (
<li
className="flex items-center justify-between gap-2 text-sm"
key={`${lab.name}-${i}`}
>
<span className="text-muted-foreground">{lab.name}</span>
<span className="flex items-center gap-2">
<span
className={cn(
"tabular-nums",
lab.flag === "critical"
? "text-destructive-foreground"
: "text-foreground",
)}
>
{lab.value}
</span>
{lab.flag !== "normal" ? (
<Badge
className="px-1.5 py-0 text-[11px]"
variant={labFlagVariant[lab.flag]}
>
{t(`chat.labCard.flags.${lab.flag}`)}
</Badge>
) : null}
</span>
</li>
))}
</ul>
) : null}
</Card>
);
}