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
+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>
);
}