mirror of
https://github.com/temetro/temetro.git
synced 2026-08-12 03:26:53 +00:00
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:
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
CalendarPlus,
|
||||
Check,
|
||||
ClipboardList,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
type InvoiceInput,
|
||||
type InvoiceLineItem,
|
||||
} from "@/lib/invoices";
|
||||
import { type InventoryInput, createInventory } from "@/lib/inventory";
|
||||
import { type PrescriptionInput, createPrescription } from "@/lib/prescriptions";
|
||||
import { type TaskInput, createTask } from "@/lib/tasks";
|
||||
import { notify } from "@/lib/toast";
|
||||
@@ -33,6 +35,7 @@ export const ACTION_ICONS = {
|
||||
task: ClipboardList,
|
||||
prescription: Pill,
|
||||
invoice: Receipt,
|
||||
inventory: Boxes,
|
||||
} as const;
|
||||
|
||||
const ICONS = ACTION_ICONS;
|
||||
@@ -61,6 +64,18 @@ export function summarize(data: ActionPreviewData): string[] {
|
||||
`${items.length} item${items.length === 1 ? "" : "s"} · ${formatMoney(total)}`,
|
||||
].filter(Boolean);
|
||||
}
|
||||
if (data.kind === "inventory") {
|
||||
const items = (r.items as InventoryInput[] | undefined) ?? [];
|
||||
return [
|
||||
`${items.length} item${items.length === 1 ? "" : "s"}`,
|
||||
items
|
||||
.map((it) =>
|
||||
[it.name, it.strength].filter(Boolean).join(" ") +
|
||||
(it.stockQuantity ? ` ×${it.stockQuantity}` : ""),
|
||||
)
|
||||
.join(", "),
|
||||
].filter(Boolean);
|
||||
}
|
||||
// prescription
|
||||
return [
|
||||
[r.medication, r.dose].filter(Boolean).join(" "),
|
||||
@@ -81,6 +96,12 @@ export async function commitAction(data: ActionPreviewData): Promise<void> {
|
||||
await createTask(data.record as TaskInput);
|
||||
} else if (data.kind === "invoice") {
|
||||
await createInvoice({ ...(data.record as InvoiceInput), source: "ai" });
|
||||
} else if (data.kind === "inventory") {
|
||||
const { items = [] } = data.record as { items?: InventoryInput[] };
|
||||
// Commit each proposed stock item via the RBAC-gated create endpoint.
|
||||
for (const item of items) {
|
||||
await createInventory(item);
|
||||
}
|
||||
} else {
|
||||
await createPrescription({
|
||||
...(data.record as PrescriptionInput),
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { Effort } from "@/lib/ai-models";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChatInputProps = {
|
||||
onSubmit: (text: string) => void;
|
||||
onSubmit: (text: string, files: File[]) => void;
|
||||
status: ChatStatus;
|
||||
onStop?: () => void;
|
||||
model: string;
|
||||
@@ -55,36 +55,16 @@ export function ChatInput({
|
||||
const canSend =
|
||||
(value.trim().length > 0 || files.length > 0) && !isGenerating;
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
const submit = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
// Allow submitting while generating — the panel queues it (Claude-style).
|
||||
if (!trimmed && files.length === 0) {
|
||||
return;
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (trimmed) {
|
||||
parts.push(trimmed);
|
||||
}
|
||||
// Include the text of attached files so the agent can parse them (e.g. a
|
||||
// database export to import). Read text-like files; cap each so a huge file
|
||||
// can't blow the context. Binary files are referenced by name only.
|
||||
for (const file of files) {
|
||||
const textLike =
|
||||
/\.(csv|tsv|json|txt|md|xml|ndjson|tab)$/i.test(file.name) ||
|
||||
file.type.startsWith("text/") ||
|
||||
file.type === "application/json";
|
||||
if (textLike) {
|
||||
try {
|
||||
const content = (await file.text()).slice(0, 200_000);
|
||||
parts.push(`--- File: ${file.name} ---\n${content}`);
|
||||
} catch {
|
||||
parts.push(`[Attached: ${file.name}]`);
|
||||
}
|
||||
} else {
|
||||
parts.push(`[Attached: ${file.name}]`);
|
||||
}
|
||||
}
|
||||
onSubmit(parts.join("\n\n"));
|
||||
// Hand the raw files to the panel; it sends them as proper attachment parts
|
||||
// (rendered as chips, not raw inlined text) and the backend extracts any
|
||||
// text-like content for the model.
|
||||
onSubmit(trimmed, files);
|
||||
setValue("");
|
||||
setFiles([]);
|
||||
}, [value, files, onSubmit]);
|
||||
@@ -234,7 +214,7 @@ export function ChatInput({
|
||||
<PatientFormDialog
|
||||
key={addKey}
|
||||
mode="create"
|
||||
onCreated={(fileNumber) => onSubmit(`/patient ${fileNumber}`)}
|
||||
onCreated={(fileNumber) => onSubmit(`/patient ${fileNumber}`, [])}
|
||||
onOpenChange={setAddOpen}
|
||||
open={addOpen}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport, type ToolUIPart } from "ai";
|
||||
import {
|
||||
DefaultChatTransport,
|
||||
type FileUIPart,
|
||||
type ToolUIPart,
|
||||
} from "ai";
|
||||
import { AlertTriangle, Brain, ChevronDown, ShieldCheck, X } from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentInfo,
|
||||
AttachmentPreview,
|
||||
Attachments,
|
||||
} from "@/components/ai-elements/attachments";
|
||||
import {
|
||||
ChainOfThought,
|
||||
ChainOfThoughtContent,
|
||||
@@ -83,6 +93,23 @@ import { getPatient } from "@/lib/patients";
|
||||
// pulls records instantly without the LLM (also works offline).
|
||||
const PATIENT_COMMAND = /^\/(?:patient\s+)?(\d+)$/i;
|
||||
|
||||
// Read a File into a FileUIPart (data URL). The backend extracts text-like
|
||||
// content for the model; images/PDFs are read directly by vision providers.
|
||||
function fileToPart(file: File): Promise<FileUIPart> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () =>
|
||||
resolve({
|
||||
type: "file",
|
||||
mediaType: file.type || "application/octet-stream",
|
||||
filename: file.name,
|
||||
url: reader.result as string,
|
||||
});
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function ChatPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [model, setModel] = useState<string>(DEFAULT_MODEL_ID);
|
||||
@@ -92,11 +119,14 @@ export function ChatPanel() {
|
||||
// per session before the first such send — inline (no modal). `pendingConsent`
|
||||
// holds the message text waiting on that one-time approval.
|
||||
const [consented, setConsented] = useState(false);
|
||||
const [pendingConsent, setPendingConsent] = useState<string | null>(null);
|
||||
const [pendingConsent, setPendingConsent] = useState<{
|
||||
text: string;
|
||||
files: File[];
|
||||
} | null>(null);
|
||||
|
||||
// Claude-style message queue: messages submitted while the assistant is busy
|
||||
// (or waiting on the Veil gate) wait here and auto-send when it goes idle.
|
||||
const [queued, setQueued] = useState<string[]>([]);
|
||||
const [queued, setQueued] = useState<{ text: string; files: File[] }[]>([]);
|
||||
|
||||
// Persisted conversation: a client-owned thread id (a fresh one per new chat),
|
||||
// saved to the server after each exchange so history survives reloads.
|
||||
@@ -151,9 +181,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) => {
|
||||
async (text: string, modelId: string, files: File[] = []) => {
|
||||
const fileParts = await Promise.all(files.map(fileToPart));
|
||||
sendMessage(
|
||||
{ text },
|
||||
{ text, files: fileParts },
|
||||
{ body: { model: modelId, effort, threadId: threadIdRef.current } },
|
||||
);
|
||||
},
|
||||
@@ -161,13 +192,13 @@ export function ChatPanel() {
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
async (text: string, files: File[] = []) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
if (!trimmed && files.length === 0) return;
|
||||
|
||||
// Busy or awaiting the Veil gate → queue and auto-send when idle.
|
||||
if (status === "submitted" || status === "streaming" || pendingConsent) {
|
||||
setQueued((q) => [...q, trimmed]);
|
||||
setQueued((q) => [...q, { text: trimmed, files }]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,10 +237,10 @@ export function ChatPanel() {
|
||||
|
||||
// Cloud model → inline Veil consent once before sending externally.
|
||||
if (isCloudModel && !consented) {
|
||||
setPendingConsent(trimmed);
|
||||
setPendingConsent({ text: trimmed, files });
|
||||
return;
|
||||
}
|
||||
runAgentWith(trimmed, model);
|
||||
void runAgentWith(trimmed, model, files);
|
||||
},
|
||||
[
|
||||
consented,
|
||||
@@ -228,22 +259,22 @@ export function ChatPanel() {
|
||||
if (status !== "ready" || pendingConsent || queued.length === 0) return;
|
||||
const [next, ...rest] = queued;
|
||||
setQueued(rest);
|
||||
if (next) void send(next);
|
||||
if (next) void send(next.text, next.files);
|
||||
}, [status, pendingConsent, queued, send]);
|
||||
|
||||
// Veil gate actions.
|
||||
const confirmConsent = useCallback(() => {
|
||||
setConsented(true);
|
||||
const text = pendingConsent;
|
||||
const pending = pendingConsent;
|
||||
setPendingConsent(null);
|
||||
if (text) runAgentWith(text, model);
|
||||
if (pending) void runAgentWith(pending.text, model, pending.files);
|
||||
}, [pendingConsent, runAgentWith, model]);
|
||||
|
||||
const useLocalInstead = useCallback(() => {
|
||||
setModel("ollama");
|
||||
const text = pendingConsent;
|
||||
const pending = pendingConsent;
|
||||
setPendingConsent(null);
|
||||
if (text) runAgentWith(text, "ollama");
|
||||
if (pending) void runAgentWith(pending.text, "ollama", pending.files);
|
||||
}, [pendingConsent, runAgentWith]);
|
||||
|
||||
const cancelConsent = useCallback(() => setPendingConsent(null), []);
|
||||
@@ -381,10 +412,13 @@ export function ChatPanel() {
|
||||
</span>
|
||||
<QueueList>
|
||||
{queued.map((q, i) => (
|
||||
<QueueItem key={`${i}-${q}`}>
|
||||
<QueueItem key={`${i}-${q.text}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<QueueItemIndicator />
|
||||
<QueueItemContent>{q}</QueueItemContent>
|
||||
<QueueItemContent>
|
||||
{q.text ||
|
||||
t("chat.queue.attachmentsOnly", { count: q.files.length })}
|
||||
</QueueItemContent>
|
||||
<QueueItemActions>
|
||||
<QueueItemAction
|
||||
aria-label={t("chat.queue.remove")}
|
||||
@@ -415,13 +449,16 @@ export function ChatPanel() {
|
||||
const firstActionPreviewIdx = message.parts.findIndex(
|
||||
(p) => p.type === "data-actionPreview",
|
||||
);
|
||||
// Attachments the clinician uploaded — rendered once as a chip group.
|
||||
const fileParts = message.parts.filter((p) => p.type === "file");
|
||||
const firstFileIdx = message.parts.findIndex((p) => p.type === "file");
|
||||
return (
|
||||
<Message from={message.role} key={message.id}>
|
||||
<MessageContent className="w-full">
|
||||
{steps.length > 0 ? (
|
||||
<ChainOfThought
|
||||
className="mb-1"
|
||||
defaultOpen={isLast && isWorking}
|
||||
defaultOpen={false}
|
||||
key={`${message.id}-cot`}
|
||||
>
|
||||
<ChainOfThoughtHeader>{t("chat.steps")}</ChainOfThoughtHeader>
|
||||
@@ -478,6 +515,26 @@ export function ChatPanel() {
|
||||
<MessageResponse key={key}>{part.text}</MessageResponse>
|
||||
);
|
||||
}
|
||||
if (part.type === "file") {
|
||||
// Render the whole message's files as one chip group, once.
|
||||
if (i !== firstFileIdx) return null;
|
||||
return (
|
||||
<Attachments className="w-full" key={key} variant="inline">
|
||||
{fileParts.map((fp, fi) => (
|
||||
<Attachment
|
||||
data={{
|
||||
...(fp as FileUIPart),
|
||||
id: `${message.id}-file-${fi}`,
|
||||
}}
|
||||
key={`${message.id}-file-${fi}`}
|
||||
>
|
||||
<AttachmentPreview />
|
||||
<AttachmentInfo />
|
||||
</Attachment>
|
||||
))}
|
||||
</Attachments>
|
||||
);
|
||||
}
|
||||
if (part.type === "data-patientCard") {
|
||||
return (
|
||||
<PatientResult
|
||||
|
||||
Reference in New Issue
Block a user