mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +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";
|
||||
|
||||
@@ -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<Notice>(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 (
|
||||
<div
|
||||
@@ -48,10 +64,14 @@ export function AiSetupNotice() {
|
||||
>
|
||||
<TriangleAlert className="size-4 shrink-0 text-warning" />
|
||||
<p className="min-w-0 flex-1 truncate text-foreground">
|
||||
<span className="font-medium">{t("chat.setupNotice.title")}</span>
|
||||
<span className="font-medium">
|
||||
{isOff
|
||||
? t("chat.setupNotice.offTitle")
|
||||
: t("chat.setupNotice.title")}
|
||||
</span>
|
||||
<span className="text-muted-foreground max-sm:hidden">
|
||||
{" — "}
|
||||
{t("chat.setupNotice.body")}
|
||||
{isOff ? t("chat.setupNotice.offBody") : t("chat.setupNotice.body")}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
|
||||
@@ -188,7 +188,17 @@ export function ChatPanel() {
|
||||
getAiConfig()
|
||||
.then((cfg) => {
|
||||
if (cancelled) return;
|
||||
setModel(cfg.mode === "local" ? "ollama" : cfg.defaultModel);
|
||||
// Seed the model to match the configured mode. In "auto" we prefer the
|
||||
// user's cloud default when a key exists, else fall back to the local
|
||||
// sentinel (mirrors the backend's provider resolution).
|
||||
const hasApiKey = Object.values(cfg.apiKeySet).some(Boolean);
|
||||
const seeded =
|
||||
cfg.mode === "local"
|
||||
? "ollama"
|
||||
: cfg.mode === "auto" && !hasApiKey
|
||||
? "ollama"
|
||||
: cfg.defaultModel;
|
||||
setModel(seeded);
|
||||
setEffort(cfg.defaultEffort);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -687,6 +697,22 @@ export function ChatPanel() {
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
// Fallback: a structured card the client doesn't recognize (e.g. a
|
||||
// data part added or renamed on the backend). Surface a small
|
||||
// placeholder so a written-but-unhandled result is never silently
|
||||
// invisible. `data-step`/`data-source` intentionally render nothing
|
||||
// here (they're consumed above), so skip them.
|
||||
if (
|
||||
part.type.startsWith("data-") &&
|
||||
part.type !== "data-step" &&
|
||||
part.type !== "data-source"
|
||||
) {
|
||||
return (
|
||||
<Badge className="self-start" key={key} variant="outline">
|
||||
{t("chat.card.unsupported")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
|
||||
@@ -303,6 +303,8 @@ export function PatientDetail({
|
||||
<AvatarFallback>{patient.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{/* Name, status, and the overflow menu share one left-aligned row;
|
||||
Edit now lives inside the menu rather than as a separate button. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-base text-foreground">
|
||||
{patient.name}
|
||||
@@ -310,6 +312,59 @@ export function PatientDetail({
|
||||
<Badge variant={statusVariant[patient.status]}>
|
||||
{t(`patients.status.${patient.status}`)}
|
||||
</Badge>
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t("patientCard.moreActions")}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</MenuTrigger>
|
||||
<MenuPopup align="start">
|
||||
{onEdit && (
|
||||
<MenuItem onClick={onEdit}>
|
||||
<Pencil className="size-4" />
|
||||
{t("patientCard.edit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={() => printPatientSummary(patient, t)}>
|
||||
<FileDown className="size-4" />
|
||||
{t("patientCard.exportPdf")}
|
||||
</MenuItem>
|
||||
{onScribe && (
|
||||
<MenuItem onClick={onScribe}>
|
||||
<Mic className="size-4" />
|
||||
{t("scribe.recordVisit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onTransfer && (
|
||||
<MenuItem onClick={onTransfer}>
|
||||
<ArrowLeftRight className="size-4" />
|
||||
{t("patients.transfer.action")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onWalletPush && (
|
||||
<MenuItem onClick={onWalletPush}>
|
||||
<Send className="size-4" />
|
||||
{t("walletPush.action")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem onClick={onDelete} variant="destructive">
|
||||
<Trash2 className="size-4" />
|
||||
{t("patients.delete.action")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">{idLine}</span>
|
||||
{patient.alerts.length > 0 && (
|
||||
@@ -323,64 +378,6 @@ export function PatientDetail({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions — one primary (Edit) with the rest tucked into an overflow
|
||||
menu so the header stays uncluttered. */}
|
||||
<div className="flex items-center gap-2">
|
||||
{onEdit && (
|
||||
<Button onClick={onEdit} size="sm" type="button" variant="outline">
|
||||
<Pencil className="size-4" />
|
||||
{t("patientCard.edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t("patientCard.moreActions")}
|
||||
className="ms-auto"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</MenuTrigger>
|
||||
<MenuPopup align="end">
|
||||
<MenuItem onClick={() => printPatientSummary(patient, t)}>
|
||||
<FileDown className="size-4" />
|
||||
{t("patientCard.exportPdf")}
|
||||
</MenuItem>
|
||||
{onScribe && (
|
||||
<MenuItem onClick={onScribe}>
|
||||
<Mic className="size-4" />
|
||||
{t("scribe.recordVisit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onTransfer && (
|
||||
<MenuItem onClick={onTransfer}>
|
||||
<ArrowLeftRight className="size-4" />
|
||||
{t("patients.transfer.action")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onWalletPush && (
|
||||
<MenuItem onClick={onWalletPush}>
|
||||
<Send className="size-4" />
|
||||
{t("walletPush.action")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem onClick={onDelete} variant="destructive">
|
||||
<Trash2 className="size-4" />
|
||||
{t("patients.delete.action")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Section title={t("patientCard.overview")}>
|
||||
|
||||
@@ -173,29 +173,6 @@ export function PatientsView() {
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setStatusFilter((value ?? "all") as StatusFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
value={statusFilter}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("patients.filterStatus")}
|
||||
className="w-full sm:w-40"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{STATUS_FILTERS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value === "all"
|
||||
? t("patients.allStatuses")
|
||||
: t(`patients.status.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
<Button
|
||||
className="rounded-3xl"
|
||||
onClick={() => {
|
||||
@@ -233,7 +210,38 @@ export function PatientsView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardFrame className="mt-8 w-full">
|
||||
{/* Filter sits on its own row just above the table so the toolbar stays a
|
||||
clean title + search + primary actions cluster. */}
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("patients.filterLabel")}
|
||||
</span>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setStatusFilter((value ?? "all") as StatusFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
value={statusFilter}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("patients.filterStatus")}
|
||||
className="w-40"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{STATUS_FILTERS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value === "all"
|
||||
? t("patients.allStatuses")
|
||||
: t(`patients.status.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<CardFrame className="mt-4 w-full">
|
||||
<Table variant="card">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
FieldLabel,
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
SettingsFrame,
|
||||
ToggleRow,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import {
|
||||
@@ -41,7 +40,7 @@ const PROVIDERS: ApiProvider[] = ["openai", "anthropic", "gemini"];
|
||||
const VEIL_LEVELS: VeilLevel[] = ["full", "names", "off"];
|
||||
|
||||
const DEFAULTS: AiConfig = {
|
||||
mode: "local",
|
||||
mode: "auto",
|
||||
provider: "anthropic",
|
||||
ollamaBaseUrl: "http://localhost:11434",
|
||||
ollamaModel: "llama3.1",
|
||||
@@ -196,15 +195,26 @@ export function AIPanel() {
|
||||
|
||||
const keyIsSet = config.apiKeySet[config.provider];
|
||||
|
||||
// Which mode-hint copy to show under the Mode select.
|
||||
const modeHintSuffix =
|
||||
config.mode === "api"
|
||||
? "Api"
|
||||
: config.mode === "local"
|
||||
? "Local"
|
||||
: config.mode === "off"
|
||||
? "Off"
|
||||
: "Auto";
|
||||
|
||||
return (
|
||||
<>
|
||||
{policy ? (
|
||||
<SettingsSection
|
||||
<SettingsFrame
|
||||
bodyClassName="space-y-3"
|
||||
description={t("settings.ai.availability.description")}
|
||||
title={t("settings.ai.availability.title")}
|
||||
>
|
||||
{isAdmin ? (
|
||||
<div className="space-y-3">
|
||||
<>
|
||||
<ToggleRow
|
||||
checked={policy.aiEnabled}
|
||||
description={t("settings.ai.availability.enabledHint")}
|
||||
@@ -240,58 +250,67 @@ export function AIPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<SettingsCard className="px-4 py-3.5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{policy.aiEnabled
|
||||
? policy.disabledForEmployees
|
||||
? t("settings.ai.availability.readonlyEmployeesOnly")
|
||||
: t("settings.ai.availability.readonlyEnabled")
|
||||
: t("settings.ai.availability.readonlyDisabled")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{policy.aiEnabled
|
||||
? policy.disabledForEmployees
|
||||
? t("settings.ai.availability.readonlyEmployeesOnly")
|
||||
: t("settings.ai.availability.readonlyEnabled")
|
||||
: t("settings.ai.availability.readonlyDisabled")}
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</SettingsFrame>
|
||||
) : null}
|
||||
|
||||
<SettingsSection
|
||||
<SettingsFrame
|
||||
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>
|
||||
<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="auto">
|
||||
{t("settings.ai.modeAuto")}
|
||||
</SelectItem>
|
||||
<SelectItem value="api">{t("settings.ai.modeApi")}</SelectItem>
|
||||
<SelectItem value="local">
|
||||
{t("settings.ai.modeLocal")}
|
||||
</SelectItem>
|
||||
<SelectItem value="off">{t("settings.ai.modeOff")}</SelectItem>
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`settings.ai.mode${modeHintSuffix}Hint`)}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsFrame>
|
||||
|
||||
{config.mode === "api" ? (
|
||||
<SettingsSection
|
||||
{config.mode === "off" ? (
|
||||
<SettingsFrame
|
||||
description={t("settings.ai.offDescription")}
|
||||
title={t("settings.ai.offTitle")}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.ai.offNote")}
|
||||
</p>
|
||||
</SettingsFrame>
|
||||
) : config.mode !== "local" ? (
|
||||
// API and Automatic both configure a cloud provider (Automatic falls
|
||||
// back to local Ollama when no key is set).
|
||||
<SettingsFrame
|
||||
bodyClassName="space-y-5"
|
||||
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="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.provider")}</FieldLabel>
|
||||
<Select
|
||||
@@ -377,15 +396,14 @@ export function AIPanel() {
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</SettingsFrame>
|
||||
) : (
|
||||
<SettingsSection
|
||||
<SettingsFrame
|
||||
bodyClassName="space-y-5"
|
||||
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="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.ollamaBaseUrl")}</FieldLabel>
|
||||
<Input
|
||||
@@ -414,40 +432,40 @@ export function AIPanel() {
|
||||
? t("settings.ai.testing")
|
||||
: t("settings.ai.testConnection")}
|
||||
</Button>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</SettingsFrame>
|
||||
)}
|
||||
|
||||
<SettingsSection
|
||||
description={t("settings.ai.veilDescription")}
|
||||
title={t("settings.ai.veilTitle")}
|
||||
>
|
||||
<SettingsCard className="space-y-4 p-5">
|
||||
{config.mode !== "off" ? (
|
||||
<SettingsFrame
|
||||
bodyClassName="space-y-4"
|
||||
description={t("settings.ai.veilDescription")}
|
||||
title={t("settings.ai.veilTitle")}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</SettingsFrame>
|
||||
) : null}
|
||||
|
||||
{dirty ? (
|
||||
<div className="sticky bottom-4 z-10">
|
||||
|
||||
@@ -6,8 +6,51 @@ import { Check, Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CardFrame,
|
||||
CardFrameAction,
|
||||
CardFrameDescription,
|
||||
CardFrameHeader,
|
||||
CardFrameTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
// A settings section rendered inside the COSS "frame" surface: a titled header
|
||||
// (with optional description + action) sitting above a padded body. Replaces the
|
||||
// old plain-`div` SettingsCard pattern so every settings panel shares one framed
|
||||
// look. Use `bodyClassName` to control the body layout (spacing/grid).
|
||||
export function SettingsFrame({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
bodyClassName,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<CardFrame className={className}>
|
||||
<CardFrameHeader className="border-b border-border/60">
|
||||
<CardFrameTitle className="text-base">{title}</CardFrameTitle>
|
||||
{description ? (
|
||||
<CardFrameDescription>{description}</CardFrameDescription>
|
||||
) : null}
|
||||
{action ? <CardFrameAction>{action}</CardFrameAction> : null}
|
||||
</CardFrameHeader>
|
||||
<div className={cn("p-5", bodyClassName)}>{children}</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// Back-compat wrapper: existing panels compose with SettingsSection, which now
|
||||
// renders through the COSS frame surface so the whole settings page shares one
|
||||
// framed look. The body keeps vertical spacing between stacked children.
|
||||
export function SettingsSection({
|
||||
title,
|
||||
description,
|
||||
@@ -20,18 +63,14 @@ export function SettingsSection({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
|
||||
{description ? (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
<SettingsFrame
|
||||
action={action}
|
||||
bodyClassName="space-y-4"
|
||||
description={description}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
</SettingsFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import type { Effort } from "@/lib/ai-models";
|
||||
// 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";
|
||||
// "auto" auto-picks a cloud provider when a key is set, else falls back to local
|
||||
// Ollama; "off" disables the assistant entirely.
|
||||
export type AiMode = "api" | "local" | "auto" | "off";
|
||||
export type ApiProvider = "openai" | "anthropic" | "gemini";
|
||||
export type VeilLevel = "off" | "names" | "full";
|
||||
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"failedTitle": "تعذّر حذف المريض",
|
||||
"failedBody": "يرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"filterLabel": "تصفية",
|
||||
"filterStatus": "تصفية حسب الحالة",
|
||||
"allStatuses": "كل الحالات",
|
||||
"moreActions": "إجراءات إضافية"
|
||||
@@ -1149,7 +1150,12 @@
|
||||
"title": "اربط نموذج ذكاء اصطناعي للبدء",
|
||||
"body": "لم يتم إعداد أي مزوّد ذكاء اصطناعي بعد. أضف مفتاح API أو وجّه temetro إلى نموذج Ollama محلي حتى يتمكّن المساعد من الرد.",
|
||||
"action": "فتح إعدادات الذكاء الاصطناعي",
|
||||
"dismiss": "تجاهل"
|
||||
"dismiss": "تجاهل",
|
||||
"offTitle": "تم إيقاف مساعد الذكاء الاصطناعي",
|
||||
"offBody": "الذكاء الاصطناعي مُعطّل في الإعدادات ← الذكاء الاصطناعي. فعّله لاستخدام المساعد."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "بطاقة غير مدعومة"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "اسأل أي شيء، أو اكتب /patient 10293",
|
||||
@@ -2111,6 +2117,13 @@
|
||||
"modeLocal": "نموذج محلي (Ollama)",
|
||||
"modeApiHint": "تذهب الطلبات إلى مزوّدك المختار. تُزال هوية معرّفات المريض بواسطة Veil قبل مغادرتها.",
|
||||
"modeLocalHint": "تعمل الطلبات مقابل نموذج على بنيتك التحتية الخاصة. لا تغادر بيانات المريض العيادة.",
|
||||
"modeAuto": "تلقائي",
|
||||
"modeOff": "إيقاف",
|
||||
"modeAutoHint": "يستخدم مفتاح واجهة برمجة تطبيقات سحابي عند توفره، وإلا يعود إلى نموذج Ollama المحلي.",
|
||||
"modeOffHint": "مساعد الذكاء الاصطناعي مُعطّل. لا تُرسل أي طلبات.",
|
||||
"offTitle": "المساعد مُوقف",
|
||||
"offDescription": "مساعد الذكاء الاصطناعي مُوقف لحسابك.",
|
||||
"offNote": "اختر تلقائي أو مفتاح واجهة برمجة سحابي أو نموذج محلي أعلاه لتفعيل المساعد.",
|
||||
"providerTitle": "المزوّد ومفتاح API",
|
||||
"providerDescription": "يُشفَّر مفتاحك عند التخزين ولا يُعرض مرة أخرى أبدًا. يمكنك تخزين مفتاح لكل مزوّد والتبديل بينها.",
|
||||
"provider": "المزوّد",
|
||||
|
||||
@@ -364,6 +364,7 @@
|
||||
"failedTitle": "Patient konnte nicht gelöscht werden",
|
||||
"failedBody": "Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"filterLabel": "Filter",
|
||||
"filterStatus": "Nach Status filtern",
|
||||
"allStatuses": "Alle Status",
|
||||
"moreActions": "Weitere Aktionen"
|
||||
@@ -1133,7 +1134,12 @@
|
||||
"title": "Verbinden Sie ein KI-Modell, um zu beginnen",
|
||||
"body": "Es ist noch kein KI-Anbieter eingerichtet. Fügen Sie einen API-Schlüssel hinzu oder verweisen Sie temetro auf ein lokales Ollama-Modell, damit der Assistent antworten kann.",
|
||||
"action": "KI-Einstellungen öffnen",
|
||||
"dismiss": "Ausblenden"
|
||||
"dismiss": "Ausblenden",
|
||||
"offTitle": "Der KI-Assistent ist ausgeschaltet",
|
||||
"offBody": "KI ist unter Einstellungen → KI auf Aus gestellt. Schalten Sie sie ein, um den Assistenten zu nutzen."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "Nicht unterstützte Karte"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Fragen Sie etwas oder tippen Sie /patient 10293",
|
||||
@@ -2091,6 +2097,13 @@
|
||||
"modeLocal": "Lokales Modell (Ollama)",
|
||||
"modeApiHint": "Anfragen gehen an Ihren gewählten Anbieter. Patientenkennungen werden von Veil anonymisiert, bevor sie das System verlassen.",
|
||||
"modeLocalHint": "Anfragen laufen gegen ein Modell auf Ihrer eigenen Infrastruktur. Keine Patientendaten verlassen die Klinik.",
|
||||
"modeAuto": "Automatisch",
|
||||
"modeOff": "Aus",
|
||||
"modeAutoHint": "Verwendet einen Cloud-API-Schlüssel, sofern vorhanden, andernfalls Ihr lokales Ollama-Modell.",
|
||||
"modeOffHint": "Der KI-Assistent ist deaktiviert. Es werden keine Anfragen gesendet.",
|
||||
"offTitle": "Assistent aus",
|
||||
"offDescription": "Der KI-Assistent ist für Ihr Konto ausgeschaltet.",
|
||||
"offNote": "Wählen Sie oben Automatisch, Cloud-API-Schlüssel oder Lokales Modell, um den Assistenten zu aktivieren.",
|
||||
"providerTitle": "Anbieter & API-Schlüssel",
|
||||
"providerDescription": "Ihr Schlüssel wird verschlüsselt gespeichert und nie wieder angezeigt. Sie können einen Schlüssel pro Anbieter speichern und zwischen ihnen wechseln.",
|
||||
"provider": "Anbieter",
|
||||
|
||||
@@ -364,6 +364,7 @@
|
||||
"failedTitle": "Couldn't delete patient",
|
||||
"failedBody": "Please try again."
|
||||
},
|
||||
"filterLabel": "Filter",
|
||||
"filterStatus": "Filter by status",
|
||||
"allStatuses": "All statuses",
|
||||
"moreActions": "More actions"
|
||||
@@ -1133,7 +1134,12 @@
|
||||
"title": "Connect an AI model to get started",
|
||||
"body": "No AI provider is set up yet. Add an API key or point temetro at a local Ollama model so the assistant can answer.",
|
||||
"action": "Open AI settings",
|
||||
"dismiss": "Dismiss"
|
||||
"dismiss": "Dismiss",
|
||||
"offTitle": "The AI assistant is turned off",
|
||||
"offBody": "AI is set to Off in Settings → AI. Turn it on to use the assistant."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "Unsupported card"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Ask anything, or type /patient 10293",
|
||||
@@ -2089,8 +2095,15 @@
|
||||
"mode": "Mode",
|
||||
"modeApi": "Cloud API key",
|
||||
"modeLocal": "Local model (Ollama)",
|
||||
"modeAuto": "Automatic",
|
||||
"modeOff": "Off",
|
||||
"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.",
|
||||
"modeAutoHint": "Uses a cloud API key when one is set, otherwise falls back to your local Ollama model.",
|
||||
"modeOffHint": "The AI assistant is disabled. No requests are sent.",
|
||||
"offTitle": "Assistant off",
|
||||
"offDescription": "The AI assistant is turned off for your account.",
|
||||
"offNote": "Choose Automatic, Cloud API key, or Local model above to enable the assistant.",
|
||||
"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",
|
||||
|
||||
@@ -364,6 +364,7 @@
|
||||
"failedTitle": "Impossible de supprimer le patient",
|
||||
"failedBody": "Veuillez réessayer."
|
||||
},
|
||||
"filterLabel": "Filtre",
|
||||
"filterStatus": "Filtrer par statut",
|
||||
"allStatuses": "Tous les statuts",
|
||||
"moreActions": "Plus d’actions"
|
||||
@@ -1133,7 +1134,12 @@
|
||||
"title": "Connectez un modèle d'IA pour commencer",
|
||||
"body": "Aucun fournisseur d'IA n'est encore configuré. Ajoutez une clé API ou pointez temetro vers un modèle Ollama local pour que l'assistant puisse répondre.",
|
||||
"action": "Ouvrir les paramètres d'IA",
|
||||
"dismiss": "Ignorer"
|
||||
"dismiss": "Ignorer",
|
||||
"offTitle": "L'assistant IA est désactivé",
|
||||
"offBody": "L'IA est réglée sur Désactivée dans Paramètres → IA. Activez-la pour utiliser l'assistant."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "Carte non prise en charge"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Demandez n'importe quoi, ou tapez /patient 10293",
|
||||
@@ -2091,6 +2097,13 @@
|
||||
"modeLocal": "Modèle local (Ollama)",
|
||||
"modeApiHint": "Les requêtes vont vers le fournisseur que vous avez choisi. Les identifiants des patients sont dé-identifiés par Veil avant leur départ.",
|
||||
"modeLocalHint": "Les requêtes s'exécutent sur un modèle de votre propre infrastructure. Aucune donnée patient ne quitte la clinique.",
|
||||
"modeAuto": "Automatique",
|
||||
"modeOff": "Désactivé",
|
||||
"modeAutoHint": "Utilise une clé API cloud si elle est définie, sinon revient à votre modèle Ollama local.",
|
||||
"modeOffHint": "L'assistant IA est désactivé. Aucune requête n'est envoyée.",
|
||||
"offTitle": "Assistant désactivé",
|
||||
"offDescription": "L'assistant IA est désactivé pour votre compte.",
|
||||
"offNote": "Choisissez Automatique, Clé API cloud ou Modèle local ci-dessus pour activer l'assistant.",
|
||||
"providerTitle": "Fournisseur & clé API",
|
||||
"providerDescription": "Votre clé est chiffrée au repos et n'est plus jamais affichée. Vous pouvez stocker une clé par fournisseur et basculer entre elles.",
|
||||
"provider": "Fournisseur",
|
||||
|
||||
@@ -364,6 +364,7 @@
|
||||
"failedTitle": "Bukaanka lama tirtiri karin",
|
||||
"failedBody": "Fadlan isku day mar kale."
|
||||
},
|
||||
"filterLabel": "Kala sooc",
|
||||
"filterStatus": "Ku kala sooc xaaladda",
|
||||
"allStatuses": "Dhammaan xaaladaha",
|
||||
"moreActions": "Ficillo dheeraad ah"
|
||||
@@ -1133,7 +1134,12 @@
|
||||
"title": "Ku xir moodeel AI si aad u bilowdo",
|
||||
"body": "Weli bixiye AI lama dejin. Ku dar furaha API ama ku tilmaam temetro moodeel Ollama oo maxalli ah si kaaliyuhu u jawaabo.",
|
||||
"action": "Fur dejinta AI",
|
||||
"dismiss": "Rid"
|
||||
"dismiss": "Rid",
|
||||
"offTitle": "Kaaliyaha AI waa la damiyay",
|
||||
"offBody": "AI waxaa loo dejiyay Damin gudaha Settings → AI. Shid si aad u isticmaasho kaaliyaha."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "Kaadh aan la taageerin"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Waydii wax kasta, ama qor /patient 10293",
|
||||
@@ -2091,6 +2097,13 @@
|
||||
"modeLocal": "Moodeel maxalli ah (Ollama)",
|
||||
"modeApiHint": "Codsiyada waxay u socdaan bixiyahaaga aad dooratay. Aqoonsiyada bukaanka waxaa qariya Veil ka hor inta aysan bixin.",
|
||||
"modeLocalHint": "Codsiyada waxay ku socdaan moodeel kaabayaashaada ah. Xog bukaan kama baxdo rugta.",
|
||||
"modeAuto": "Toos ah",
|
||||
"modeOff": "Damin",
|
||||
"modeAutoHint": "Wuxuu isticmaalaa furaha API-ga daruuraha marka la dejiyo, haddii kale wuxuu ku noqdaa moodelkaaga Ollama ee gudaha.",
|
||||
"modeOffHint": "Kaaliyaha AI waa la damiyay. Wax codsi ah lama dirayo.",
|
||||
"offTitle": "Kaaliyuhu waa damsan yahay",
|
||||
"offDescription": "Kaaliyaha AI waa laga damiyay akoonkaaga.",
|
||||
"offNote": "Dooro Toos ah, Furaha API daruuraha, ama Moodel Gudaha kor si aad u shidid kaaliyaha.",
|
||||
"providerTitle": "Bixiye & furaha API",
|
||||
"providerDescription": "Furahaaga waa la shifraa marka la kaydiyo mana dib loo muujiyo. Waxaad kaydin kartaa fur bixiye kasta oo aad kala beddeli kartaa.",
|
||||
"provider": "Bixiye",
|
||||
|
||||
Reference in New Issue
Block a user