mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
frontend: declutter chat input to a single model+effort picker
Replace the five clinical context selects (access, response mode, specialty, facility, time range) with one model picker styled like the reference design: a primary model list with descriptions, an Effort submenu, and a "More models" submenu. Move the Add-patient pill up into the toolbar and drop the now-empty bottom context-selector card so the input is a single clean rounded surface. Model/effort selection is lifted to ChatPanel so it can travel with each send (wired to the backend agent in a later phase). Adds a shared model catalog (lib/ai-models.ts) reused by the picker, Settings, and the chat wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,78 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import { ArrowUp, Mic, Plus, Square, UserPlus, X } from "lucide-react";
|
||||||
ArrowUp,
|
|
||||||
Building2,
|
|
||||||
CalendarRange,
|
|
||||||
ChevronDown,
|
|
||||||
Hand,
|
|
||||||
Mic,
|
|
||||||
Plus,
|
|
||||||
Square,
|
|
||||||
Stethoscope,
|
|
||||||
UserPlus,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { ModelPicker } from "@/components/chat/model-picker";
|
||||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||||
import {
|
import type { Effort } from "@/lib/ai-models";
|
||||||
Menu,
|
|
||||||
MenuPopup,
|
|
||||||
MenuRadioGroup,
|
|
||||||
MenuRadioItem,
|
|
||||||
MenuTrigger,
|
|
||||||
} from "@/components/ui/menu";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ChatInputProps = {
|
type ChatInputProps = {
|
||||||
onSubmit: (text: string) => void;
|
onSubmit: (text: string) => void;
|
||||||
status: ChatStatus;
|
status: ChatStatus;
|
||||||
onStop?: () => void;
|
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 =
|
const iconButton =
|
||||||
"flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
|
"flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
|
||||||
const pillButton =
|
const pillButton =
|
||||||
@@ -80,79 +33,19 @@ const pillButton =
|
|||||||
const contextPill =
|
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";
|
"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({
|
export function ChatInput({
|
||||||
ariaLabel,
|
onSubmit,
|
||||||
triggerClassName,
|
status,
|
||||||
chevronClassName,
|
onStop,
|
||||||
icon,
|
model,
|
||||||
prefix,
|
effort,
|
||||||
value,
|
onModelChange,
|
||||||
onValueChange,
|
onEffortChange,
|
||||||
options,
|
}: ChatInputProps) {
|
||||||
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 (
|
|
||||||
<Menu>
|
|
||||||
<MenuTrigger
|
|
||||||
render={
|
|
||||||
<button aria-label={ariaLabel} className={triggerClassName} type="button" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
{prefix ? (
|
|
||||||
<span className="font-medium text-foreground">{prefix}</span>
|
|
||||||
) : null}
|
|
||||||
<span className="truncate">{selected?.label}</span>
|
|
||||||
<ChevronDown className={chevronClassName} />
|
|
||||||
</MenuTrigger>
|
|
||||||
<MenuPopup align={align}>
|
|
||||||
<MenuRadioGroup onValueChange={onValueChange} value={value}>
|
|
||||||
{options.map((option) => (
|
|
||||||
<MenuRadioItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</MenuRadioItem>
|
|
||||||
))}
|
|
||||||
</MenuRadioGroup>
|
|
||||||
</MenuPopup>
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
|
|
||||||
const { t } = useTranslation();
|
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 [value, setValue] = useState("");
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
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);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
// Bumped on each open so the dialog remounts with a fresh file number + form.
|
// Bumped on each open so the dialog remounts with a fresh file number + form.
|
||||||
const [addKey, setAddKey] = useState(0);
|
const [addKey, setAddKey] = useState(0);
|
||||||
@@ -216,10 +109,10 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
submit();
|
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. */}
|
||||||
<div className="rounded-b-[22px] bg-input">
|
<div className="bg-input">
|
||||||
<textarea
|
<textarea
|
||||||
aria-label={t("chat.input.message")}
|
aria-label={t("chat.input.message")}
|
||||||
className="field-sizing-content block max-h-48 min-h-16 w-full resize-none bg-transparent px-5 pt-5 pb-2 text-base text-foreground outline-none placeholder:text-muted-foreground"
|
className="field-sizing-content block max-h-48 min-h-16 w-full resize-none bg-transparent px-5 pt-5 pb-2 text-base text-foreground outline-none placeholder:text-muted-foreground"
|
||||||
@@ -270,28 +163,26 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
|
|||||||
>
|
>
|
||||||
<Plus className="size-[18px]" />
|
<Plus className="size-[18px]" />
|
||||||
</button>
|
</button>
|
||||||
<SelectPill
|
<button
|
||||||
ariaLabel={t("chat.input.accessLevel")}
|
className={cn(contextPill, "ml-0.5")}
|
||||||
chevronClassName="size-4 opacity-70"
|
onClick={() => {
|
||||||
icon={<Hand className="size-4" />}
|
setAddKey((k) => k + 1);
|
||||||
onValueChange={setAccess}
|
setAddOpen(true);
|
||||||
options={accessOptions}
|
}}
|
||||||
triggerClassName={pillButton}
|
type="button"
|
||||||
value={access}
|
>
|
||||||
/>
|
<UserPlus className="size-4" />
|
||||||
|
<span>{t("chat.input.addPatient")}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<SelectPill
|
<ModelPicker
|
||||||
align="end"
|
effort={effort}
|
||||||
ariaLabel={t("chat.input.responseMode")}
|
model={model}
|
||||||
chevronClassName="size-4 opacity-70"
|
onEffortChange={onEffortChange}
|
||||||
icon={null}
|
onModelChange={onModelChange}
|
||||||
onValueChange={setResponseMode}
|
|
||||||
options={responseOptions}
|
|
||||||
prefix={t("chat.input.clinical")}
|
|
||||||
triggerClassName={cn(pillButton, "mr-1")}
|
triggerClassName={cn(pillButton, "mr-1")}
|
||||||
value={responseMode}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
aria-label={t("chat.input.dictate")}
|
aria-label={t("chat.input.dictate")}
|
||||||
@@ -321,48 +212,6 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom (darker) card peeking out below, more rounded: context selectors */}
|
|
||||||
<div className="flex flex-wrap items-center gap-1 px-3 pt-2.5 pb-3">
|
|
||||||
<SelectPill
|
|
||||||
ariaLabel={t("chat.input.specialty")}
|
|
||||||
chevronClassName="size-3.5 opacity-70"
|
|
||||||
icon={<Stethoscope className="size-4" />}
|
|
||||||
onValueChange={setSpecialty}
|
|
||||||
options={specialtyOptions}
|
|
||||||
triggerClassName={contextPill}
|
|
||||||
value={specialty}
|
|
||||||
/>
|
|
||||||
<SelectPill
|
|
||||||
ariaLabel={t("chat.input.facility")}
|
|
||||||
chevronClassName="size-3.5 opacity-70"
|
|
||||||
icon={<Building2 className="size-4" />}
|
|
||||||
onValueChange={setFacility}
|
|
||||||
options={facilityOptions}
|
|
||||||
triggerClassName={contextPill}
|
|
||||||
value={facility}
|
|
||||||
/>
|
|
||||||
<SelectPill
|
|
||||||
ariaLabel={t("chat.input.timeRange")}
|
|
||||||
chevronClassName="size-3.5 opacity-70"
|
|
||||||
icon={<CalendarRange className="size-4" />}
|
|
||||||
onValueChange={setTimeRange}
|
|
||||||
options={timeOptions}
|
|
||||||
triggerClassName={contextPill}
|
|
||||||
value={timeRange}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className={cn(contextPill, "ml-auto")}
|
|
||||||
onClick={() => {
|
|
||||||
setAddKey((k) => k + 1);
|
|
||||||
setAddOpen(true);
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<UserPlus className="size-4" />
|
|
||||||
<span>{t("chat.input.addPatient")}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<PatientFormDialog
|
<PatientFormDialog
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "@/components/ai-elements/message";
|
} from "@/components/ai-elements/message";
|
||||||
import { ChatInput } from "@/components/chat/chat-input";
|
import { ChatInput } from "@/components/chat/chat-input";
|
||||||
import { PatientResult } from "@/components/chat/patient-cards";
|
import { PatientResult } from "@/components/chat/patient-cards";
|
||||||
|
import { DEFAULT_EFFORT, DEFAULT_MODEL_ID, type Effort } from "@/lib/ai-models";
|
||||||
import { getPatient, type Patient } from "@/lib/patients";
|
import { getPatient, type Patient } from "@/lib/patients";
|
||||||
|
|
||||||
type ChatMessage =
|
type ChatMessage =
|
||||||
@@ -39,6 +40,10 @@ export function ChatPanel() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [status, setStatus] = useState<ChatStatus>("ready");
|
const [status, setStatus] = useState<ChatStatus>("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<string>(DEFAULT_MODEL_ID);
|
||||||
|
const [effort, setEffort] = useState<Effort>(DEFAULT_EFFORT);
|
||||||
|
|
||||||
const send = useCallback(async (text: string) => {
|
const send = useCallback(async (text: string) => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
@@ -127,7 +132,15 @@ export function ChatPanel() {
|
|||||||
}, [requestedPatient, send]);
|
}, [requestedPatient, send]);
|
||||||
|
|
||||||
const promptInput = (
|
const promptInput = (
|
||||||
<ChatInput onStop={handleStop} onSubmit={send} status={status} />
|
<ChatInput
|
||||||
|
effort={effort}
|
||||||
|
model={model}
|
||||||
|
onEffortChange={setEffort}
|
||||||
|
onModelChange={setModel}
|
||||||
|
onStop={handleStop}
|
||||||
|
onSubmit={send}
|
||||||
|
status={status}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-foreground">{model.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t(model.descriptionKey)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<Menu>
|
||||||
|
<MenuTrigger
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
aria-label={t("chat.input.model")}
|
||||||
|
className={triggerClassName}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium text-foreground">
|
||||||
|
{selected?.label ?? t("chat.input.model")}
|
||||||
|
</span>
|
||||||
|
{showEffort ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t(`chat.input.effortOptions.${effort}`)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<ChevronDown className="size-4 opacity-70" />
|
||||||
|
</MenuTrigger>
|
||||||
|
<MenuPopup align="end" className="min-w-64">
|
||||||
|
<MenuRadioGroup onValueChange={onModelChange} value={model}>
|
||||||
|
{PRIMARY_MODELS.map((m) => (
|
||||||
|
<MenuRadioItem key={m.id} value={m.id}>
|
||||||
|
<ModelRow model={m} />
|
||||||
|
</MenuRadioItem>
|
||||||
|
))}
|
||||||
|
</MenuRadioGroup>
|
||||||
|
|
||||||
|
<MenuSeparator />
|
||||||
|
|
||||||
|
{showEffort ? (
|
||||||
|
<MenuSub>
|
||||||
|
<MenuSubTrigger>
|
||||||
|
<span>{t("chat.input.effort")}</span>
|
||||||
|
<span className="ms-auto text-muted-foreground">
|
||||||
|
{t(`chat.input.effortOptions.${effort}`)}
|
||||||
|
</span>
|
||||||
|
</MenuSubTrigger>
|
||||||
|
<MenuSubPopup>
|
||||||
|
<MenuRadioGroup
|
||||||
|
onValueChange={(value) => onEffortChange(value as Effort)}
|
||||||
|
value={effort}
|
||||||
|
>
|
||||||
|
{EFFORT_LEVELS.map((level) => (
|
||||||
|
<MenuRadioItem key={level} value={level}>
|
||||||
|
{t(`chat.input.effortOptions.${level}`)}
|
||||||
|
</MenuRadioItem>
|
||||||
|
))}
|
||||||
|
</MenuRadioGroup>
|
||||||
|
</MenuSubPopup>
|
||||||
|
</MenuSub>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MenuSub>
|
||||||
|
<MenuSubTrigger>{t("chat.input.moreModels")}</MenuSubTrigger>
|
||||||
|
<MenuSubPopup className={cn("min-w-60")}>
|
||||||
|
<MenuRadioGroup onValueChange={onModelChange} value={model}>
|
||||||
|
{MORE_MODELS.map((m) => (
|
||||||
|
<MenuRadioItem key={m.id} value={m.id}>
|
||||||
|
<ModelRow model={m} />
|
||||||
|
</MenuRadioItem>
|
||||||
|
))}
|
||||||
|
</MenuRadioGroup>
|
||||||
|
</MenuSubPopup>
|
||||||
|
</MenuSub>
|
||||||
|
</MenuPopup>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -616,45 +616,29 @@
|
|||||||
"placeholder": "Look up a patient — try /patient 10293",
|
"placeholder": "Look up a patient — try /patient 10293",
|
||||||
"message": "Message",
|
"message": "Message",
|
||||||
"addPatient": "Add patient",
|
"addPatient": "Add patient",
|
||||||
"clinical": "Clinical",
|
|
||||||
"attachFile": "Attach file",
|
"attachFile": "Attach file",
|
||||||
"attachFiles": "Attach files",
|
"attachFiles": "Attach files",
|
||||||
"removeFile": "Remove {{name}}",
|
"removeFile": "Remove {{name}}",
|
||||||
"dictate": "Dictate",
|
"dictate": "Dictate",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"stop": "Stop",
|
"stop": "Stop",
|
||||||
"accessLevel": "Access level",
|
"model": "Model",
|
||||||
"responseMode": "Response mode",
|
"moreModels": "More models",
|
||||||
"specialty": "Specialty",
|
"effort": "Effort",
|
||||||
"facility": "Facility",
|
"effortOptions": {
|
||||||
"timeRange": "Time range",
|
"low": "Low",
|
||||||
"access": {
|
"medium": "Medium",
|
||||||
"standard": "Standard access",
|
"high": "High"
|
||||||
"breakGlass": "Break-glass (emergency)",
|
|
||||||
"readOnly": "Read-only"
|
|
||||||
},
|
},
|
||||||
"response": {
|
"models": {
|
||||||
"concise": "Concise",
|
"opus48": "For complex clinical reasoning",
|
||||||
"detailed": "Detailed",
|
"sonnet46": "Balanced for everyday tasks",
|
||||||
"comprehensive": "Comprehensive"
|
"haiku45": "Fastest for quick answers",
|
||||||
},
|
"gpt4o": "OpenAI multimodal flagship",
|
||||||
"specialtyOptions": {
|
"gpt4oMini": "Fast, low-cost OpenAI model",
|
||||||
"internalMedicine": "Internal Medicine",
|
"gemini20Flash": "Fast Google model",
|
||||||
"cardiology": "Cardiology",
|
"gemini15Pro": "Google long-context model",
|
||||||
"pediatrics": "Pediatrics",
|
"ollama": "Runs locally on your infrastructure"
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -662,7 +646,10 @@
|
|||||||
"notFound": "No patient found for file #{{number}}.",
|
"notFound": "No patient found for file #{{number}}.",
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"sex": { "F": "Female", "M": "Male" },
|
"sex": {
|
||||||
|
"F": "Female",
|
||||||
|
"M": "Male"
|
||||||
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"fullName": "Full name",
|
"fullName": "Full name",
|
||||||
"mrn": "MRN",
|
"mrn": "MRN",
|
||||||
|
|||||||
Reference in New Issue
Block a user