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
+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": {