feat(chat): replace model picker with situation modes (Chat/Analysis/Graph)

The composer's SELECT is now a clinician-facing mode picker instead of a list
of LLM vendors (the model still comes from Settings → AI and continues to drive
the Veil consent gate). The mode travels with each send:

- backend appends an Analysis/Graph directive to the agent's system prompt
- Graph mode renders an Obsidian-style RecordGraph for the /patient fast-path
  (new data-recordGraph message part reusing the shared graph component)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-17 19:30:12 +03:00
parent 651c197f94
commit d30c310336
7 changed files with 207 additions and 25 deletions
+21 -3
View File
@@ -78,7 +78,23 @@ function inlineTextFiles(messages: UIMessage[]): UIMessage[] {
});
}
function systemPrompt(veilActive: boolean, providerLabel: string): string {
// A short directive for the clinician's chosen "situation" mode, appended to the
// base prompt. Chat mode adds nothing.
function modeDirective(mode: string | undefined): string {
if (mode === "analysis") {
return "Mode — Analysis: the clinician wants interpretation, not just retrieval. After fetching a patient's data, surface patterns and correlations across their problems, labs and visits (e.g. recurring complaints, trends, likely links) and call out anything notable. Stay grounded in the tool results.";
}
if (mode === "graph") {
return "Mode — Graph: the clinician wants to see how a patient's problems and visits connect. When they reference a patient, call getPatient (its record graph renders automatically) and briefly describe the key relationships between illnesses and encounters.";
}
return "";
}
function systemPrompt(
veilActive: boolean,
providerLabel: string,
mode?: string,
): string {
return [
"You are temetro, a clinical assistant that helps clinicians retrieve,",
"organize, and add patient information. You operate over a real patient",
@@ -130,6 +146,7 @@ function systemPrompt(veilActive: boolean, providerLabel: string): string {
veilActive
? `Privacy: this conversation runs on an external provider (${providerLabel}). Patient identifiers are de-identified as tokens like [PATIENT_1] / [MRN_1]; refer to patients generically ("this patient") rather than repeating tokens.`
: "",
modeDirective(mode),
]
.filter(Boolean)
.join("\n");
@@ -137,10 +154,11 @@ function systemPrompt(veilActive: boolean, providerLabel: string): string {
chatRouter.post("/", async (req, res, next) => {
try {
const { messages, model: requestedModel } = req.body as {
const { messages, model: requestedModel, mode } = req.body as {
messages: UIMessage[];
model?: string;
effort?: string;
mode?: string;
};
if (!Array.isArray(messages)) {
res.status(400).json({ error: "messages must be an array." });
@@ -171,7 +189,7 @@ chatRouter.post("/", async (req, res, next) => {
};
const modelMessages = await convertToModelMessages(inlineTextFiles(messages));
const system = systemPrompt(veil.active, resolved.providerLabel);
const system = systemPrompt(veil.active, resolved.providerLabel, mode);
const stream = createUIMessageStream({
execute: async ({ writer }) => {
+9 -15
View File
@@ -11,19 +11,17 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { ModelPicker } from "@/components/chat/model-picker";
import { ModePicker } from "@/components/chat/mode-picker";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import type { Effort } from "@/lib/ai-models";
import type { ChatMode } from "@/lib/chat-modes";
import { cn } from "@/lib/utils";
type ChatInputProps = {
onSubmit: (text: string, files: File[]) => void;
status: ChatStatus;
onStop?: () => void;
model: string;
effort: Effort;
onModelChange: (model: string) => void;
onEffortChange: (effort: Effort) => void;
mode: ChatMode;
onModeChange: (mode: ChatMode) => void;
};
const iconButton =
@@ -37,10 +35,8 @@ export function ChatInput({
onSubmit,
status,
onStop,
model,
effort,
onModelChange,
onEffortChange,
mode,
onModeChange,
}: ChatInputProps) {
const { t } = useTranslation();
@@ -181,11 +177,9 @@ export function ChatInput({
</div>
<div className="flex shrink-0 items-center gap-1">
<ModelPicker
effort={effort}
model={model}
onEffortChange={onEffortChange}
onModelChange={onModelChange}
<ModePicker
mode={mode}
onModeChange={onModeChange}
triggerClassName={cn(pillButton, "mr-1")}
/>
<button
+35 -7
View File
@@ -61,6 +61,7 @@ import { InventoryListCard } from "@/components/chat/inventory-list-card";
import { ImportPreviewCard } from "@/components/chat/import-preview-card";
import { LabChartCard } from "@/components/chat/lab-chart-card";
import { PatientResult } from "@/components/chat/patient-cards";
import { RecordGraph } from "@/components/graph/record-graph";
import {
AppointmentListCard,
PrescriptionListCard,
@@ -79,6 +80,7 @@ import {
type Effort,
getModel,
} from "@/lib/ai-models";
import { type ChatMode, DEFAULT_MODE } from "@/lib/chat-modes";
import type { ActionPreviewData, TemetroUIMessage } from "@/lib/ai-chat";
import {
getThread,
@@ -114,6 +116,9 @@ export function ChatPanel() {
const { t } = useTranslation();
const [model, setModel] = useState<string>(DEFAULT_MODEL_ID);
const [effort, setEffort] = useState<Effort>(DEFAULT_EFFORT);
// The clinician-facing "situation" mode (Chat / Analysis / Graph). The model
// itself comes from Settings → AI; this shapes what the assistant does.
const [mode, setMode] = useState<ChatMode>(DEFAULT_MODE);
// Veil consent: cloud models de-identify + send data externally. We ask once
// per session before the first such send — inline (no modal). `pendingConsent`
@@ -185,10 +190,10 @@ export function ChatPanel() {
const fileParts = await Promise.all(files.map(fileToPart));
sendMessage(
{ text, files: fileParts },
{ body: { model: modelId, effort, threadId: threadIdRef.current } },
{ body: { model: modelId, effort, mode, threadId: threadIdRef.current } },
);
},
[sendMessage, effort],
[sendMessage, effort, mode],
);
const send = useCallback(
@@ -223,7 +228,11 @@ export function ChatPanel() {
id: nanoid(),
role: "assistant",
parts: patient
? [{ type: "data-patientCard", data: patient }]
? [
mode === "graph"
? { type: "data-recordGraph", data: patient }
: { type: "data-patientCard", data: patient },
]
: [
{
type: "text",
@@ -245,6 +254,7 @@ export function ChatPanel() {
[
consented,
isCloudModel,
mode,
model,
pendingConsent,
runAgentWith,
@@ -353,10 +363,8 @@ export function ChatPanel() {
const promptInput = (
<ChatInput
effort={effort}
model={model}
onEffortChange={setEffort}
onModelChange={setModel}
mode={mode}
onModeChange={setMode}
onStop={stop}
onSubmit={send}
status={status}
@@ -545,6 +553,26 @@ export function ChatPanel() {
/>
);
}
if (part.type === "data-recordGraph") {
return (
<div
className="w-full overflow-hidden rounded-2xl border bg-card/30"
key={key}
>
<div className="flex items-center justify-between gap-2 px-4 pt-3">
<span className="font-medium text-foreground text-sm">
{part.data.name}
</span>
<span className="text-muted-foreground text-xs">
{t("chat.graphCard.label")}
</span>
</div>
<div className="p-3">
<RecordGraph patient={part.data} />
</div>
</div>
);
}
if (part.type === "data-labCard") {
return <LabChartCard data={part.data} key={key} />;
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Menu,
MenuPopup,
MenuRadioGroup,
MenuRadioItem,
MenuTrigger,
} from "@/components/ui/menu";
import { type ChatMode, CHAT_MODES, getMode } from "@/lib/chat-modes";
type ModePickerProps = {
mode: ChatMode;
onModeChange: (mode: ChatMode) => void;
triggerClassName: string;
};
/**
* The situation-mode control in the chat composer (Chat / Analysis / Graph).
* Replaces the old model picker — clinicians pick what they want the assistant
* to do, not which LLM runs (that's configured once in Settings → AI).
*/
export function ModePicker({
mode,
onModeChange,
triggerClassName,
}: ModePickerProps) {
const { t } = useTranslation();
const selected = getMode(mode);
const SelectedIcon = selected.icon;
return (
<Menu>
<MenuTrigger
render={
<button
aria-label={t("chat.input.mode")}
className={triggerClassName}
type="button"
/>
}
>
<SelectedIcon className="size-4 opacity-80" />
<span className="truncate font-medium text-foreground">
{t(selected.labelKey)}
</span>
<ChevronDown className="size-4 opacity-70" />
</MenuTrigger>
<MenuPopup align="end" className="min-w-64">
<MenuRadioGroup
onValueChange={(value) => onModeChange(value as ChatMode)}
value={mode}
>
{CHAT_MODES.map((m) => {
const Icon = m.icon;
return (
<MenuRadioItem key={m.id} value={m.id}>
<span className="flex items-center gap-2">
<Icon className="size-4 opacity-80" />
<span className="flex flex-col">
<span className="text-foreground">{t(m.labelKey)}</span>
<span className="text-muted-foreground text-xs">
{t(m.descriptionKey)}
</span>
</span>
</span>
</MenuRadioItem>
);
})}
</MenuRadioGroup>
</MenuPopup>
</Menu>
);
}
+3
View File
@@ -69,6 +69,9 @@ export type ActionPreviewData = {
// prefixed with `data-` (e.g. `data-patientCard`), per the AI SDK convention.
export type TemetroDataParts = {
patientCard: Patient;
// The same patient rendered as an Obsidian-style problems↔visits graph (Graph
// mode). Carries the full record so the graph renders client-side.
recordGraph: Patient;
labCard: LabCardData;
importPreview: ImportPreviewData;
veilNotice: VeilNoticeData;
+44
View File
@@ -0,0 +1,44 @@
// The chat "situation" modes shown in the composer — what the clinician wants
// the assistant to do, rather than which LLM runs (the model/provider is set
// once in Settings → AI). The mode travels with each send so the backend can
// shape its system prompt, and Graph mode also renders the record graph for the
// `/patient` fast-path.
import { MessageSquare, Network, Sparkles } from "lucide-react";
export type ChatMode = "chat" | "analysis" | "graph";
export const DEFAULT_MODE: ChatMode = "chat";
export type ChatModeOption = {
id: ChatMode;
icon: typeof MessageSquare;
// i18n keys under `chat.input.modes.*`.
labelKey: string;
descriptionKey: string;
};
export const CHAT_MODES: ChatModeOption[] = [
{
id: "chat",
icon: MessageSquare,
labelKey: "chat.input.modes.chat.label",
descriptionKey: "chat.input.modes.chat.description",
},
{
id: "analysis",
icon: Sparkles,
labelKey: "chat.input.modes.analysis.label",
descriptionKey: "chat.input.modes.analysis.description",
},
{
id: "graph",
icon: Network,
labelKey: "chat.input.modes.graph.label",
descriptionKey: "chat.input.modes.graph.description",
},
];
export function getMode(id: string): ChatModeOption {
return CHAT_MODES.find((m) => m.id === id) ?? CHAT_MODES[0];
}
@@ -888,6 +888,21 @@
"send": "Send",
"stop": "Stop",
"model": "Model",
"mode": "Mode",
"modes": {
"chat": {
"label": "Chat",
"description": "Ask and retrieve patient information."
},
"analysis": {
"label": "Analysis",
"description": "Interpret patterns across a patient's history."
},
"graph": {
"label": "Graph",
"description": "See how problems and visits connect."
}
},
"moreModels": "More models",
"effort": "Effort",
"effortOptions": {
@@ -911,6 +926,9 @@
"thinking": "Thinking…",
"steps": "Steps",
"reasoning": "Reasoning",
"graphCard": {
"label": "Record graph"
},
"history": {
"title": "Chats",
"untitled": "New chat",