From b299501ab2705dfa7675b9a028e11bb2ada3b542 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Mon, 13 Jul 2026 20:22:36 +0300 Subject: [PATCH] frontend+backend: patients/settings UI, AI Auto/Off modes, wallet doc push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - patients: move status filter to its own "Filter" row above the table - patient sheet: name + ⋯ menu on one left row; Edit moved into the ⋯ menu - settings: redesign sections with the COSS CardFrame surface (SettingsFrame) - AI: add Automatic (auto-pick provider) and Off modes; default to Automatic so a fresh install shows the setup banner until a provider is configured - AI: fix the setup banner never showing (defaulted Ollama URL counted as configured); add an "AI off" notice; add a fallback render for unknown chat data parts so cards never silently vanish - backend: include patient attachment metadata in the wallet record-update bundle so pushed documents reach the wallet - i18n: add mode/off/card keys across all five locales Co-Authored-By: Claude Opus 4.8 --- backend/src/lib/ai-validation.ts | 2 +- backend/src/routes/chat.ts | 6 + backend/src/services/ai/config.ts | 4 +- backend/src/services/ai/provider.ts | 32 ++- backend/src/services/wallet-updates.ts | 17 +- backend/src/types/ai.ts | 8 +- frontend/components/chat/ai-setup-notice.tsx | 38 +++- frontend/components/chat/chat-panel.tsx | 28 ++- .../components/patients/patient-detail.tsx | 113 ++++++----- .../components/patients/patients-view.tsx | 56 +++--- frontend/components/settings/settings-ai.tsx | 182 ++++++++++-------- .../components/settings/settings-parts.tsx | 61 ++++-- frontend/lib/ai-settings.ts | 4 +- frontend/lib/i18n/locales/ar/translation.json | 15 +- frontend/lib/i18n/locales/de/translation.json | 15 +- frontend/lib/i18n/locales/en/translation.json | 15 +- frontend/lib/i18n/locales/fr/translation.json | 15 +- frontend/lib/i18n/locales/so/translation.json | 15 +- 18 files changed, 421 insertions(+), 205 deletions(-) diff --git a/backend/src/lib/ai-validation.ts b/backend/src/lib/ai-validation.ts index 0f3b222..5ae1367 100644 --- a/backend/src/lib/ai-validation.ts +++ b/backend/src/lib/ai-validation.ts @@ -5,7 +5,7 @@ import { z } from "zod"; // is the plaintext key for the *currently selected* provider — it is encrypted // before storage and never echoed back. export const aiConfigInputSchema = z.object({ - mode: z.enum(["api", "local"]).optional(), + mode: z.enum(["api", "local", "auto", "off"]).optional(), provider: z.enum(["openai", "anthropic", "gemini"]).optional(), ollamaBaseUrl: z.string().url().optional(), ollamaModel: z.string().min(1).max(120).optional(), diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 19c5177..553fcda 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -208,6 +208,12 @@ chatRouter.post("/", async (req, res, next) => { } const settings = await getAiSettings(req.user!.id); + if (settings.mode === "off") { + throw new HttpError( + 400, + "The AI assistant is turned off. Turn it on in Settings → AI.", + ); + } const modelId = requestedModel || settings.defaultModel; const resolved = resolveModel(settings, modelId); const veil = createVeil(settings.veilLevel, resolved.isExternal); diff --git a/backend/src/services/ai/config.ts b/backend/src/services/ai/config.ts index 90db22f..9a0e843 100644 --- a/backend/src/services/ai/config.ts +++ b/backend/src/services/ai/config.ts @@ -13,7 +13,9 @@ import { type AiSettingsRow = typeof userAiSettings.$inferSelect; const DEFAULTS: Omit = { - mode: "local", + // Default to auto: use a cloud key if the user adds one, else local Ollama. + // A fresh user with nothing configured then sees the setup banner. + mode: "auto", provider: "anthropic", ollamaBaseUrl: DEFAULT_OLLAMA_BASE_URL, ollamaModel: "llama3.1", diff --git a/backend/src/services/ai/provider.ts b/backend/src/services/ai/provider.ts index eda2e2a..61b8915 100644 --- a/backend/src/services/ai/provider.ts +++ b/backend/src/services/ai/provider.ts @@ -69,8 +69,30 @@ export function resolveModel( settings: AiSettingsRow, requestedModelId: string, ): ResolvedModel { - // Local mode (or the local sentinel) → Ollama's OpenAI-compatible endpoint. - if (settings.mode === "local" || requestedModelId === OLLAMA_SENTINEL) { + // Off → the assistant is disabled. Guard here in case a request slips past the + // route-level short-circuit. + if (settings.mode === "off") { + throw new HttpError( + 400, + "The AI assistant is turned off. Turn it on in Settings → AI.", + ); + } + + const requested = providerForModel(requestedModelId); + // In api/auto mode, find a cloud provider that actually has a key. Auto with + // no key (and api mode's remaining fall-through) drops to local Ollama below. + const provider = + settings.mode === "api" || settings.mode === "auto" + ? chooseProvider(settings, requested) + : null; + + // Local when: explicit local mode, the local sentinel was picked, or auto with + // no configured cloud key. → Ollama's OpenAI-compatible endpoint. + if ( + settings.mode === "local" || + requestedModelId === OLLAMA_SENTINEL || + (settings.mode === "auto" && !provider) + ) { const ollama = createOpenAICompatible({ name: "ollama", baseURL: `${settings.ollamaBaseUrl.replace(/\/$/, "")}/v1`, @@ -82,11 +104,7 @@ export function resolveModel( }; } - // API mode. Pick a provider that actually has a key, falling back gracefully - // so a configured key (e.g. Gemini) is used even if the picked model belongs - // to a different, unconfigured provider. - const requested = providerForModel(requestedModelId); - const provider = chooseProvider(settings, requested); + // API mode with a picked cloud model but no matching/any key. if (!provider) { throw new HttpError( 400, diff --git a/backend/src/services/wallet-updates.ts b/backend/src/services/wallet-updates.ts index eba4820..81cb0d2 100644 --- a/backend/src/services/wallet-updates.ts +++ b/backend/src/services/wallet-updates.ts @@ -15,6 +15,7 @@ import { } from "../lib/wallet-crypto.js"; import { ed25519PubToX25519Hex } from "../lib/wallet-x25519.js"; import { listAppointments } from "./appointments.js"; +import { listAttachments } from "./attachments.js"; import { listInvoices } from "./invoices.js"; import { getPatient } from "./patients.js"; import { signWithClinicKey } from "./signing.js"; @@ -122,19 +123,29 @@ export async function createRecordUpdate( // Appointments and invoices live in their own tables (not on the Patient // snapshot), so pull the ones for this patient and ship them alongside — the // wallet has no other way to see them and they'd otherwise silently vanish. - const [orgAppointments, orgInvoices] = await Promise.all([ + // Attachments (files/documents) are shipped as metadata so the wallet can list + // them and show a count; the bytes stay on the clinic for now. + const [orgAppointments, orgInvoices, attachmentRows] = await Promise.all([ listAppointments(orgId), listInvoices(orgId), + listAttachments(orgId, fileNumber), ]); const appointments = orgAppointments.filter( (a) => a.fileNumber === fileNumber, ); const invoices = orgInvoices.filter((i) => i.fileNumber === fileNumber); + const documents = attachmentRows.map((a) => ({ + id: a.id, + filename: a.filename, + mimeType: a.mimeType, + sizeBytes: a.sizeBytes, + createdAt: a.createdAt, + })); // The wallet opens this, verifies the signature over the same bytes, then - // replaces its on-device record with `patient` (+ appointments/invoices). + // replaces its on-device record with `patient` (+ appointments/invoices/documents). const bundle = utf8ToBytes( - JSON.stringify({ patient, appointments, invoices, changes }), + JSON.stringify({ patient, appointments, invoices, documents, changes }), ); const { signature, publicKey } = await signWithClinicKey(orgId, bundle); const x25519Hex = ed25519PubToX25519Hex(decodeWalletNumber(walletNumber)); diff --git a/backend/src/types/ai.ts b/backend/src/types/ai.ts index 281f7cd..8c7b486 100644 --- a/backend/src/types/ai.ts +++ b/backend/src/types/ai.ts @@ -2,8 +2,12 @@ // AI panel. The chat agent reads these to decide which provider/model to call // and how strict the Veil de-identification safeguard should be. -// Two inference modes: a user-provided cloud API key, or a local Ollama model. -export type AiMode = "api" | "local"; +// Inference modes: +// api — a user-provided cloud API key +// local — a local Ollama model +// auto — auto-pick: use a cloud key when one is set, else fall back to local +// off — the assistant is disabled +export type AiMode = "api" | "local" | "auto" | "off"; // The three supported cloud providers for API-key mode. export type ApiProvider = "openai" | "anthropic" | "gemini"; diff --git a/frontend/components/chat/ai-setup-notice.tsx b/frontend/components/chat/ai-setup-notice.tsx index a4e7d41..567be55 100644 --- a/frontend/components/chat/ai-setup-notice.tsx +++ b/frontend/components/chat/ai-setup-notice.tsx @@ -14,9 +14,13 @@ import { getAiConfig } from "@/lib/ai-settings"; // message just fails silently, so the banner spells out the missing setup and // links straight to AI settings. Rendered in both the empty and active chat // states so a user mid-conversation with no provider still sees why replies fail. +// Which advisory (if any) to show: the setup nudge (no provider wired), or a +// notice that the assistant has been switched off. +type Notice = "setup" | "off" | null; + export function AiSetupNotice() { const { t } = useTranslation(); - const [needsSetup, setNeedsSetup] = useState(false); + const [notice, setNotice] = useState(null); const [dismissed, setDismissed] = useState(false); useEffect(() => { @@ -24,22 +28,34 @@ export function AiSetupNotice() { getAiConfig() .then((cfg) => { if (!active) return; - // Configured = an API key for any provider, or a local Ollama endpoint. + if (cfg.mode === "off") { + setNotice("off"); + return; + } const hasApiKey = Object.values(cfg.apiKeySet).some(Boolean); - const hasLocal = - cfg.mode === "local" && cfg.ollamaBaseUrl.trim().length > 0; - setNeedsSetup(!(hasApiKey || hasLocal)); + // Whether the assistant is actually wired up for the chosen mode: + // - api / auto → needs a cloud API key (auto silently falls back to + // local, but with no key we nudge the user to finish + // setup rather than depend on an unverified Ollama). + // - local → needs a non-empty Ollama endpoint (opted in). + const configured = + cfg.mode === "local" + ? cfg.ollamaBaseUrl.trim().length > 0 + : hasApiKey; + setNotice(configured ? null : "setup"); }) .catch(() => { // If we can't read the config, don't nag — the chat still works. - if (active) setNeedsSetup(false); + if (active) setNotice(null); }); return () => { active = false; }; }, []); - if (!needsSetup || dismissed) return null; + if (!notice || dismissed) return null; + + const isOff = notice === "off"; return (

- {t("chat.setupNotice.title")} + + {isOff + ? t("chat.setupNotice.offTitle") + : t("chat.setupNotice.title")} + {" — "} - {t("chat.setupNotice.body")} + {isOff ? t("chat.setupNotice.offBody") : t("chat.setupNotice.body")}

- {/* Actions — one primary (Edit) with the rest tucked into an overflow - menu so the header stays uncluttered. */} -
- {onEdit && ( - - )} - - - } - > - - - - printPatientSummary(patient, t)}> - - {t("patientCard.exportPdf")} - - {onScribe && ( - - - {t("scribe.recordVisit")} - - )} - {onTransfer && ( - - - {t("patients.transfer.action")} - - )} - {onWalletPush && ( - - - {t("walletPush.action")} - - )} - {onDelete && ( - <> - - - - {t("patients.delete.action")} - - - )} - - -
diff --git a/frontend/components/patients/patients-view.tsx b/frontend/components/patients/patients-view.tsx index a053664..fe247d8 100644 --- a/frontend/components/patients/patients-view.tsx +++ b/frontend/components/patients/patients-view.tsx @@ -173,29 +173,6 @@ export function PatientsView() { value={query} /> -