frontend+backend: patients/settings UI, AI Auto/Off modes, wallet doc push

- 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 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-07-13 20:22:36 +03:00
parent 17209a2cf1
commit b299501ab2
18 changed files with 421 additions and 205 deletions
+1 -1
View File
@@ -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(),
+6
View File
@@ -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);
+3 -1
View File
@@ -13,7 +13,9 @@ import {
type AiSettingsRow = typeof userAiSettings.$inferSelect;
const DEFAULTS: Omit<AiSettingsRow, "userId" | "updatedAt"> = {
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",
+25 -7
View File
@@ -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,
+14 -3
View File
@@ -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));
+6 -2
View File
@@ -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";