From ff37b555b0c9f209852acc955f4645b2e2fb5571 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sat, 13 Jun 2026 18:38:38 +0300 Subject: [PATCH] =?UTF-8?q?frontend:=20Settings=20=E2=86=92=20AI=20panel?= =?UTF-8?q?=20(modes,=20providers,=20Veil)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/components/settings/settings-ai.tsx | 342 ++++++++++++++++++ .../components/settings/settings-view.tsx | 12 +- frontend/lib/ai-settings.ts | 52 +++ frontend/lib/i18n/locales/en/translation.json | 52 +++ 4 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 frontend/components/settings/settings-ai.tsx create mode 100644 frontend/lib/ai-settings.ts diff --git a/frontend/components/settings/settings-ai.tsx b/frontend/components/settings/settings-ai.tsx new file mode 100644 index 0000000..420347d --- /dev/null +++ b/frontend/components/settings/settings-ai.tsx @@ -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(DEFAULTS); + const [baseline, setBaseline] = useState(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 = (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 ( + <> + + +
+ {t("settings.ai.mode")} + +

+ {config.mode === "api" + ? t("settings.ai.modeApiHint") + : t("settings.ai.modeLocalHint")} +

+
+
+
+ + {config.mode === "api" ? ( + + +
+
+ {t("settings.ai.provider")} + +
+
+ {t("settings.ai.defaultModel")} + +
+
+ +
+ {t("settings.ai.apiKey")} + setApiKey(event.target.value)} + placeholder={ + keyIsSet + ? t("settings.ai.apiKeySet") + : t("settings.ai.apiKeyPlaceholder") + } + type="password" + value={apiKey} + /> +

+ {keyIsSet ? ( + <> + + {t("settings.ai.apiKeyStored", { + provider: t(`settings.ai.providers.${config.provider}`), + })} + + ) : ( + t("settings.ai.apiKeyHint") + )} +

+
+ +
+ {t("settings.ai.defaultEffort")} + +
+
+
+ ) : ( + + +
+
+ {t("settings.ai.ollamaBaseUrl")} + set("ollamaBaseUrl", event.target.value)} + placeholder="http://localhost:11434" + value={config.ollamaBaseUrl} + /> +
+
+ {t("settings.ai.ollamaModel")} + set("ollamaModel", event.target.value)} + placeholder="llama3.1" + value={config.ollamaModel} + /> +
+
+ +
+
+ )} + + + +
+ {t("settings.ai.veilLevel")} + +
+

+ {config.mode === "local" + ? t("settings.ai.veilLocalNote") + : t("settings.ai.veilApiNote")} +

+
+
+ + {dirty ? ( +
+
+

+ {t("settings.ai.unsavedChanges")} +

+ +
+
+ ) : null} + + ); +} diff --git a/frontend/components/settings/settings-view.tsx b/frontend/components/settings/settings-view.tsx index 39252a9..16dffe3 100644 --- a/frontend/components/settings/settings-view.tsx +++ b/frontend/components/settings/settings-view.tsx @@ -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("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() {
{activeTab === "profile" && } + {activeTab === "ai" && } {activeTab === "records" && } {activeTab === "signing" && } {activeTab === "careTeam" && } diff --git a/frontend/lib/ai-settings.ts b/frontend/lib/ai-settings.ts new file mode 100644 index 0000000..9a26389 --- /dev/null +++ b/frontend/lib/ai-settings.ts @@ -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; +}; + +export type AiConfigPatch = Partial< + Omit +> & { + // Plaintext key for the currently selected provider; "" clears it. + apiKey?: string; +}; + +export async function getAiConfig(): Promise { + const res = await apiFetch<{ config: AiConfig }>("/api/ai/config"); + return res.config; +} + +export async function saveAiConfig(patch: AiConfigPatch): Promise { + 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), + }); +} diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index ca92aa4..27b1b3c 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -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." } } }