From 31d86bb5dd900cbdf1d227ce590349d692ab8e57 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sat, 13 Jun 2026 18:44:20 +0300 Subject: [PATCH] frontend: wire chat to the real agent + lab charts + import approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the mock chat with the live backend agent: - chat-panel uses @ai-sdk/react useChat against /api/chat (credentials included), passing the selected model + effort per send. The `/patient ` fast-path is kept as an instant client-side shortcut. - Renders the agent's streamed data parts: patientCard → record cards (PatientResult), labCard → new LabChartCard (visx area chart with a high/low flag badge in the top-right corner + recent values), importPreview → ImportPreviewCard (the human approval gate: review counts/issues, then commit via POST /api/ai/import — nothing is written until approved). - Veil consent: a one-time dialog before the first send to a cloud model, explaining that identifiers are de-identified before leaving the clinic. - Attached text files (csv/json/txt…) now include their content in the message so the agent can parse an export for import. Shared chat message/data-part types in lib/ai-chat.ts. Co-Authored-By: Claude Opus 4.8 --- frontend/components/chat/chat-input.tsx | 22 +- frontend/components/chat/chat-panel.tsx | 296 ++++++++++-------- .../components/chat/import-preview-card.tsx | 116 +++++++ frontend/components/chat/lab-chart-card.tsx | 123 ++++++++ frontend/lib/ai-chat.ts | 37 +++ frontend/lib/ai-settings.ts | 11 + frontend/lib/i18n/locales/en/translation.json | 33 ++ frontend/package-lock.json | 68 +++- frontend/package.json | 1 + 9 files changed, 564 insertions(+), 143 deletions(-) create mode 100644 frontend/components/chat/import-preview-card.tsx create mode 100644 frontend/components/chat/lab-chart-card.tsx create mode 100644 frontend/lib/ai-chat.ts diff --git a/frontend/components/chat/chat-input.tsx b/frontend/components/chat/chat-input.tsx index e8abb01..46938e1 100644 --- a/frontend/components/chat/chat-input.tsx +++ b/frontend/components/chat/chat-input.tsx @@ -55,7 +55,7 @@ export function ChatInput({ const canSend = (value.trim().length > 0 || files.length > 0) && !isGenerating; - const submit = useCallback(() => { + const submit = useCallback(async () => { const trimmed = value.trim(); if ((!trimmed && files.length === 0) || isGenerating) { return; @@ -64,8 +64,24 @@ export function ChatInput({ if (trimmed) { parts.push(trimmed); } - if (files.length > 0) { - parts.push(`[Attached: ${files.map((file) => file.name).join(", ")}]`); + // 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")); setValue(""); diff --git a/frontend/components/chat/chat-panel.tsx b/frontend/components/chat/chat-panel.tsx index 91e6928..230eaef 100644 --- a/frontend/components/chat/chat-panel.tsx +++ b/frontend/components/chat/chat-panel.tsx @@ -1,9 +1,10 @@ "use client"; +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; import { nanoid } from "nanoid"; -import type { ChatStatus } from "ai"; import { useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -17,110 +18,124 @@ import { MessageResponse, } from "@/components/ai-elements/message"; import { ChatInput } from "@/components/chat/chat-input"; +import { ImportPreviewCard } from "@/components/chat/import-preview-card"; +import { LabChartCard } from "@/components/chat/lab-chart-card"; import { PatientResult } from "@/components/chat/patient-cards"; -import { DEFAULT_EFFORT, DEFAULT_MODEL_ID, type Effort } from "@/lib/ai-models"; -import { getPatient, type Patient } from "@/lib/patients"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DEFAULT_EFFORT, + DEFAULT_MODEL_ID, + type Effort, + getModel, +} from "@/lib/ai-models"; +import type { TemetroUIMessage } from "@/lib/ai-chat"; +import { API_BASE_URL } from "@/lib/api-client"; +import { getPatient } from "@/lib/patients"; -type ChatMessage = - | { id: string; role: "user"; text: string } - | { id: string; role: "assistant"; kind: "text"; text: string } - | { - id: string; - role: "assistant"; - kind: "patient"; - fileNumber: string; - status: "loading" | "ready" | "not-found"; - patient?: Patient; - }; - -// Trigger: `/patient 10293` or just `/10293` pulls up records. +// Trigger: `/patient 10293` or just `/10293` — a client-side fast-path that +// pulls records instantly without the LLM (also works offline). const PATIENT_COMMAND = /^\/(?:patient\s+)?(\d+)$/i; export function ChatPanel() { const { t } = useTranslation(); - const [messages, setMessages] = useState([]); - const [status, setStatus] = useState("ready"); - // Model + effort selected in the input; travels with each send (wired to the - // backend agent in a later phase). Defaults come from the shared catalog. const [model, setModel] = useState(DEFAULT_MODEL_ID); const [effort, setEffort] = useState(DEFAULT_EFFORT); - const send = useCallback(async (text: string) => { - const trimmed = text.trim(); - if (!trimmed) { - return; - } + // Veil consent: cloud models de-identify + send data externally. We ask once + // per session before the first such send. + const [consented, setConsented] = useState(false); + const [consentOpen, setConsentOpen] = useState(false); + const pendingSend = useRef(null); - setMessages((prev) => [ - ...prev, - { id: nanoid(), role: "user", text: trimmed }, - ]); + const transport = useMemo( + () => + new DefaultChatTransport({ + api: `${API_BASE_URL}/api/chat`, + credentials: "include", + }), + [], + ); - const match = trimmed.match(PATIENT_COMMAND); + const { messages, setMessages, sendMessage, status, stop } = + useChat({ transport }); - // Patient lookup: append a loading card set, then fill it in once the - // (mock) record comes back. - if (match) { - const fileNumber = match[1]; - const resultId = nanoid(); - setStatus("submitted"); - setMessages((prev) => [ - ...prev, - { - id: resultId, - role: "assistant", - kind: "patient", - fileNumber, - status: "loading", - }, - ]); + const isCloudModel = (getModel(model)?.provider ?? "ollama") !== "ollama"; - let patient: Patient | null = null; - try { - patient = await getPatient(fileNumber); - } catch { - // Network / auth errors fall through as "not found"; a 401 will have - // already redirected to /login via the API client. - patient = null; + // Run the LLM agent for a message (after any consent gate). + const runAgent = useCallback( + (text: string) => { + sendMessage({ text }, { body: { model, effort } }); + }, + [sendMessage, model, effort], + ); + + const send = useCallback( + async (text: string) => { + const trimmed = text.trim(); + if (!trimmed) return; + + // Fast-path: `/patient ` renders cards directly, no LLM. + const match = trimmed.match(PATIENT_COMMAND); + if (match) { + const fileNumber = match[1]; + const userId = nanoid(); + setMessages((prev) => [ + ...prev, + { id: userId, role: "user", parts: [{ type: "text", text: trimmed }] }, + ]); + let patient = null; + try { + patient = await getPatient(fileNumber); + } catch { + patient = null; + } + setMessages((prev) => [ + ...prev, + { + id: nanoid(), + role: "assistant", + parts: patient + ? [{ type: "data-patientCard", data: patient }] + : [ + { + type: "text", + text: t("chat.patientNotFound", { fileNumber }), + }, + ], + }, + ]); + return; } - setMessages((prev) => - prev.map((message) => - message.id === resultId && - message.role === "assistant" && - message.kind === "patient" - ? { - ...message, - status: patient ? "ready" : "not-found", - patient: patient ?? undefined, - } - : message - ) - ); - setStatus("ready"); - return; - } - // UI-only: mock assistant reply for anything that isn't a command. - setStatus("submitted"); - window.setTimeout(() => { - setStatus("streaming"); - setMessages((prev) => [ - ...prev, - { - id: nanoid(), - role: "assistant", - kind: "text", - text: `This is a **UI-only preview** of temetro. Try \`/patient 10293\` to pull up a patient's records.\n\nYou asked:\n\n> ${trimmed}`, - }, - ]); - window.setTimeout(() => setStatus("ready"), 250); - }, 500); - }, []); + // Cloud model → ask for Veil consent once before sending externally. + if (isCloudModel && !consented) { + pendingSend.current = trimmed; + setConsentOpen(true); + return; + } + runAgent(trimmed); + }, + [consented, isCloudModel, runAgent, setMessages, t], + ); - const handleStop = useCallback(() => setStatus("ready"), []); + const confirmConsent = useCallback(() => { + setConsented(true); + setConsentOpen(false); + const text = pendingSend.current; + pendingSend.current = null; + if (text) runAgent(text); + }, [runAgent]); - // Opening a patient from the Patients page lands here as `/?patient=`; - // run the lookup once on arrival. + // Opening a patient from the Patients page lands here as `/?patient=`. const searchParams = useSearchParams(); const requestedPatient = searchParams.get("patient"); const handledPatientRef = useRef(null); @@ -137,12 +152,38 @@ export function ChatPanel() { model={model} onEffortChange={setEffort} onModelChange={setModel} - onStop={handleStop} + onStop={stop} onSubmit={send} status={status} /> ); + const consentDialog = ( + + + + {t("chat.consent.title")} + + {t("chat.consent.body", { + provider: getModel(model)?.label ?? model, + })} + + + +

