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:
Khalid Abdi
2026-06-13 23:49:18 +03:00
parent ddf4b49d82
commit fa2e499440
9 changed files with 874 additions and 110 deletions
@@ -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>
);
}
+4 -4
View File
@@ -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}
+161 -92
View File
@@ -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>
);
}
+30
View File
@@ -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>;
+62 -5
View File
@@ -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": {