mirror of
https://github.com/temetro/temetro.git
synced 2026-08-06 00:47:41 +00:00
frontend: Settings → AI panel (modes, providers, Veil)
New per-user AI settings section (visible to every clinician, not just admins): - Inference mode: cloud API key vs local Ollama. - Provider + key (OpenAI / Anthropic / Gemini) via a select; the key field is write-only and shows a "key set" indicator from the backend's apiKeySet, never echoing the stored secret. Default model + effort per provider. - Local Ollama: base URL + model name with a "Test connection" probe. - Veil: de-identification level (full / names / off) with mode-aware copy. Talks to the new /api/ai/config + /api/ai/test endpoints via lib/ai-settings.ts. The old mock clinical selects (specialty/facility/time range/access/response) are dropped entirely rather than relocated — they were placeholder knobs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectItem,
|
||||
SelectPopup,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
FieldLabel,
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import { AI_MODELS, EFFORT_LEVELS, type Effort } from "@/lib/ai-models";
|
||||
import {
|
||||
type AiConfig,
|
||||
type AiMode,
|
||||
type ApiProvider,
|
||||
type VeilLevel,
|
||||
getAiConfig,
|
||||
saveAiConfig,
|
||||
testAiConnection,
|
||||
} from "@/lib/ai-settings";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
const PROVIDERS: ApiProvider[] = ["openai", "anthropic", "gemini"];
|
||||
const VEIL_LEVELS: VeilLevel[] = ["full", "names", "off"];
|
||||
|
||||
const DEFAULTS: AiConfig = {
|
||||
mode: "local",
|
||||
provider: "anthropic",
|
||||
ollamaBaseUrl: "http://localhost:11434",
|
||||
ollamaModel: "llama3.1",
|
||||
defaultModel: "claude-sonnet-4-6",
|
||||
defaultEffort: "medium",
|
||||
veilLevel: "full",
|
||||
apiKeySet: { openai: false, anthropic: false, gemini: false },
|
||||
};
|
||||
|
||||
export function AIPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [config, setConfig] = useState<AiConfig>(DEFAULTS);
|
||||
const [baseline, setBaseline] = useState<AiConfig>(DEFAULTS);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAiConfig()
|
||||
.then((stored) => {
|
||||
if (cancelled) return;
|
||||
setConfig(stored);
|
||||
setBaseline(stored);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep defaults; Save will retry against the backend.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const set = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) =>
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
// Models available for the currently selected cloud provider.
|
||||
const providerModels = useMemo(
|
||||
() => AI_MODELS.filter((m) => m.provider === config.provider),
|
||||
[config.provider],
|
||||
);
|
||||
|
||||
const dirty =
|
||||
JSON.stringify(config) !== JSON.stringify(baseline) || apiKey.length > 0;
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const patch = {
|
||||
mode: config.mode,
|
||||
provider: config.provider,
|
||||
ollamaBaseUrl: config.ollamaBaseUrl,
|
||||
ollamaModel: config.ollamaModel,
|
||||
defaultModel: config.defaultModel,
|
||||
defaultEffort: config.defaultEffort,
|
||||
veilLevel: config.veilLevel,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
};
|
||||
const saved = await saveAiConfig(patch);
|
||||
setConfig(saved);
|
||||
setBaseline(saved);
|
||||
setApiKey("");
|
||||
notify.success(t("settings.ai.savedTitle"), t("settings.ai.savedBody"));
|
||||
} catch {
|
||||
notify.error(
|
||||
t("settings.ai.saveFailedTitle"),
|
||||
t("settings.ai.saveFailedBody"),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testAiConnection({
|
||||
mode: config.mode,
|
||||
provider: config.provider,
|
||||
ollamaBaseUrl: config.ollamaBaseUrl,
|
||||
});
|
||||
if (result.ok) notify.success(t("settings.ai.testOk"), result.message);
|
||||
else notify.error(t("settings.ai.testFailed"), result.message);
|
||||
} catch {
|
||||
notify.error(t("settings.ai.testFailed"), t("settings.ai.testError"));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const keyIsSet = config.apiKeySet[config.provider];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
description={t("settings.ai.modeDescription")}
|
||||
title={t("settings.ai.modeTitle")}
|
||||
>
|
||||
<SettingsCard className="space-y-5 p-5">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.mode")}</FieldLabel>
|
||||
<Select
|
||||
onValueChange={(value) => set("mode", value as AiMode)}
|
||||
value={config.mode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
<SelectItem value="api">{t("settings.ai.modeApi")}</SelectItem>
|
||||
<SelectItem value="local">
|
||||
{t("settings.ai.modeLocal")}
|
||||
</SelectItem>
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{config.mode === "api"
|
||||
? t("settings.ai.modeApiHint")
|
||||
: t("settings.ai.modeLocalHint")}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
{config.mode === "api" ? (
|
||||
<SettingsSection
|
||||
description={t("settings.ai.providerDescription")}
|
||||
title={t("settings.ai.providerTitle")}
|
||||
>
|
||||
<SettingsCard className="space-y-5 p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.provider")}</FieldLabel>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
set("provider", value as ApiProvider)
|
||||
}
|
||||
value={config.provider}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{PROVIDERS.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{t(`settings.ai.providers.${p}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.defaultModel")}</FieldLabel>
|
||||
<Select
|
||||
onValueChange={(value) => set("defaultModel", value as string)}
|
||||
value={config.defaultModel}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("settings.ai.selectModel")} />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{providerModels.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.apiKey")}</FieldLabel>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={
|
||||
keyIsSet
|
||||
? t("settings.ai.apiKeySet")
|
||||
: t("settings.ai.apiKeyPlaceholder")
|
||||
}
|
||||
type="password"
|
||||
value={apiKey}
|
||||
/>
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{keyIsSet ? (
|
||||
<>
|
||||
<Check className="size-3.5 text-foreground" />
|
||||
{t("settings.ai.apiKeyStored", {
|
||||
provider: t(`settings.ai.providers.${config.provider}`),
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
t("settings.ai.apiKeyHint")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.defaultEffort")}</FieldLabel>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
set("defaultEffort", value as Effort)
|
||||
}
|
||||
value={config.defaultEffort}
|
||||
>
|
||||
<SelectTrigger className="sm:w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{EFFORT_LEVELS.map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{t(`chat.input.effortOptions.${level}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
) : (
|
||||
<SettingsSection
|
||||
description={t("settings.ai.localDescription")}
|
||||
title={t("settings.ai.localTitle")}
|
||||
>
|
||||
<SettingsCard className="space-y-5 p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.ollamaBaseUrl")}</FieldLabel>
|
||||
<Input
|
||||
onChange={(event) => set("ollamaBaseUrl", event.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
value={config.ollamaBaseUrl}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.ollamaModel")}</FieldLabel>
|
||||
<Input
|
||||
onChange={(event) => set("ollamaModel", event.target.value)}
|
||||
placeholder="llama3.1"
|
||||
value={config.ollamaModel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="rounded-lg"
|
||||
disabled={testing}
|
||||
onClick={test}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{testing
|
||||
? t("settings.ai.testing")
|
||||
: t("settings.ai.testConnection")}
|
||||
</Button>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection
|
||||
description={t("settings.ai.veilDescription")}
|
||||
title={t("settings.ai.veilTitle")}
|
||||
>
|
||||
<SettingsCard className="space-y-4 p-5">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.veilLevel")}</FieldLabel>
|
||||
<Select
|
||||
onValueChange={(value) => set("veilLevel", value as VeilLevel)}
|
||||
value={config.veilLevel}
|
||||
>
|
||||
<SelectTrigger className="sm:w-64">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{VEIL_LEVELS.map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{t(`settings.ai.veilLevels.${level}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{config.mode === "local"
|
||||
? t("settings.ai.veilLocalNote")
|
||||
: t("settings.ai.veilApiNote")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
{dirty ? (
|
||||
<div className="sticky bottom-4 z-10">
|
||||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.ai.unsavedChanges")}
|
||||
</p>
|
||||
<Button disabled={saving} onClick={save} size="sm">
|
||||
{saving ? t("settings.ai.saving") : t("settings.ai.saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AIPanel } from "@/components/settings/settings-ai";
|
||||
import { SigningPanel } from "@/components/settings/settings-billing";
|
||||
import { CareTeamPanel } from "@/components/settings/settings-care-team";
|
||||
import { DevelopersPanel } from "@/components/settings/settings-developers";
|
||||
@@ -13,12 +14,16 @@ import { useActiveRole } from "@/lib/roles";
|
||||
|
||||
const TABS = [
|
||||
{ id: "profile", labelKey: "settings.tabs.profile" },
|
||||
{ id: "ai", labelKey: "settings.tabs.ai" },
|
||||
{ id: "records", labelKey: "settings.tabs.records" },
|
||||
{ id: "signing", labelKey: "settings.tabs.signing" },
|
||||
{ id: "careTeam", labelKey: "settings.tabs.careTeam" },
|
||||
{ id: "developers", labelKey: "settings.tabs.developers" },
|
||||
] as const;
|
||||
|
||||
// Per-user tabs every clinician sees; the rest are clinic-wide (admin only).
|
||||
const PERSONAL_TABS = ["profile", "ai"];
|
||||
|
||||
type Tab = (typeof TABS)[number]["id"];
|
||||
|
||||
export function SettingsView() {
|
||||
@@ -27,9 +32,11 @@ export function SettingsView() {
|
||||
const [tab, setTab] = useState<Tab>("profile");
|
||||
|
||||
// Only clinic owners/admins manage clinic-wide settings (care team, records,
|
||||
// signing, developers). Everyone else gets their own profile only.
|
||||
// signing, developers). Everyone else gets their personal tabs (profile, AI).
|
||||
const isAdmin = role === "owner" || role === "admin";
|
||||
const visibleTabs = isAdmin ? TABS : TABS.filter((item) => item.id === "profile");
|
||||
const visibleTabs = isAdmin
|
||||
? TABS
|
||||
: TABS.filter((item) => PERSONAL_TABS.includes(item.id));
|
||||
const activeTab = visibleTabs.some((item) => item.id === tab) ? tab : "profile";
|
||||
|
||||
return (
|
||||
@@ -59,6 +66,7 @@ export function SettingsView() {
|
||||
|
||||
<div className="mt-10 space-y-12">
|
||||
{activeTab === "profile" && <ProfilePanel />}
|
||||
{activeTab === "ai" && <AIPanel />}
|
||||
{activeTab === "records" && <RecordsPanel />}
|
||||
{activeTab === "signing" && <SigningPanel />}
|
||||
{activeTab === "careTeam" && <CareTeamPanel />}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { apiFetch } from "@/lib/api-client";
|
||||
import type { Effort } from "@/lib/ai-models";
|
||||
|
||||
// Mirrors backend/src/types/ai.ts. Per-user AI configuration fetched from and
|
||||
// saved to /api/ai/config. Provider API keys are write-only: they are never
|
||||
// returned, only `apiKeySet` reports which providers have a stored key.
|
||||
|
||||
export type AiMode = "api" | "local";
|
||||
export type ApiProvider = "openai" | "anthropic" | "gemini";
|
||||
export type VeilLevel = "off" | "names" | "full";
|
||||
|
||||
export type AiConfig = {
|
||||
mode: AiMode;
|
||||
provider: ApiProvider;
|
||||
ollamaBaseUrl: string;
|
||||
ollamaModel: string;
|
||||
defaultModel: string;
|
||||
defaultEffort: Effort;
|
||||
veilLevel: VeilLevel;
|
||||
apiKeySet: Record<ApiProvider, boolean>;
|
||||
};
|
||||
|
||||
export type AiConfigPatch = Partial<
|
||||
Omit<AiConfig, "apiKeySet">
|
||||
> & {
|
||||
// Plaintext key for the currently selected provider; "" clears it.
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
export async function getAiConfig(): Promise<AiConfig> {
|
||||
const res = await apiFetch<{ config: AiConfig }>("/api/ai/config");
|
||||
return res.config;
|
||||
}
|
||||
|
||||
export async function saveAiConfig(patch: AiConfigPatch): Promise<AiConfig> {
|
||||
const res = await apiFetch<{ config: AiConfig }>("/api/ai/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return res.config;
|
||||
}
|
||||
|
||||
export async function testAiConnection(input: {
|
||||
mode: AiMode;
|
||||
provider?: ApiProvider;
|
||||
ollamaBaseUrl?: string;
|
||||
}): Promise<{ ok: boolean; message: string }> {
|
||||
return apiFetch<{ ok: boolean; message: string }>("/api/ai/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
@@ -783,6 +783,7 @@
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"profile": "Profile",
|
||||
"ai": "AI",
|
||||
"records": "Records",
|
||||
"signing": "Signing",
|
||||
"careTeam": "Care team",
|
||||
@@ -998,6 +999,57 @@
|
||||
"backupDescription": "Recover your signing identity if you lose this device",
|
||||
"backupLabel": "Recovery phrase",
|
||||
"backupDesc": "Export an encrypted backup of your signing key to restore it on a new device."
|
||||
},
|
||||
"ai": {
|
||||
"modeTitle": "Inference mode",
|
||||
"modeDescription": "Choose how temetro runs the AI. A cloud API key sends data off your infrastructure; a local model keeps everything on your machine.",
|
||||
"mode": "Mode",
|
||||
"modeApi": "Cloud API key",
|
||||
"modeLocal": "Local model (Ollama)",
|
||||
"modeApiHint": "Requests go to your chosen provider. Patient identifiers are de-identified by Veil before they leave.",
|
||||
"modeLocalHint": "Requests run against a model on your own infrastructure. No patient data leaves the clinic.",
|
||||
"providerTitle": "Provider & API key",
|
||||
"providerDescription": "Your key is encrypted at rest and never shown again. You can store a key per provider and switch between them.",
|
||||
"provider": "Provider",
|
||||
"providers": {
|
||||
"openai": "OpenAI",
|
||||
"anthropic": "Anthropic",
|
||||
"gemini": "Google Gemini"
|
||||
},
|
||||
"apiKey": "API key",
|
||||
"apiKeyPlaceholder": "Paste your API key",
|
||||
"apiKeySet": "•••••••••• (key set)",
|
||||
"apiKeyHint": "The key is encrypted before storage and used only to call the provider.",
|
||||
"apiKeyStored": "A key is stored for {{provider}}.",
|
||||
"defaultModel": "Default model",
|
||||
"selectModel": "Select a model",
|
||||
"defaultEffort": "Default effort",
|
||||
"localTitle": "Local model (Ollama)",
|
||||
"localDescription": "Point temetro at your Ollama server. The model runs on your infrastructure, so patient data never leaves the clinic.",
|
||||
"ollamaBaseUrl": "Ollama base URL",
|
||||
"ollamaModel": "Model name",
|
||||
"testConnection": "Test connection",
|
||||
"testing": "Testing…",
|
||||
"testOk": "Connection succeeded",
|
||||
"testFailed": "Connection failed",
|
||||
"testError": "Could not run the test.",
|
||||
"veilTitle": "Veil — PHI safeguards",
|
||||
"veilDescription": "Veil de-identifies patient information before it is sent to an external model, then restores it in the response.",
|
||||
"veilLevel": "De-identification level",
|
||||
"veilLevels": {
|
||||
"full": "Full — names, MRNs, providers & free-text",
|
||||
"names": "Names only — direct identifiers",
|
||||
"off": "Off — send data as-is (not recommended)"
|
||||
},
|
||||
"veilApiNote": "Veil applies to every external request. Identifiers are replaced with tokens the model never sees, and re-inserted before you read the answer.",
|
||||
"veilLocalNote": "You are in local mode, so patient data never leaves your infrastructure and Veil is not needed.",
|
||||
"unsavedChanges": "You have unsaved AI settings.",
|
||||
"saveChanges": "Save changes",
|
||||
"saving": "Saving…",
|
||||
"savedTitle": "AI settings saved",
|
||||
"savedBody": "Your AI configuration has been updated.",
|
||||
"saveFailedTitle": "Could not save",
|
||||
"saveFailedBody": "Saving your AI settings failed. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user