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
+29 -9
View File
@@ -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
+27 -1
View File
@@ -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;
})}