+ {t("chat.consent.veilNote")} +

+
+ + + + +
+
+ ); + if (messages.length === 0) { return (
@@ -152,6 +193,7 @@ export function ChatPanel() { {promptInput}
+ {consentDialog} ); } @@ -160,48 +202,46 @@ export function ChatPanel() {
- {messages.map((message) => { - if (message.role === "assistant" && message.kind === "patient") { - return ( - - - - setMessages((prev) => - prev.map((m) => - m.id === message.id && - m.role === "assistant" && - m.kind === "patient" - ? { ...m, patient: updated, status: "ready" } - : m - ) - ) - } - patient={message.patient} - status={message.status} - /> - - - ); - } - - return ( - - - {message.role === "user" ? ( - {message.text} - ) : ( - {message.text} - )} - - - ); - })} + {messages.map((message) => ( + + + {message.parts.map((part, i) => { + const key = `${message.id}-${i}`; + if (part.type === "text") { + return message.role === "user" ? ( + + {part.text} + + ) : ( + {part.text} + ); + } + if (part.type === "data-patientCard") { + return ( + + ); + } + if (part.type === "data-labCard") { + return ; + } + if (part.type === "data-importPreview") { + return ; + } + return null; + })} + + + ))}
{promptInput}
+ {consentDialog}
); } diff --git a/frontend/components/chat/import-preview-card.tsx b/frontend/components/chat/import-preview-card.tsx new file mode 100644 index 0000000..63512d0 --- /dev/null +++ b/frontend/components/chat/import-preview-card.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { AlertTriangle, Check, Database, X } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import type { ImportPreviewData } from "@/lib/ai-chat"; +import { commitImport } from "@/lib/ai-settings"; +import { notify } from "@/lib/toast"; + +type Status = "pending" | "committing" | "done" | "rejected"; + +// The human approval gate for the migration import. The agent proposes records +// (dry run, nothing written); the clinician reviews counts + issues here and +// must approve before anything is inserted via POST /api/ai/import. +export function ImportPreviewCard({ data }: { data: ImportPreviewData }) { + const { t } = useTranslation(); + const [status, setStatus] = useState("pending"); + const [result, setResult] = useState<{ created: number; failed: number } | null>( + null, + ); + + const approve = async () => { + setStatus("committing"); + try { + const res = await commitImport(data.valid); + setResult({ created: res.created.length, failed: res.failed.length }); + setStatus("done"); + notify.success( + t("chat.importCard.importedTitle"), + t("chat.importCard.importedBody", { count: res.created.length }), + ); + } catch { + setStatus("pending"); + notify.error( + t("chat.importCard.failedTitle"), + t("chat.importCard.failedBody"), + ); + } + }; + + return ( + +
+ + {t("chat.importCard.title")} +
+ +
+ + {t("chat.importCard.ready")}{" "} + {data.valid.length} + + {data.invalid.length > 0 ? ( + + + {t("chat.importCard.skipped")}{" "} + {data.invalid.length} + + ) : null} + + {t("chat.importCard.total")}{" "} + {data.total} + +
+ + {data.invalid.length > 0 ? ( +
    + {data.invalid.slice(0, 5).map((issue) => ( +
  • + {t("chat.importCard.row", { index: issue.index + 1 })}:{" "} + {issue.errors.join("; ")} +
  • + ))} +
+ ) : null} + + {status === "done" && result ? ( +

+ + {t("chat.importCard.importedBody", { count: result.created })} + {result.failed > 0 + ? ` · ${t("chat.importCard.failedCount", { count: result.failed })}` + : ""} +

+ ) : status === "rejected" ? ( +

+ {t("chat.importCard.rejectedNote")} +

+ ) : ( +
+ + +
+ )} +
+ ); +} diff --git a/frontend/components/chat/lab-chart-card.tsx b/frontend/components/chat/lab-chart-card.tsx new file mode 100644 index 0000000..d18589c --- /dev/null +++ b/frontend/components/chat/lab-chart-card.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { ArrowDown, ArrowUp } from "lucide-react"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { Area, AreaChart } from "@/components/charts/area-chart"; +import { Grid } from "@/components/charts/grid"; +import { ChartTooltip } from "@/components/charts/tooltip"; +import { XAxis } from "@/components/charts/x-axis"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import type { LabCardData } from "@/lib/ai-chat"; +import type { LabFlag } from "@/lib/patients"; +import { cn } from "@/lib/utils"; + +type BadgeVariant = "default" | "secondary" | "destructive" | "outline"; + +const labFlagVariant: Record = { + normal: "outline", + low: "secondary", + high: "secondary", + critical: "destructive", +}; + +// Plot the headline lab series. labTrend.points are most-recent-last numbers; we +// synthesise evenly spaced dates ending today purely so the date x-axis spaces +// the readings — the values are what matter. +function toChartData(points: number[]): { date: Date; value: number }[] { + const today = new Date(); + return points.map((value, i) => { + const date = new Date(today); + date.setDate(today.getDate() - (points.length - 1 - i)); + return { date, value }; + }); +} + +// The flag of the most recent reading of the headline lab, for the corner badge. +function headlineFlag(data: LabCardData): LabFlag | null { + const matching = data.labs.filter((l) => l.name === data.labTrend.label); + const last = matching[matching.length - 1] ?? data.labs[data.labs.length - 1]; + return last?.flag ?? null; +} + +export function LabChartCard({ data }: { data: LabCardData }) { + const { t } = useTranslation(); + const chartData = useMemo( + () => toChartData(data.labTrend.points), + [data.labTrend.points], + ); + const flag = headlineFlag(data); + const latest = data.labTrend.points[data.labTrend.points.length - 1]; + + return ( + +
+
+ + {data.name} + +

