mirror of
https://github.com/temetro/temetro.git
synced 2026-08-20 07:02:17 +00:00
fa2e499440
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>
150 lines
4.7 KiB
TypeScript
150 lines
4.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|