feat: clinic-wide AI (analytics/earnings/inventory + invoice-from-file),

real Live card, persistent chat history

Analytics & earnings:
- Analytics now carries real money computed from invoices (billed/paid/
  outstanding + by-month); new Earnings section on the Analysis page drawn with
  the project's Bklit chart components (shared EarningsChart).

AI agent reaches the whole clinic:
- new read tools getClinicInfo / getAnalytics / listInventory render clinic,
  analytics (with a Bklit earnings chart) and inventory cards in chat
- proposeInvoice turns an uploaded purchase/medication list into an invoice
  (new "invoice" action-preview kind → createInvoice); invoices/appointments
  auto-create/link a patient (ensurePatient) so they hit the Patients page

Live card:
- plots real data — patients checked in today — via GET /api/analytics/live
  (polled); value pill clamped and margins widened so nothing spills the card

Persistent AI chat history (Claude-style):
- ai_chat_threads + ai_chat_messages (migration 0016); per-user, org-scoped
  thread CRUD under /api/chat/threads
- chat panel owns a thread id, loads /?thread=<id>, and auto-saves after each
  exchange; sidebar lists past chats (open/delete), "New chat" starts fresh

Verified with backend typecheck + frontend tsc + next build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-14 22:25:15 +03:00
parent b946dc9226
commit 3aa699aefe
27 changed files with 4274 additions and 61 deletions
+91 -1
View File
@@ -43,8 +43,11 @@ import {
ToolOutput,
} from "@/components/ai-elements/tool";
import { ActionPreviewCard } from "@/components/chat/action-preview-card";
import { AnalyticsCard } from "@/components/chat/analytics-card";
import { BatchActionPreviewCard } from "@/components/chat/batch-action-preview-card";
import { ChatInput } from "@/components/chat/chat-input";
import { ClinicCard } from "@/components/chat/clinic-card";
import { InventoryListCard } from "@/components/chat/inventory-list-card";
import { ImportPreviewCard } from "@/components/chat/import-preview-card";
import { LabChartCard } from "@/components/chat/lab-chart-card";
import { PatientResult } from "@/components/chat/patient-cards";
@@ -67,6 +70,11 @@ import {
getModel,
} from "@/lib/ai-models";
import type { ActionPreviewData, TemetroUIMessage } from "@/lib/ai-chat";
import {
getThread,
notifyThreadsChanged,
saveThread,
} from "@/lib/ai-chat-history";
import { getAiConfig } from "@/lib/ai-settings";
import { API_BASE_URL } from "@/lib/api-client";
import { getPatient } from "@/lib/patients";
@@ -90,6 +98,15 @@ export function ChatPanel() {
// (or waiting on the Veil gate) wait here and auto-send when it goes idle.
const [queued, setQueued] = useState<string[]>([]);
// Persisted conversation: a client-owned thread id (a fresh one per new chat),
// saved to the server after each exchange so history survives reloads.
const [threadId, setThreadId] = useState<string>(() => nanoid());
const threadIdRef = useRef(threadId);
threadIdRef.current = threadId;
// Skip the auto-save that would otherwise fire right after loading a thread
// (which would needlessly bump it to the top of the history).
const justLoadedRef = useRef(false);
const transport = useMemo(
() =>
new DefaultChatTransport<TemetroUIMessage>({
@@ -135,7 +152,10 @@ export function ChatPanel() {
// Run the LLM agent for a message (after any Veil gate) on a given model.
const runAgentWith = useCallback(
(text: string, modelId: string) => {
sendMessage({ text }, { body: { model: modelId, effort } });
sendMessage(
{ text },
{ body: { model: modelId, effort, threadId: threadIdRef.current } },
);
},
[sendMessage, effort],
);
@@ -239,6 +259,67 @@ export function ChatPanel() {
}
}, [requestedPatient, send]);
// Open a saved thread from `/?thread=<id>` (sidebar history); a bare `/` starts
// a fresh chat. Driven by the URL so the sidebar links and "New chat" work.
const requestedThread = searchParams.get("thread");
useEffect(() => {
if (requestedThread) {
if (requestedThread === threadIdRef.current) return; // already open
let active = true;
getThread(requestedThread)
.then((thread) => {
if (!active) return;
justLoadedRef.current = true;
setThreadId(thread.id);
setMessages(
thread.messages.map(
(m) =>
({
id: nanoid(),
role: m.role,
parts: m.parts,
}) as TemetroUIMessage,
),
);
})
.catch(() => {
/* missing/forbidden thread → leave the current chat as-is */
});
return () => {
active = false;
};
}
// No ?thread → fresh chat (e.g. after "New chat").
setThreadId(nanoid());
setMessages([]);
}, [requestedThread, setMessages]);
// Auto-save the conversation a moment after it settles (covers both LLM and
// the `/patient` fast path). Skips the redundant save right after a load.
useEffect(() => {
if (messages.length === 0) return;
if (status === "submitted" || status === "streaming") return;
if (justLoadedRef.current) {
justLoadedRef.current = false;
return;
}
const id = setTimeout(() => {
const firstUser = messages.find((m) => m.role === "user");
const textPart = firstUser?.parts.find((p) => p.type === "text") as
| { text?: string }
| undefined;
const title =
(textPart?.text ?? "").trim().slice(0, 60) ||
t("chat.history.untitled");
saveThread(threadIdRef.current, messages, title)
.then(notifyThreadsChanged)
.catch(() => {
/* a failed save shouldn't disrupt the chat */
});
}, 800);
return () => clearTimeout(id);
}, [messages, status, t]);
const promptInput = (
<ChatInput
effort={effort}
@@ -447,6 +528,15 @@ export function ChatPanel() {
/>
);
}
if (part.type === "data-inventoryList") {
return <InventoryListCard items={part.data.items} key={key} />;
}
if (part.type === "data-clinicCard") {
return <ClinicCard data={part.data} key={key} />;
}
if (part.type === "data-analyticsCard") {
return <AnalyticsCard data={part.data} key={key} />;
}
if (part.type === "data-veilNotice") {
return (
<Badge className="gap-1 self-start" key={key} variant="secondary">