+ {data.labTrend.label} · {data.labTrend.unit} +

+
+ {/* High/low indicator, top-right corner. */} + {flag && flag !== "normal" ? ( + + {flag === "low" ? ( + + ) : ( + + )} + {t(`chat.labCard.flags.${flag}`)} + + ) : ( + + {latest} + + )} +
+ + + + + + + + + {data.labs.length > 0 ? ( +
    + {data.labs.slice(-6).map((lab, i) => ( +
  • + {lab.name} + + + {lab.value} + + {lab.flag !== "normal" ? ( + + {t(`chat.labCard.flags.${lab.flag}`)} + + ) : null} + +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/frontend/lib/ai-chat.ts b/frontend/lib/ai-chat.ts new file mode 100644 index 0000000..4b51ab1 --- /dev/null +++ b/frontend/lib/ai-chat.ts @@ -0,0 +1,37 @@ +import type { UIMessage } from "ai"; + +import type { Lab, Patient, Trend } from "@/lib/patients"; + +// Custom data parts the backend agent streams alongside its text. They carry +// REAL (un-redacted) record data straight to the clinician's screen for rich +// rendering — the model itself only ever sees Veil-redacted tool results. + +export type LabCardData = { + fileNumber: string; + name: string; + labs: Lab[]; + labTrend: Trend; +}; + +export type ImportPreviewData = { + // Validated, ready-to-commit records (server re-validates on commit). + valid: unknown[]; + invalid: { index: number; errors: string[] }[]; + total: number; +}; + +export type VeilNoticeData = { + provider: string; + level: string; +}; + +// Maps each data part name → its payload. Part `type` strings are the key +// prefixed with `data-` (e.g. `data-patientCard`), per the AI SDK convention. +export type TemetroDataParts = { + patientCard: Patient; + labCard: LabCardData; + importPreview: ImportPreviewData; + veilNotice: VeilNoticeData; +}; + +export type TemetroUIMessage = UIMessage; diff --git a/frontend/lib/ai-settings.ts b/frontend/lib/ai-settings.ts index 9a26389..cd4dc07 100644 --- a/frontend/lib/ai-settings.ts +++ b/frontend/lib/ai-settings.ts @@ -50,3 +50,14 @@ export async function testAiConnection(input: { body: JSON.stringify(input), }); } + +// Commit records the clinician approved in a chat import preview. The backend +// re-validates and writes via the audited patient service. +export async function commitImport( + records: unknown[], +): Promise<{ created: string[]; failed: { fileNumber?: string; error: string }[] }> { + return apiFetch("/api/ai/import", { + method: "POST", + body: JSON.stringify({ records }), + }); +} diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 27b1b3c..c5248de 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -640,6 +640,39 @@ "gemini15Pro": "Google long-context model", "ollama": "Runs locally on your infrastructure" } + }, + "patientNotFound": "I couldn't find a patient with file number {{fileNumber}}.", + "consent": { + "title": "Send to external AI provider?", + "body": "This message will be processed by {{provider}}, an external provider.", + "veilNote": "Veil de-identifies patient information (names, MRNs, providers) before it leaves your infrastructure, and restores it in the answer. Local Ollama models avoid this entirely.", + "cancel": "Cancel", + "confirm": "De-identify & send" + }, + "labCard": { + "flags": { + "low": "Low", + "high": "High", + "critical": "Critical", + "normal": "Normal" + } + }, + "importCard": { + "title": "Import patient records", + "ready": "Ready to import", + "skipped": "Skipped", + "total": "Parsed", + "row": "Row {{index}}", + "approve": "Import {{count}} record(s)", + "approve_one": "Import 1 record", + "reject": "Discard", + "importing": "Importing…", + "rejectedNote": "Import discarded. Nothing was written.", + "importedTitle": "Records imported", + "importedBody": "Imported {{count}} patient record(s).", + "failedCount": "{{count}} failed", + "failedTitle": "Import failed", + "failedBody": "The records could not be imported. Please try again." } }, "patientCard": { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 16b041a..8ec9e07 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@ai-sdk/react": "^3.0.206", "@base-ui/react": "^1.5.0", "@number-flow/react": "^0.6.0", "@radix-ui/react-use-controllable-state": "^1.2.2", @@ -73,13 +74,13 @@ } }, "node_modules/@ai-sdk/gateway": { - "version": "3.0.121", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.121.tgz", - "integrity": "sha512-uY248djJRxa5W68MHiyqO8WLdOeKQoRClGg7PVX/VPhVW8SJNM7/l5DcrA5WAM3YfQrLyNkgZa2VOu8T0t8LUw==", + "version": "3.0.130", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.130.tgz", + "integrity": "sha512-qenRdpoYM+2y8Ibj3Y7XngvfcG4NIpejaM+YqAKWXi3/N1qZYeIelrm19jxhIwQW0W/g7WUz0L2Agl7+FnwrQA==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "3.0.10", - "@ai-sdk/provider-utils": "4.0.27", + "@ai-sdk/provider-utils": "4.0.29", "@vercel/oidc": "3.2.0" }, "engines": { @@ -102,9 +103,9 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "4.0.27", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", - "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", + "version": "4.0.29", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.29.tgz", + "integrity": "sha512-uhukHaCBvqkwBHkT8C2PrnqKTCoLn3pdHXqtcR9I8ErH+flbzgW4o7VHSNIup9LRu+WBvZIZDQLsx6rwl2tiOA==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "3.0.10", @@ -118,6 +119,24 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/react": { + "version": "3.0.206", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.206.tgz", + "integrity": "sha512-26gbPT876BZVXJlSNjqkCDOFiqfxAAhYn+a/ryV6ce5TWo6EfWQZeEiLrZOS6bxaOi7xxkB5uPZaZKFaIJndjQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "4.0.29", + "ai": "6.0.204", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -5222,14 +5241,14 @@ } }, "node_modules/ai": { - "version": "6.0.193", - "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.193.tgz", - "integrity": "sha512-VQOTOse8+X8kMtg61DNSXlYJzwOW4NjMLDJNk/qxClWsFe4oiyFJDHGGG1oezfGcFzuYuQe/8Z7r4kwiZWh2YQ==", + "version": "6.0.204", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.204.tgz", + "integrity": "sha512-SudB8rUwaVhpWF8+qTJcxUptXPIdN9rWMknzTT3WbKa2QwiGRshyepFKNkDILWm882LgqlEyRZgKhNT14j0jpQ==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "3.0.121", + "@ai-sdk/gateway": "3.0.130", "@ai-sdk/provider": "3.0.10", - "@ai-sdk/provider-utils": "4.0.27", + "@ai-sdk/provider-utils": "4.0.29", "@opentelemetry/api": "^1.9.0" }, "engines": { @@ -14605,6 +14624,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -14648,6 +14680,18 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8ce97f8..f799755 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@ai-sdk/react": "^3.0.206", "@base-ui/react": "^1.5.0", "@number-flow/react": "^0.6.0", "@radix-ui/react-use-controllable-state": "^1.2.2",