mirror of
https://github.com/temetro/temetro.git
synced 2026-08-20 15:12:18 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user