mirror of
https://github.com/temetro/temetro.git
synced 2026-09-04 14:55:28 +00:00
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:
@@ -55,7 +55,7 @@ export function ChatInput({
|
|||||||
const canSend =
|
const canSend =
|
||||||
(value.trim().length > 0 || files.length > 0) && !isGenerating;
|
(value.trim().length > 0 || files.length > 0) && !isGenerating;
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(async () => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if ((!trimmed && files.length === 0) || isGenerating) {
|
if ((!trimmed && files.length === 0) || isGenerating) {
|
||||||
return;
|
return;
|
||||||
@@ -64,8 +64,24 @@ export function ChatInput({
|
|||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
parts.push(trimmed);
|
parts.push(trimmed);
|
||||||
}
|
}
|
||||||
if (files.length > 0) {
|
// Include the text of attached files so the agent can parse them (e.g. a
|
||||||
parts.push(`[Attached: ${files.map((file) => file.name).join(", ")}]`);
|
// 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"));
|
onSubmit(parts.join("\n\n"));
|
||||||
setValue("");
|
setValue("");
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useChat } from "@ai-sdk/react";
|
||||||
|
import { DefaultChatTransport } from "ai";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import type { ChatStatus } from "ai";
|
|
||||||
import { useSearchParams } from "next/navigation";
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,110 +18,124 @@ import {
|
|||||||
MessageResponse,
|
MessageResponse,
|
||||||
} from "@/components/ai-elements/message";
|
} from "@/components/ai-elements/message";
|
||||||
import { ChatInput } from "@/components/chat/chat-input";
|
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 { PatientResult } from "@/components/chat/patient-cards";
|
||||||
import { DEFAULT_EFFORT, DEFAULT_MODEL_ID, type Effort } from "@/lib/ai-models";
|
import { Button } from "@/components/ui/button";
|
||||||
import { getPatient, type Patient } from "@/lib/patients";
|
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 =
|
// Trigger: `/patient 10293` or just `/10293` — a client-side fast-path that
|
||||||
| { id: string; role: "user"; text: string }
|
// pulls records instantly without the LLM (also works offline).
|
||||||
| { 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.
|
|
||||||
const PATIENT_COMMAND = /^\/(?:patient\s+)?(\d+)$/i;
|
const PATIENT_COMMAND = /^\/(?:patient\s+)?(\d+)$/i;
|
||||||
|
|
||||||
export function ChatPanel() {
|
export function ChatPanel() {
|
||||||
const { t } = useTranslation();
|
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 [model, setModel] = useState<string>(DEFAULT_MODEL_ID);
|
||||||
const [effort, setEffort] = useState<Effort>(DEFAULT_EFFORT);
|
const [effort, setEffort] = useState<Effort>(DEFAULT_EFFORT);
|
||||||
|
|
||||||
const send = useCallback(async (text: string) => {
|
// Veil consent: cloud models de-identify + send data externally. We ask once
|
||||||
const trimmed = text.trim();
|
// per session before the first such send.
|
||||||
if (!trimmed) {
|
const [consented, setConsented] = useState(false);
|
||||||
return;
|
const [consentOpen, setConsentOpen] = useState(false);
|
||||||
}
|
const pendingSend = useRef<string | null>(null);
|
||||||
|
|
||||||
setMessages((prev) => [
|
const transport = useMemo(
|
||||||
...prev,
|
() =>
|
||||||
{ id: nanoid(), role: "user", text: trimmed },
|
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
|
const isCloudModel = (getModel(model)?.provider ?? "ollama") !== "ollama";
|
||||||
// (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",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
let patient: Patient | null = null;
|
// Run the LLM agent for a message (after any consent gate).
|
||||||
try {
|
const runAgent = useCallback(
|
||||||
patient = await getPatient(fileNumber);
|
(text: string) => {
|
||||||
} catch {
|
sendMessage({ text }, { body: { model, effort } });
|
||||||
// Network / auth errors fall through as "not found"; a 401 will have
|
},
|
||||||
// already redirected to /login via the API client.
|
[sendMessage, model, effort],
|
||||||
patient = null;
|
);
|
||||||
|
|
||||||
|
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.
|
// Cloud model → ask for Veil consent once before sending externally.
|
||||||
setStatus("submitted");
|
if (isCloudModel && !consented) {
|
||||||
window.setTimeout(() => {
|
pendingSend.current = trimmed;
|
||||||
setStatus("streaming");
|
setConsentOpen(true);
|
||||||
setMessages((prev) => [
|
return;
|
||||||
...prev,
|
}
|
||||||
{
|
runAgent(trimmed);
|
||||||
id: nanoid(),
|
},
|
||||||
role: "assistant",
|
[consented, isCloudModel, runAgent, setMessages, t],
|
||||||
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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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#>`;
|
// Opening a patient from the Patients page lands here as `/?patient=<file#>`.
|
||||||
// run the lookup once on arrival.
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const requestedPatient = searchParams.get("patient");
|
const requestedPatient = searchParams.get("patient");
|
||||||
const handledPatientRef = useRef<string | null>(null);
|
const handledPatientRef = useRef<string | null>(null);
|
||||||
@@ -137,12 +152,38 @@ export function ChatPanel() {
|
|||||||
model={model}
|
model={model}
|
||||||
onEffortChange={setEffort}
|
onEffortChange={setEffort}
|
||||||
onModelChange={setModel}
|
onModelChange={setModel}
|
||||||
onStop={handleStop}
|
onStop={stop}
|
||||||
onSubmit={send}
|
onSubmit={send}
|
||||||
status={status}
|
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) {
|
if (messages.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center px-4">
|
<div className="flex flex-1 flex-col items-center justify-center px-4">
|
||||||
@@ -152,6 +193,7 @@ export function ChatPanel() {
|
|||||||
</h1>
|
</h1>
|
||||||
{promptInput}
|
{promptInput}
|
||||||
</div>
|
</div>
|
||||||
|
{consentDialog}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -160,48 +202,46 @@ export function ChatPanel() {
|
|||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<Conversation>
|
<Conversation>
|
||||||
<ConversationContent className="mx-auto w-full max-w-3xl">
|
<ConversationContent className="mx-auto w-full max-w-3xl">
|
||||||
{messages.map((message) => {
|
{messages.map((message) => (
|
||||||
if (message.role === "assistant" && message.kind === "patient") {
|
<Message from={message.role} key={message.id}>
|
||||||
return (
|
<MessageContent className="w-full">
|
||||||
<Message from="assistant" key={message.id}>
|
{message.parts.map((part, i) => {
|
||||||
<MessageContent className="w-full">
|
const key = `${message.id}-${i}`;
|
||||||
<PatientResult
|
if (part.type === "text") {
|
||||||
fileNumber={message.fileNumber}
|
return message.role === "user" ? (
|
||||||
onPatientUpdated={(updated) =>
|
<span className="whitespace-pre-wrap" key={key}>
|
||||||
setMessages((prev) =>
|
{part.text}
|
||||||
prev.map((m) =>
|
</span>
|
||||||
m.id === message.id &&
|
) : (
|
||||||
m.role === "assistant" &&
|
<MessageResponse key={key}>{part.text}</MessageResponse>
|
||||||
m.kind === "patient"
|
);
|
||||||
? { ...m, patient: updated, status: "ready" }
|
}
|
||||||
: m
|
if (part.type === "data-patientCard") {
|
||||||
)
|
return (
|
||||||
)
|
<PatientResult
|
||||||
}
|
fileNumber={part.data.fileNumber}
|
||||||
patient={message.patient}
|
key={key}
|
||||||
status={message.status}
|
patient={part.data}
|
||||||
/>
|
status="ready"
|
||||||
</MessageContent>
|
/>
|
||||||
</Message>
|
);
|
||||||
);
|
}
|
||||||
}
|
if (part.type === "data-labCard") {
|
||||||
|
return <LabChartCard data={part.data} key={key} />;
|
||||||
return (
|
}
|
||||||
<Message from={message.role} key={message.id}>
|
if (part.type === "data-importPreview") {
|
||||||
<MessageContent>
|
return <ImportPreviewCard data={part.data} key={key} />;
|
||||||
{message.role === "user" ? (
|
}
|
||||||
<span className="whitespace-pre-wrap">{message.text}</span>
|
return null;
|
||||||
) : (
|
})}
|
||||||
<MessageResponse>{message.text}</MessageResponse>
|
</MessageContent>
|
||||||
)}
|
</Message>
|
||||||
</MessageContent>
|
))}
|
||||||
</Message>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ConversationContent>
|
</ConversationContent>
|
||||||
<ConversationScrollButton />
|
<ConversationScrollButton />
|
||||||
</Conversation>
|
</Conversation>
|
||||||
<div className="mx-auto w-full max-w-3xl px-4 pb-4">{promptInput}</div>
|
<div className="mx-auto w-full max-w-3xl px-4 pb-4">{promptInput}</div>
|
||||||
|
{consentDialog}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { UIMessage } from "ai";
|
||||||
|
|
||||||
|
import type { Lab, Patient, Trend } from "@/lib/patients";
|
||||||
|
|
||||||
|
// Custom data parts the backend agent streams alongside its text. They carry
|
||||||
|
// REAL (un-redacted) record data straight to the clinician's screen for rich
|
||||||
|
// rendering — the model itself only ever sees Veil-redacted tool results.
|
||||||
|
|
||||||
|
export type LabCardData = {
|
||||||
|
fileNumber: string;
|
||||||
|
name: string;
|
||||||
|
labs: Lab[];
|
||||||
|
labTrend: Trend;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportPreviewData = {
|
||||||
|
// Validated, ready-to-commit records (server re-validates on commit).
|
||||||
|
valid: unknown[];
|
||||||
|
invalid: { index: number; errors: string[] }[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VeilNoticeData = {
|
||||||
|
provider: string;
|
||||||
|
level: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maps each data part name → its payload. Part `type` strings are the key
|
||||||
|
// prefixed with `data-` (e.g. `data-patientCard`), per the AI SDK convention.
|
||||||
|
export type TemetroDataParts = {
|
||||||
|
patientCard: Patient;
|
||||||
|
labCard: LabCardData;
|
||||||
|
importPreview: ImportPreviewData;
|
||||||
|
veilNotice: VeilNoticeData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemetroUIMessage = UIMessage<never, TemetroDataParts>;
|
||||||
@@ -50,3 +50,14 @@ export async function testAiConnection(input: {
|
|||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Commit records the clinician approved in a chat import preview. The backend
|
||||||
|
// re-validates and writes via the audited patient service.
|
||||||
|
export async function commitImport(
|
||||||
|
records: unknown[],
|
||||||
|
): Promise<{ created: string[]; failed: { fileNumber?: string; error: string }[] }> {
|
||||||
|
return apiFetch("/api/ai/import", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ records }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -640,6 +640,39 @@
|
|||||||
"gemini15Pro": "Google long-context model",
|
"gemini15Pro": "Google long-context model",
|
||||||
"ollama": "Runs locally on your infrastructure"
|
"ollama": "Runs locally on your infrastructure"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"patientNotFound": "I couldn't find a patient with file number {{fileNumber}}.",
|
||||||
|
"consent": {
|
||||||
|
"title": "Send to external AI provider?",
|
||||||
|
"body": "This message will be processed by {{provider}}, an external provider.",
|
||||||
|
"veilNote": "Veil de-identifies patient information (names, MRNs, providers) before it leaves your infrastructure, and restores it in the answer. Local Ollama models avoid this entirely.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "De-identify & send"
|
||||||
|
},
|
||||||
|
"labCard": {
|
||||||
|
"flags": {
|
||||||
|
"low": "Low",
|
||||||
|
"high": "High",
|
||||||
|
"critical": "Critical",
|
||||||
|
"normal": "Normal"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"importCard": {
|
||||||
|
"title": "Import patient records",
|
||||||
|
"ready": "Ready to import",
|
||||||
|
"skipped": "Skipped",
|
||||||
|
"total": "Parsed",
|
||||||
|
"row": "Row {{index}}",
|
||||||
|
"approve": "Import {{count}} record(s)",
|
||||||
|
"approve_one": "Import 1 record",
|
||||||
|
"reject": "Discard",
|
||||||
|
"importing": "Importing…",
|
||||||
|
"rejectedNote": "Import discarded. Nothing was written.",
|
||||||
|
"importedTitle": "Records imported",
|
||||||
|
"importedBody": "Imported {{count}} patient record(s).",
|
||||||
|
"failedCount": "{{count}} failed",
|
||||||
|
"failedTitle": "Import failed",
|
||||||
|
"failedBody": "The records could not be imported. Please try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"patientCard": {
|
"patientCard": {
|
||||||
|
|||||||
Generated
+56
-12
@@ -8,6 +8,7 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/react": "^3.0.206",
|
||||||
"@base-ui/react": "^1.5.0",
|
"@base-ui/react": "^1.5.0",
|
||||||
"@number-flow/react": "^0.6.0",
|
"@number-flow/react": "^0.6.0",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
@@ -73,13 +74,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ai-sdk/gateway": {
|
"node_modules/@ai-sdk/gateway": {
|
||||||
"version": "3.0.121",
|
"version": "3.0.130",
|
||||||
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.121.tgz",
|
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.130.tgz",
|
||||||
"integrity": "sha512-uY248djJRxa5W68MHiyqO8WLdOeKQoRClGg7PVX/VPhVW8SJNM7/l5DcrA5WAM3YfQrLyNkgZa2VOu8T0t8LUw==",
|
"integrity": "sha512-qenRdpoYM+2y8Ibj3Y7XngvfcG4NIpejaM+YqAKWXi3/N1qZYeIelrm19jxhIwQW0W/g7WUz0L2Agl7+FnwrQA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.10",
|
"@ai-sdk/provider": "3.0.10",
|
||||||
"@ai-sdk/provider-utils": "4.0.27",
|
"@ai-sdk/provider-utils": "4.0.29",
|
||||||
"@vercel/oidc": "3.2.0"
|
"@vercel/oidc": "3.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -102,9 +103,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ai-sdk/provider-utils": {
|
"node_modules/@ai-sdk/provider-utils": {
|
||||||
"version": "4.0.27",
|
"version": "4.0.29",
|
||||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.29.tgz",
|
||||||
"integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==",
|
"integrity": "sha512-uhukHaCBvqkwBHkT8C2PrnqKTCoLn3pdHXqtcR9I8ErH+flbzgW4o7VHSNIup9LRu+WBvZIZDQLsx6rwl2tiOA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.10",
|
"@ai-sdk/provider": "3.0.10",
|
||||||
@@ -118,6 +119,24 @@
|
|||||||
"zod": "^3.25.76 || ^4.1.8"
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ai-sdk/react": {
|
||||||
|
"version": "3.0.206",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.206.tgz",
|
||||||
|
"integrity": "sha512-26gbPT876BZVXJlSNjqkCDOFiqfxAAhYn+a/ryV6ce5TWo6EfWQZeEiLrZOS6bxaOi7xxkB5uPZaZKFaIJndjQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider-utils": "4.0.29",
|
||||||
|
"ai": "6.0.204",
|
||||||
|
"swr": "^2.2.5",
|
||||||
|
"throttleit": "2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
@@ -5222,14 +5241,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ai": {
|
"node_modules/ai": {
|
||||||
"version": "6.0.193",
|
"version": "6.0.204",
|
||||||
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.193.tgz",
|
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.204.tgz",
|
||||||
"integrity": "sha512-VQOTOse8+X8kMtg61DNSXlYJzwOW4NjMLDJNk/qxClWsFe4oiyFJDHGGG1oezfGcFzuYuQe/8Z7r4kwiZWh2YQ==",
|
"integrity": "sha512-SudB8rUwaVhpWF8+qTJcxUptXPIdN9rWMknzTT3WbKa2QwiGRshyepFKNkDILWm882LgqlEyRZgKhNT14j0jpQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/gateway": "3.0.121",
|
"@ai-sdk/gateway": "3.0.130",
|
||||||
"@ai-sdk/provider": "3.0.10",
|
"@ai-sdk/provider": "3.0.10",
|
||||||
"@ai-sdk/provider-utils": "4.0.27",
|
"@ai-sdk/provider-utils": "4.0.29",
|
||||||
"@opentelemetry/api": "^1.9.0"
|
"@opentelemetry/api": "^1.9.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -14605,6 +14624,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swr": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dequal": "^2.0.3",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tagged-tag": {
|
"node_modules/tagged-tag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||||
@@ -14648,6 +14680,18 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/throttleit": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/react": "^3.0.206",
|
||||||
"@base-ui/react": "^1.5.0",
|
"@base-ui/react": "^1.5.0",
|
||||||
"@number-flow/react": "^0.6.0",
|
"@number-flow/react": "^0.6.0",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user