mirror of
https://github.com/temetro/temetro.git
synced 2026-08-09 10:09:39 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user