mirror of
https://github.com/temetro/temetro.git
synced 2026-08-27 10:56:58 +00:00
feat: live AI chat (thinking + inline Veil) and display/add actions
Make the chat feel alive and let the agent act on the clinic — safely. UX (frontend): - Stream `data-step` parts from each tool into an inline Chain-of-Thought trace, plus a "Thinking…" shimmer while a request is in flight (works even on the non-streamed external+Veil path). - Replace the modal Veil consent Dialog with an inline, once-per-session "Veil" confirmation above the input (with a "Use local model" option). - Render new list + action-preview cards; chat-input now uses COSS tokens. Agent (backend): - Add display tools (listAppointments / listTasks / listPrescriptions) and propose tools (proposeAppointment / proposeTask / proposePrescription) that validate as a dry run and stream an approval card — nothing is written until the clinician approves, via the existing RBAC-gated create endpoints. - previewImport now also covers single-patient add + migration. - System prompt: display + add only, never edit/delete or alter the schema; stronger migration guidance. ToolContext carries the viewer for task scoping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, CalendarPlus, Check, ClipboardList, Pill, 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 { ActionPreviewData } from "@/lib/ai-chat";
|
||||
import { type AppointmentInput, createAppointment } from "@/lib/appointments";
|
||||
import { type PrescriptionInput, createPrescription } from "@/lib/prescriptions";
|
||||
import { type TaskInput, createTask } from "@/lib/tasks";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
type Status = "pending" | "committing" | "done" | "rejected";
|
||||
|
||||
const ICONS = {
|
||||
appointment: CalendarPlus,
|
||||
task: ClipboardList,
|
||||
prescription: Pill,
|
||||
} as const;
|
||||
|
||||
// Summarise the proposed record into a couple of readable lines per kind.
|
||||
function summarize(data: ActionPreviewData): string[] {
|
||||
const r = data.record as Record<string, unknown>;
|
||||
if (data.kind === "appointment") {
|
||||
return [
|
||||
String(r.name ?? ""),
|
||||
[r.date, r.time].filter(Boolean).join(" · "),
|
||||
[r.type, r.provider].filter(Boolean).join(" · "),
|
||||
].filter(Boolean);
|
||||
}
|
||||
if (data.kind === "task") {
|
||||
return [
|
||||
String(r.title ?? ""),
|
||||
[r.assignee, r.due, r.priority].filter(Boolean).join(" · "),
|
||||
].filter(Boolean);
|
||||
}
|
||||
// prescription
|
||||
return [
|
||||
[r.medication, r.dose].filter(Boolean).join(" "),
|
||||
[r.frequency, r.duration].filter(Boolean).join(" · "),
|
||||
String(r.name ?? ""),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
async function commit(data: ActionPreviewData): Promise<void> {
|
||||
if (data.kind === "appointment") {
|
||||
await createAppointment(data.record as AppointmentInput);
|
||||
} else if (data.kind === "task") {
|
||||
await createTask(data.record as TaskInput);
|
||||
} else {
|
||||
await createPrescription(data.record as PrescriptionInput);
|
||||
}
|
||||
}
|
||||
|
||||
// The human approval gate for an agent-proposed add. The agent drafts the record
|
||||
// (dry run, nothing written); the clinician reviews it here and must approve
|
||||
// before it is committed via the matching RBAC-gated create endpoint.
|
||||
export function ActionPreviewCard({ data }: { data: ActionPreviewData }) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<Status>("pending");
|
||||
const Icon = ICONS[data.kind];
|
||||
const hasIssues = (data.issues?.length ?? 0) > 0;
|
||||
const lines = summarize(data);
|
||||
|
||||
const approve = async () => {
|
||||
setStatus("committing");
|
||||
try {
|
||||
await commit(data);
|
||||
setStatus("done");
|
||||
notify.success(
|
||||
t("chat.actionCard.addedTitle"),
|
||||
t(`chat.actionCard.kind.${data.kind}`),
|
||||
);
|
||||
} catch {
|
||||
setStatus("pending");
|
||||
notify.error(
|
||||
t("chat.actionCard.failedTitle"),
|
||||
t("chat.actionCard.failedBody"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full gap-3 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">
|
||||
{t(`chat.actionCard.title.${data.kind}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 text-sm">
|
||||
{lines.map((line, i) => (
|
||||
<p
|
||||
className={i === 0 ? "font-medium text-foreground" : "text-muted-foreground"}
|
||||
key={line + i}
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasIssues ? (
|
||||
<ul className="space-y-1 rounded-lg bg-muted/50 p-3 text-muted-foreground text-xs">
|
||||
{data.issues!.slice(0, 5).map((issue) => (
|
||||
<li className="flex items-start gap-1.5" key={issue}>
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-warning-foreground" />
|
||||
{issue}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{status === "done" ? (
|
||||
<p className="flex items-center gap-1.5 text-foreground text-sm">
|
||||
<Check className="size-4" />
|
||||
{t("chat.actionCard.added")}
|
||||
</p>
|
||||
) : status === "rejected" ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("chat.actionCard.discarded")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={status === "committing" || hasIssues}
|
||||
onClick={approve}
|
||||
size="sm"
|
||||
>
|
||||
{status === "committing"
|
||||
? t("chat.actionCard.adding")
|
||||
: t("chat.actionCard.approve")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={status === "committing"}
|
||||
onClick={() => setStatus("rejected")}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<X className="size-4" />
|
||||
{t("chat.actionCard.discard")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ const iconButton =
|
||||
const pillButton =
|
||||
"flex h-8 items-center gap-1.5 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
|
||||
const contextPill =
|
||||
"flex h-7 items-center gap-1.5 rounded-md px-2 text-[13px] text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground";
|
||||
"flex h-7 items-center gap-1.5 rounded-md px-2 text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground";
|
||||
|
||||
export function ChatInput({
|
||||
onSubmit,
|
||||
@@ -125,7 +125,7 @@ export function ChatInput({
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
className="w-full overflow-hidden rounded-[28px] border border-border/60 bg-input shadow-sm"
|
||||
className="w-full overflow-hidden rounded-[28px] border border-border bg-input shadow-sm"
|
||||
>
|
||||
{/* Textarea + toolbar, filling the rounded card. */}
|
||||
<div className="bg-input">
|
||||
@@ -212,8 +212,8 @@ export function ChatInput({
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||
canSend || isGenerating
|
||||
? "bg-foreground text-background hover:bg-foreground/90"
|
||||
: "bg-muted-foreground/30 text-foreground/70"
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
disabled={!(canSend || isGenerating)}
|
||||
onClick={isGenerating && onStop ? onStop : undefined}
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
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,
|
||||
@@ -18,20 +24,19 @@ import {
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
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,
|
||||
@@ -54,10 +59,10 @@ export function ChatPanel() {
|
||||
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.
|
||||
// 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 [consentOpen, setConsentOpen] = useState(false);
|
||||
const pendingSend = useRef<string | null>(null);
|
||||
const [pendingConsent, setPendingConsent] = useState<string | null>(null);
|
||||
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
@@ -100,12 +105,12 @@ export function ChatPanel() {
|
||||
|
||||
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 } });
|
||||
// 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, model, effort],
|
||||
[sendMessage, effort],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
@@ -146,24 +151,32 @@ export function ChatPanel() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cloud model → ask for Veil consent once before sending externally.
|
||||
// Cloud model → inline Veil consent once before sending externally.
|
||||
if (isCloudModel && !consented) {
|
||||
pendingSend.current = trimmed;
|
||||
setConsentOpen(true);
|
||||
setPendingConsent(trimmed);
|
||||
return;
|
||||
}
|
||||
runAgent(trimmed);
|
||||
runAgentWith(trimmed, model);
|
||||
},
|
||||
[consented, isCloudModel, runAgent, setMessages, t],
|
||||
[consented, isCloudModel, model, runAgentWith, setMessages, t],
|
||||
);
|
||||
|
||||
// Veil gate actions.
|
||||
const confirmConsent = useCallback(() => {
|
||||
setConsented(true);
|
||||
setConsentOpen(false);
|
||||
const text = pendingSend.current;
|
||||
pendingSend.current = null;
|
||||
if (text) runAgent(text);
|
||||
}, [runAgent]);
|
||||
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=<file#>`.
|
||||
const searchParams = useSearchParams();
|
||||
@@ -188,9 +201,18 @@ export function ChatPanel() {
|
||||
/>
|
||||
);
|
||||
|
||||
const veilGate = pendingConsent ? (
|
||||
<VeilConfirmation
|
||||
onCancel={cancelConsent}
|
||||
onConfirm={confirmConsent}
|
||||
onUseLocal={useLocalInstead}
|
||||
provider={getModel(model)?.label ?? model}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const errorAlert = error ? (
|
||||
<div
|
||||
className="flex w-full items-start gap-2 rounded-2xl border border-destructive/40 bg-destructive/8 px-4 py-3 text-sm text-destructive-foreground"
|
||||
className="flex w-full items-start gap-2 rounded-2xl border border-destructive/40 bg-destructive/8 px-4 py-3 text-destructive-foreground text-sm"
|
||||
role="alert"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
@@ -203,45 +225,119 @@ export function ChatPanel() {
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
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>
|
||||
);
|
||||
// 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 (
|
||||
<Message from={message.role} key={message.id}>
|
||||
<MessageContent className="w-full">
|
||||
{steps.length > 0 ? (
|
||||
<ChainOfThought
|
||||
className="mb-1"
|
||||
defaultOpen={isLast && isWorking}
|
||||
key={`${message.id}-cot`}
|
||||
>
|
||||
<ChainOfThoughtHeader>{t("chat.steps")}</ChainOfThoughtHeader>
|
||||
<ChainOfThoughtContent>
|
||||
{steps.map((part, i) => (
|
||||
<ChainOfThoughtStep
|
||||
key={`${message.id}-step-${i}`}
|
||||
label={part.data.label}
|
||||
status={part.data.status}
|
||||
/>
|
||||
))}
|
||||
</ChainOfThoughtContent>
|
||||
</ChainOfThought>
|
||||
) : null}
|
||||
|
||||
{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} />;
|
||||
}
|
||||
if (part.type === "data-actionPreview") {
|
||||
return <ActionPreviewCard data={part.data} key={key} />;
|
||||
}
|
||||
if (part.type === "data-appointmentList") {
|
||||
return (
|
||||
<AppointmentListCard
|
||||
appointments={part.data.appointments}
|
||||
key={key}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (part.type === "data-taskList") {
|
||||
return <TaskListCard key={key} tasks={part.data.tasks} />;
|
||||
}
|
||||
if (part.type === "data-prescriptionList") {
|
||||
return (
|
||||
<PrescriptionListCard
|
||||
key={key}
|
||||
prescriptions={part.data.prescriptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (part.type === "data-veilNotice") {
|
||||
return (
|
||||
<Badge className="gap-1 self-start" key={key} variant="secondary">
|
||||
<ShieldCheck className="size-3" />
|
||||
{t("chat.veil.activeChip", { provider: part.data.provider })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<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">
|
||||
<h1 className="text-center font-semibold text-3xl text-balance tracking-tight sm:text-4xl">
|
||||
{t("chat.heading")}
|
||||
</h1>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{errorAlert}
|
||||
{veilGate}
|
||||
{promptInput}
|
||||
</div>
|
||||
</div>
|
||||
{consentDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -250,49 +346,22 @@ 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) => (
|
||||
<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>
|
||||
))}
|
||||
{messages.map((message, i) =>
|
||||
renderMessage(message, i === messages.length - 1),
|
||||
)}
|
||||
{showThinking ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Shimmer duration={1}>{t("chat.thinking")}</Shimmer>
|
||||
</div>
|
||||
) : null}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 pb-4">
|
||||
{errorAlert}
|
||||
{veilGate}
|
||||
{promptInput}
|
||||
</div>
|
||||
{consentDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarClock, ClipboardList, Pill } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { Appointment } from "@/lib/appointments";
|
||||
import { formatPrescribedAt, type Prescription } from "@/lib/prescriptions";
|
||||
import type { Task } from "@/lib/tasks";
|
||||
|
||||
type Row = { primary: string; secondary?: string; badge?: string };
|
||||
|
||||
function Shell({
|
||||
icon: Icon,
|
||||
title,
|
||||
rows,
|
||||
emptyKey,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
rows: Row[];
|
||||
emptyKey: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="w-full gap-0 overflow-hidden p-0">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{title}</span>
|
||||
<Badge className="ml-auto" variant="secondary">
|
||||
{rows.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-muted-foreground text-sm">
|
||||
{t(emptyKey)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{rows.map((row, i) => (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5" key={row.primary + i}>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate font-medium text-foreground text-sm">
|
||||
{row.primary}
|
||||
</span>
|
||||
{row.secondary ? (
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{row.secondary}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{row.badge ? (
|
||||
<Badge className="shrink-0" variant="outline">
|
||||
{row.badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppointmentListCard({ appointments }: { appointments: Appointment[] }) {
|
||||
const { t } = useTranslation();
|
||||
const rows: Row[] = appointments.map((a) => ({
|
||||
primary: a.name,
|
||||
secondary: [a.date, a.time, a.type, a.provider].filter(Boolean).join(" · "),
|
||||
badge: a.status,
|
||||
}));
|
||||
return (
|
||||
<Shell
|
||||
emptyKey="chat.lists.noAppointments"
|
||||
icon={CalendarClock}
|
||||
rows={rows}
|
||||
title={t("chat.lists.appointments")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskListCard({ tasks }: { tasks: Task[] }) {
|
||||
const { t } = useTranslation();
|
||||
const rows: Row[] = tasks.map((tk) => ({
|
||||
primary: tk.title,
|
||||
secondary: [tk.assignee, tk.due].filter(Boolean).join(" · "),
|
||||
badge: tk.done ? t("chat.lists.done") : tk.priority,
|
||||
}));
|
||||
return (
|
||||
<Shell
|
||||
emptyKey="chat.lists.noTasks"
|
||||
icon={ClipboardList}
|
||||
rows={rows}
|
||||
title={t("chat.lists.tasks")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrescriptionListCard({
|
||||
prescriptions,
|
||||
}: {
|
||||
prescriptions: Prescription[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rows: Row[] = prescriptions.map((rx) => ({
|
||||
primary: [rx.medication, rx.dose].filter(Boolean).join(" "),
|
||||
secondary: [rx.name, rx.frequency, formatPrescribedAt(rx.prescribedAt)]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
badge: rx.status,
|
||||
}));
|
||||
return (
|
||||
<Shell
|
||||
emptyKey="chat.lists.noPrescriptions"
|
||||
icon={Pill}
|
||||
rows={rows}
|
||||
title={t("chat.lists.prescriptions")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// Inline "Veil" gate. Shown above the prompt input the first time a message
|
||||
// would leave the clinic for an external provider — replaces the old modal so
|
||||
// the chat flow is never interrupted. Veil de-identifies PHI before the send.
|
||||
export function VeilConfirmation({
|
||||
provider,
|
||||
onConfirm,
|
||||
onUseLocal,
|
||||
onCancel,
|
||||
}: {
|
||||
provider: string;
|
||||
onConfirm: () => void;
|
||||
onUseLocal: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="flex w-full flex-col gap-3 rounded-2xl border border-border bg-card/40 px-4 py-3"
|
||||
role="alertdialog"
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<ShieldCheck className="size-4" />
|
||||
</span>
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-sm">{t("chat.veil.title")}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("chat.veil.body", { provider })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button onClick={onCancel} size="sm" variant="ghost">
|
||||
{t("chat.veil.cancel")}
|
||||
</Button>
|
||||
<Button onClick={onUseLocal} size="sm" variant="outline">
|
||||
{t("chat.veil.useLocal")}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} size="sm">
|
||||
{t("chat.veil.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
import type { Appointment } from "@/lib/appointments";
|
||||
import type { Lab, Patient, Trend } from "@/lib/patients";
|
||||
import type { Prescription } from "@/lib/prescriptions";
|
||||
import type { Task } from "@/lib/tasks";
|
||||
|
||||
// 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
|
||||
@@ -25,6 +28,28 @@ export type VeilNoticeData = {
|
||||
level: string;
|
||||
};
|
||||
|
||||
// A Chain-of-Thought step the agent emits as it works (one per tool action).
|
||||
export type StepData = {
|
||||
id: string;
|
||||
label: string;
|
||||
status: "active" | "complete";
|
||||
};
|
||||
|
||||
// Read-only list cards (display actions).
|
||||
export type AppointmentListData = { appointments: Appointment[] };
|
||||
export type TaskListData = { tasks: Task[] };
|
||||
export type PrescriptionListData = { prescriptions: Prescription[] };
|
||||
|
||||
// An add proposed by the agent, awaiting one-click clinician approval. `record`
|
||||
// is the validated, ready-to-commit input for the matching create endpoint.
|
||||
export type ActionPreviewKind = "appointment" | "task" | "prescription";
|
||||
export type ActionPreviewData = {
|
||||
token: string;
|
||||
kind: ActionPreviewKind;
|
||||
record: unknown;
|
||||
issues?: 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 = {
|
||||
@@ -32,6 +57,11 @@ export type TemetroDataParts = {
|
||||
labCard: LabCardData;
|
||||
importPreview: ImportPreviewData;
|
||||
veilNotice: VeilNoticeData;
|
||||
step: StepData;
|
||||
appointmentList: AppointmentListData;
|
||||
taskList: TaskListData;
|
||||
prescriptionList: PrescriptionListData;
|
||||
actionPreview: ActionPreviewData;
|
||||
};
|
||||
|
||||
export type TemetroUIMessage = UIMessage<never, TemetroDataParts>;
|
||||
|
||||
@@ -361,6 +361,31 @@
|
||||
"in-stock": "In stock",
|
||||
"low": "Low stock",
|
||||
"out": "Out of stock"
|
||||
},
|
||||
"addItem": "Add item",
|
||||
"dialog": {
|
||||
"title": "Add inventory item",
|
||||
"description": "Add a medication to the pharmacy's stock. Only the name is required.",
|
||||
"name": "Medication",
|
||||
"namePlaceholder": "e.g. Amoxicillin",
|
||||
"form": "Form",
|
||||
"formPlaceholder": "e.g. Tablet",
|
||||
"strength": "Strength",
|
||||
"strengthPlaceholder": "e.g. 500mg",
|
||||
"stock": "In stock",
|
||||
"unit": "Unit",
|
||||
"unitPlaceholder": "e.g. tablets",
|
||||
"reorder": "Reorder at",
|
||||
"location": "Location",
|
||||
"locationPlaceholder": "e.g. A3",
|
||||
"expires": "Expires",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Add item",
|
||||
"nameRequiredTitle": "Medication name required",
|
||||
"nameRequiredBody": "Enter a medication name before adding the item.",
|
||||
"addedTitle": "Item added",
|
||||
"failedTitle": "Could not add item",
|
||||
"failedBody": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"lab": {
|
||||
@@ -643,12 +668,44 @@
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"thinking": "Thinking…",
|
||||
"steps": "Steps",
|
||||
"veil": {
|
||||
"title": "Veil",
|
||||
"body": "This message will be processed by {{provider}}, an external provider. Veil de-identifies patient information (names, MRNs, providers) before it leaves your infrastructure, and restores it in the answer.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "De-identify & send"
|
||||
"useLocal": "Use local model",
|
||||
"confirm": "De-identify & send",
|
||||
"activeChip": "Veil active · {{provider}}"
|
||||
},
|
||||
"actionCard": {
|
||||
"title": {
|
||||
"appointment": "Proposed appointment",
|
||||
"task": "Proposed task",
|
||||
"prescription": "Proposed prescription"
|
||||
},
|
||||
"kind": {
|
||||
"appointment": "Appointment added.",
|
||||
"task": "Task added.",
|
||||
"prescription": "Prescription added."
|
||||
},
|
||||
"approve": "Add",
|
||||
"adding": "Adding…",
|
||||
"discard": "Discard",
|
||||
"added": "Added.",
|
||||
"discarded": "Discarded — nothing was saved.",
|
||||
"addedTitle": "Added",
|
||||
"failedTitle": "Could not add",
|
||||
"failedBody": "Something went wrong, or you don't have permission. Please try again."
|
||||
},
|
||||
"lists": {
|
||||
"appointments": "Appointments",
|
||||
"tasks": "Tasks",
|
||||
"prescriptions": "Prescriptions",
|
||||
"done": "Done",
|
||||
"noAppointments": "No appointments.",
|
||||
"noTasks": "No tasks.",
|
||||
"noPrescriptions": "No prescriptions."
|
||||
},
|
||||
"labCard": {
|
||||
"flags": {
|
||||
|
||||
Reference in New Issue
Block a user