diff --git a/frontend/components/chat/chat-input.tsx b/frontend/components/chat/chat-input.tsx
index f43bca8..e8abb01 100644
--- a/frontend/components/chat/chat-input.tsx
+++ b/frontend/components/chat/chat-input.tsx
@@ -1,78 +1,31 @@
"use client";
import type { ChatStatus } from "ai";
-import {
- ArrowUp,
- Building2,
- CalendarRange,
- ChevronDown,
- Hand,
- Mic,
- Plus,
- Square,
- Stethoscope,
- UserPlus,
- X,
-} from "lucide-react";
+import { ArrowUp, Mic, Plus, Square, UserPlus, X } from "lucide-react";
import {
type ChangeEvent,
type KeyboardEvent,
- type ReactNode,
useCallback,
- useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
+import { ModelPicker } from "@/components/chat/model-picker";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
-import {
- Menu,
- MenuPopup,
- MenuRadioGroup,
- MenuRadioItem,
- MenuTrigger,
-} from "@/components/ui/menu";
+import type { Effort } from "@/lib/ai-models";
import { cn } from "@/lib/utils";
type ChatInputProps = {
onSubmit: (text: string) => void;
status: ChatStatus;
onStop?: () => void;
+ model: string;
+ effort: Effort;
+ onModelChange: (model: string) => void;
+ onEffortChange: (effort: Effort) => void;
};
-type Option = { value: string; label: string };
-type OptionKey = { value: string; labelKey: string };
-
-const ACCESS_OPTIONS: OptionKey[] = [
- { value: "standard", labelKey: "chat.input.access.standard" },
- { value: "break-glass", labelKey: "chat.input.access.breakGlass" },
- { value: "read-only", labelKey: "chat.input.access.readOnly" },
-];
-const RESPONSE_OPTIONS: OptionKey[] = [
- { value: "concise", labelKey: "chat.input.response.concise" },
- { value: "detailed", labelKey: "chat.input.response.detailed" },
- { value: "comprehensive", labelKey: "chat.input.response.comprehensive" },
-];
-const SPECIALTY_OPTIONS: OptionKey[] = [
- { value: "internal-medicine", labelKey: "chat.input.specialtyOptions.internalMedicine" },
- { value: "cardiology", labelKey: "chat.input.specialtyOptions.cardiology" },
- { value: "pediatrics", labelKey: "chat.input.specialtyOptions.pediatrics" },
- { value: "emergency", labelKey: "chat.input.specialtyOptions.emergency" },
- { value: "all", labelKey: "chat.input.specialtyOptions.all" },
-];
-const FACILITY_OPTIONS: OptionKey[] = [
- { value: "main-hospital", labelKey: "chat.input.facilityOptions.mainHospital" },
- { value: "north-clinic", labelKey: "chat.input.facilityOptions.northClinic" },
- { value: "telehealth", labelKey: "chat.input.facilityOptions.telehealth" },
-];
-const TIME_OPTIONS: OptionKey[] = [
- { value: "30d", labelKey: "chat.input.timeOptions.30d" },
- { value: "12m", labelKey: "chat.input.timeOptions.12m" },
- { value: "5y", labelKey: "chat.input.timeOptions.5y" },
- { value: "all", labelKey: "chat.input.timeOptions.all" },
-];
-
const iconButton =
"flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
const pillButton =
@@ -80,79 +33,19 @@ const pillButton =
const contextPill =
"flex h-7 items-center gap-1.5 rounded-md px-2 text-[13px] text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground";
-function SelectPill({
- ariaLabel,
- triggerClassName,
- chevronClassName,
- icon,
- prefix,
- value,
- onValueChange,
- options,
- align = "start",
-}: {
- ariaLabel: string;
- triggerClassName: string;
- chevronClassName: string;
- icon: ReactNode;
- prefix?: string;
- value: string;
- onValueChange: (value: string) => void;
- options: Option[];
- align?: "start" | "center" | "end";
-}) {
- const selected = options.find((option) => option.value === value);
-
- return (
-
- );
-}
-
-export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
+export function ChatInput({
+ onSubmit,
+ status,
+ onStop,
+ model,
+ effort,
+ onModelChange,
+ onEffortChange,
+}: ChatInputProps) {
const { t } = useTranslation();
- const toOptions = useCallback(
- (opts: OptionKey[]): Option[] =>
- opts.map((o) => ({ value: o.value, label: t(o.labelKey) })),
- [t],
- );
- const accessOptions = useMemo(() => toOptions(ACCESS_OPTIONS), [toOptions]);
- const responseOptions = useMemo(() => toOptions(RESPONSE_OPTIONS), [toOptions]);
- const specialtyOptions = useMemo(
- () => toOptions(SPECIALTY_OPTIONS),
- [toOptions],
- );
- const facilityOptions = useMemo(() => toOptions(FACILITY_OPTIONS), [toOptions]);
- const timeOptions = useMemo(() => toOptions(TIME_OPTIONS), [toOptions]);
const [value, setValue] = useState("");
const [files, setFiles] = useState([]);
- const [access, setAccess] = useState("standard");
- const [responseMode, setResponseMode] = useState("detailed");
- const [specialty, setSpecialty] = useState("internal-medicine");
- const [facility, setFacility] = useState("main-hospital");
- const [timeRange, setTimeRange] = useState("12m");
const [addOpen, setAddOpen] = useState(false);
// Bumped on each open so the dialog remounts with a fresh file number + form.
const [addKey, setAddKey] = useState(0);
@@ -216,10 +109,10 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
event.preventDefault();
submit();
}}
- className="w-full overflow-hidden rounded-[28px] border border-border/60 bg-muted shadow-sm"
+ className="w-full overflow-hidden rounded-[28px] border border-border/60 bg-input shadow-sm"
>
- {/* Top (lighter) card: textarea + toolbar, with a slightly smaller bottom radius */}
-
+ {/* Textarea + toolbar, filling the rounded card. */}
+
-
-
- {/* Bottom (darker) card peeking out below, more rounded: context selectors */}
-
- }
- onValueChange={setSpecialty}
- options={specialtyOptions}
- triggerClassName={contextPill}
- value={specialty}
- />
- }
- onValueChange={setFacility}
- options={facilityOptions}
- triggerClassName={contextPill}
- value={facility}
- />
- }
- onValueChange={setTimeRange}
- options={timeOptions}
- triggerClassName={contextPill}
- value={timeRange}
- />
-
-
([]);
const [status, setStatus] = useState("ready");
+ // Model + effort selected in the input; travels with each send (wired to the
+ // backend agent in a later phase). Defaults come from the shared catalog.
+ const [model, setModel] = useState(DEFAULT_MODEL_ID);
+ const [effort, setEffort] = useState(DEFAULT_EFFORT);
const send = useCallback(async (text: string) => {
const trimmed = text.trim();
@@ -127,7 +132,15 @@ export function ChatPanel() {
}, [requestedPatient, send]);
const promptInput = (
-
+
);
if (messages.length === 0) {
diff --git a/frontend/components/chat/model-picker.tsx b/frontend/components/chat/model-picker.tsx
new file mode 100644
index 0000000..47a3be6
--- /dev/null
+++ b/frontend/components/chat/model-picker.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { ChevronDown } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import {
+ Menu,
+ MenuPopup,
+ MenuRadioGroup,
+ MenuRadioItem,
+ MenuSeparator,
+ MenuSub,
+ MenuSubPopup,
+ MenuSubTrigger,
+ MenuTrigger,
+} from "@/components/ui/menu";
+import {
+ type AiModel,
+ type Effort,
+ EFFORT_LEVELS,
+ getModel,
+ MORE_MODELS,
+ PRIMARY_MODELS,
+} from "@/lib/ai-models";
+import { cn } from "@/lib/utils";
+
+type ModelPickerProps = {
+ model: string;
+ effort: Effort;
+ onModelChange: (model: string) => void;
+ onEffortChange: (effort: Effort) => void;
+ triggerClassName: string;
+};
+
+function ModelRow({ model }: { model: AiModel }) {
+ const { t } = useTranslation();
+ return (
+
+ {model.label}
+
+ {t(model.descriptionKey)}
+
+
+ );
+}
+
+/**
+ * The single model + effort control in the chat input, styled like the
+ * reference design: a list of primary models, an Effort submenu, and a
+ * "More models" submenu. Selection is owned by the chat panel so it travels
+ * with each send.
+ */
+export function ModelPicker({
+ model,
+ effort,
+ onModelChange,
+ onEffortChange,
+ triggerClassName,
+}: ModelPickerProps) {
+ const { t } = useTranslation();
+ const selected = getModel(model);
+ const showEffort = selected?.supportsEffort ?? false;
+
+ return (
+
+ );
+}
diff --git a/frontend/lib/ai-models.ts b/frontend/lib/ai-models.ts
new file mode 100644
index 0000000..bc5e438
--- /dev/null
+++ b/frontend/lib/ai-models.ts
@@ -0,0 +1,97 @@
+// Catalog of models the chat can run, shared by the chat-input model picker,
+// the Settings → AI panel, and the chat→backend wiring. The actual provider
+// call is made by the backend (`backend/src/services/ai/provider.ts`); here we
+// only describe the menu of choices. `id` is the provider's model id sent to
+// the backend; `descriptionKey` is an i18n key under `chat.input.models.*`.
+
+export type Effort = "low" | "medium" | "high";
+
+export const EFFORT_LEVELS: Effort[] = ["low", "medium", "high"];
+export const DEFAULT_EFFORT: Effort = "medium";
+
+// The three supported cloud providers (API key) plus local Ollama.
+export type AiProvider = "anthropic" | "openai" | "gemini" | "ollama";
+
+export type AiModel = {
+ /** Provider model id passed to the backend. */
+ id: string;
+ provider: AiProvider;
+ /** Short display name shown in the picker trigger. */
+ label: string;
+ /** i18n key for the one-line description under the label. */
+ descriptionKey: string;
+ /** Shown directly in the model list; non-primary live under "More models". */
+ primary?: boolean;
+ /** Whether the Effort control applies to this model. */
+ supportsEffort?: boolean;
+};
+
+// Curated catalog. Cloud model ids are best-effort current ids and can be
+// adjusted without touching the UI. Ollama is a stand-in for "your local model"
+// — the concrete tag is configured in Settings → AI.
+export const AI_MODELS: AiModel[] = [
+ {
+ id: "claude-opus-4-8",
+ provider: "anthropic",
+ label: "Opus 4.8",
+ descriptionKey: "chat.input.models.opus48",
+ primary: true,
+ supportsEffort: true,
+ },
+ {
+ id: "claude-sonnet-4-6",
+ provider: "anthropic",
+ label: "Sonnet 4.6",
+ descriptionKey: "chat.input.models.sonnet46",
+ primary: true,
+ supportsEffort: true,
+ },
+ {
+ id: "claude-haiku-4-5",
+ provider: "anthropic",
+ label: "Haiku 4.5",
+ descriptionKey: "chat.input.models.haiku45",
+ primary: true,
+ },
+ {
+ id: "gpt-4o",
+ provider: "openai",
+ label: "GPT-4o",
+ descriptionKey: "chat.input.models.gpt4o",
+ supportsEffort: true,
+ },
+ {
+ id: "gpt-4o-mini",
+ provider: "openai",
+ label: "GPT-4o mini",
+ descriptionKey: "chat.input.models.gpt4oMini",
+ },
+ {
+ id: "gemini-2.0-flash",
+ provider: "gemini",
+ label: "Gemini 2.0 Flash",
+ descriptionKey: "chat.input.models.gemini20Flash",
+ },
+ {
+ id: "gemini-1.5-pro",
+ provider: "gemini",
+ label: "Gemini 1.5 Pro",
+ descriptionKey: "chat.input.models.gemini15Pro",
+ supportsEffort: true,
+ },
+ {
+ id: "ollama",
+ provider: "ollama",
+ label: "Local (Ollama)",
+ descriptionKey: "chat.input.models.ollama",
+ },
+];
+
+export const DEFAULT_MODEL_ID = AI_MODELS[0].id;
+
+export function getModel(id: string): AiModel | undefined {
+ return AI_MODELS.find((m) => m.id === id);
+}
+
+export const PRIMARY_MODELS = AI_MODELS.filter((m) => m.primary);
+export const MORE_MODELS = AI_MODELS.filter((m) => !m.primary);
diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json
index 7cd46a3..ca92aa4 100644
--- a/frontend/lib/i18n/locales/en/translation.json
+++ b/frontend/lib/i18n/locales/en/translation.json
@@ -616,45 +616,29 @@
"placeholder": "Look up a patient — try /patient 10293",
"message": "Message",
"addPatient": "Add patient",
- "clinical": "Clinical",
"attachFile": "Attach file",
"attachFiles": "Attach files",
"removeFile": "Remove {{name}}",
"dictate": "Dictate",
"send": "Send",
"stop": "Stop",
- "accessLevel": "Access level",
- "responseMode": "Response mode",
- "specialty": "Specialty",
- "facility": "Facility",
- "timeRange": "Time range",
- "access": {
- "standard": "Standard access",
- "breakGlass": "Break-glass (emergency)",
- "readOnly": "Read-only"
+ "model": "Model",
+ "moreModels": "More models",
+ "effort": "Effort",
+ "effortOptions": {
+ "low": "Low",
+ "medium": "Medium",
+ "high": "High"
},
- "response": {
- "concise": "Concise",
- "detailed": "Detailed",
- "comprehensive": "Comprehensive"
- },
- "specialtyOptions": {
- "internalMedicine": "Internal Medicine",
- "cardiology": "Cardiology",
- "pediatrics": "Pediatrics",
- "emergency": "Emergency",
- "all": "All specialties"
- },
- "facilityOptions": {
- "mainHospital": "Main Hospital",
- "northClinic": "North Clinic",
- "telehealth": "Telehealth"
- },
- "timeOptions": {
- "30d": "Last 30 days",
- "12m": "Last 12 months",
- "5y": "Last 5 years",
- "all": "All time"
+ "models": {
+ "opus48": "For complex clinical reasoning",
+ "sonnet46": "Balanced for everyday tasks",
+ "haiku45": "Fastest for quick answers",
+ "gpt4o": "OpenAI multimodal flagship",
+ "gpt4oMini": "Fast, low-cost OpenAI model",
+ "gemini20Flash": "Fast Google model",
+ "gemini15Pro": "Google long-context model",
+ "ollama": "Runs locally on your infrastructure"
}
}
},
@@ -662,7 +646,10 @@
"notFound": "No patient found for file #{{number}}.",
"overview": "Overview",
"edit": "Edit",
- "sex": { "F": "Female", "M": "Male" },
+ "sex": {
+ "F": "Female",
+ "M": "Male"
+ },
"summary": {
"fullName": "Full name",
"mrn": "MRN",