mirror of
https://github.com/temetro/temetro.git
synced 2026-07-28 12:48:58 +00:00
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:
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user