feat(chat): file attachments as chips, CoT closed, AI add-to-inventory

Chat fixes (#1/#6/#7):
- chat-input passes raw File[] up instead of inlining file text
- chat-panel sends files as FileUIPart and renders them with the
  ai-elements Attachments component (no more raw text dumps)
- backend extracts text-like file content for the model in routes/chat
- Chain-of-Thought now defaults to collapsed
- file upload works regardless of whether the input has text

AI add-to-inventory (#2):
- new proposeInventory tool (validates items, streams an approval card)
- system prompt distinguishes stocking inventory vs. billing a patient
- ActionPreviewCard commits inventory via POST /api/inventory

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-15 18:27:43 +03:00
parent 3aa699aefe
commit 6e5598262e
7 changed files with 235 additions and 51 deletions
+51 -2
View File
@@ -33,6 +33,50 @@ export const chatRouter = Router();
chatRouter.use(requireAuth, requireOrg, requirePermission({ patient: ["read"] }));
// Text-like uploads (CSV/JSON/TXT/…) the model should read as parseable text
// rather than an opaque data URL. Images/PDFs are left as file parts so
// vision-capable providers read them directly.
const TEXT_LIKE_MEDIA = /^(text\/|application\/(json|xml|csv|x-ndjson))/i;
const TEXT_LIKE_EXT = /\.(csv|tsv|json|txt|md|xml|ndjson|tab)$/i;
function decodeDataUrl(url: string): string {
const comma = url.indexOf(",");
if (comma === -1) return "";
const meta = url.slice(0, comma);
const data = url.slice(comma + 1);
return meta.includes("base64")
? Buffer.from(data, "base64").toString("utf8")
: decodeURIComponent(data);
}
// Replace text-like file parts with a text part carrying the file's content
// (capped) so the agent can parse uploads (e.g. a medications list to add to
// inventory, or a database export to import). Display/storage are unaffected —
// this only shapes what the model sees.
function inlineTextFiles(messages: UIMessage[]): UIMessage[] {
return messages.map((message) => {
if (!Array.isArray(message.parts)) return message;
const parts = message.parts.flatMap((part) => {
if (
part.type === "file" &&
typeof part.url === "string" &&
(TEXT_LIKE_MEDIA.test(part.mediaType ?? "") ||
TEXT_LIKE_EXT.test(part.filename ?? ""))
) {
const content = decodeDataUrl(part.url).slice(0, 200_000);
return [
{
type: "text" as const,
text: `--- File: ${part.filename ?? "file"} ---\n${content}`,
},
];
}
return [part];
});
return { ...message, parts } as UIMessage;
});
}
function systemPrompt(veilActive: boolean, providerLabel: string): string {
return [
"You are temetro, a clinical assistant that helps clinicians retrieve,",
@@ -55,10 +99,15 @@ function systemPrompt(veilActive: boolean, providerLabel: string): string {
" asks to add/book/create one. They show an approval card; the record is only",
" written after the clinician clicks Add. NEVER say you added/booked/created",
" something — say you've drafted it for their approval.",
"- proposeInventory: when the clinician wants to ADD STOCK to the clinic's",
" inventory — e.g. they upload a list of medications/supplies with quantities",
" (and optionally prices) to stock. Parse it into items {name, form, strength,",
" unit, stockQuantity, reorderThreshold, expiresAt} and call proposeInventory.",
"- proposeInvoice: when the clinician wants to bill someone — e.g. they upload",
" a list of purchased medications/items. Parse it into line items",
" {description, quantity, unitPrice} (use the prices in the document) and call",
" proposeInvoice with the patient/client name.",
" proposeInvoice with the patient/client name. (Stocking inventory vs. billing a",
" patient are different — pick proposeInventory for the former.)",
"- previewImport: when the clinician wants to import/migrate an existing",
" patient database file, or add a single patient. Parse the uploaded content",
" into our patient shape and call previewImport.",
@@ -113,7 +162,7 @@ chatRouter.post("/", async (req, res, next) => {
},
};
const modelMessages = await convertToModelMessages(messages);
const modelMessages = await convertToModelMessages(inlineTextFiles(messages));
const system = systemPrompt(veil.active, resolved.providerLabel);
const stream = createUIMessageStream({