From 635c45011b04c551828dbb91cf33804fc589aa6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 03:16:06 +0200 Subject: [PATCH] feat(documents): chat-about-this-document panel in the detail sheet AI area Add a scoped 'Chat about this document' panel beneath the Read-with-AI actions. Opens a conversation grounded only in the document's stored text: a message list + input streaming the assistant reply token-by-token over the SSE route, with prior turns loaded via GET. - Availability: the panel renders in the provider-available branch of the AI section (a pure chatSlot keeps the section statically renderable), and gates internally on whether the document is content-indexed. Not indexed shows a calm read-it-first hint, never an error. - Plain-text only: assistant prose renders via ProseBlocks / StreamedProse as React children (no markdown library, no dangerouslySetInnerHTML). - Streaming reuses the Coach SSE frame parser; the wire frames are a subset. - A one-line safety note states answers describe the document and are not medical advice, consistent with the summary panel's not-a-diagnosis. - Query keys via the central factory; history invalidated on the done frame. - i18n x6 for every new string. --- messages/de.json | 19 + messages/en.json | 19 + messages/es.json | 19 + messages/fr.json | 19 + messages/it.json | 19 + messages/pl.json | 19 + .../documents/document-ai-section.tsx | 12 + .../documents/document-chat-panel.tsx | 329 ++++++++++++++++++ .../documents/document-detail-sheet.tsx | 7 + src/components/documents/use-document-chat.ts | 299 ++++++++++++++++ src/lib/query-keys/documents.ts | 10 + 11 files changed, 771 insertions(+) create mode 100644 src/components/documents/document-chat-panel.tsx create mode 100644 src/components/documents/use-document-chat.ts diff --git a/messages/de.json b/messages/de.json index 1891ebcc2..4955e2990 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8243,6 +8243,25 @@ "share": { "title": "Dieses Dokument teilen", "description": "Erstelle einen schreibgeschützten Ärzte-Link mit diesem angehängten Dokument. Link und Passphrase erscheinen nur einmal." + }, + "chat": { + "title": "Über dieses Dokument chatten", + "open": "Über dieses Dokument chatten", + "notIndexed": "Lies das Dokument zuerst mit KI, um darüber zu chatten.", + "empty": "Stelle eine Frage zu diesem Dokument, um zu beginnen.", + "placeholder": "Frage zu diesem Dokument…", + "inputLabel": "Deine Frage zu diesem Dokument", + "send": "Senden", + "thinking": "Dokument wird gelesen…", + "safety": "Antworten beschreiben dieses Dokument und sind keine medizinische Beratung.", + "close": "Chat schließen", + "errorHistory": "Unterhaltung konnte nicht geladen werden. Bitte erneut versuchen.", + "errorGeneric": "Etwas ist schiefgelaufen. Bitte erneut versuchen.", + "errorRateLimited": "Zu viele Nachrichten. Bitte kurz warten und erneut versuchen.", + "errorBudget": "Das tägliche KI-Limit ist erreicht. Bitte morgen erneut versuchen.", + "errorCredential": "Der KI-Anbieter muss in den Einstellungen neu verbunden werden.", + "errorConsent": "Aktiviere die KI-Zustimmung in den Einstellungen, um über dieses Dokument zu chatten.", + "errorNetwork": "Du scheinst offline zu sein. Prüfe deine Verbindung und versuche es erneut." } }, "selfDescription": { diff --git a/messages/en.json b/messages/en.json index 3fb5eaf38..6902df151 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8243,6 +8243,25 @@ "share": { "title": "Share this document", "description": "Create a read-only clinician link with this document attached. The link and its passphrase appear once." + }, + "chat": { + "title": "Chat about this document", + "open": "Chat about this document", + "notIndexed": "Read this document with AI first to chat about it.", + "empty": "Ask a question about this document to get started.", + "placeholder": "Ask about this document…", + "inputLabel": "Your question about this document", + "send": "Send", + "thinking": "Reading the document…", + "safety": "Answers describe this document and are not medical advice.", + "close": "Close chat", + "errorHistory": "Couldn't load the conversation. Try again.", + "errorGeneric": "Something went wrong. Try again.", + "errorRateLimited": "Too many messages. Wait a moment and try again.", + "errorBudget": "The daily AI limit has been reached. Try again tomorrow.", + "errorCredential": "The AI provider needs reconnecting in settings.", + "errorConsent": "Turn on AI consent in settings to chat about this document.", + "errorNetwork": "You appear to be offline. Check your connection and try again." } }, "selfDescription": { diff --git a/messages/es.json b/messages/es.json index a47925492..87f2950db 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8243,6 +8243,25 @@ "share": { "title": "Compartir este documento", "description": "Crea un enlace de solo lectura para profesionales con este documento adjunto. El enlace y su frase de contraseña aparecen una sola vez." + }, + "chat": { + "title": "Chatea sobre este documento", + "open": "Chatea sobre este documento", + "notIndexed": "Primero lee este documento con IA para poder chatear sobre él.", + "empty": "Haz una pregunta sobre este documento para empezar.", + "placeholder": "Pregunta sobre este documento…", + "inputLabel": "Tu pregunta sobre este documento", + "send": "Enviar", + "thinking": "Leyendo el documento…", + "safety": "Las respuestas describen este documento y no son consejo médico.", + "close": "Cerrar chat", + "errorHistory": "No se pudo cargar la conversación. Inténtalo de nuevo.", + "errorGeneric": "Algo salió mal. Inténtalo de nuevo.", + "errorRateLimited": "Demasiados mensajes. Espera un momento e inténtalo de nuevo.", + "errorBudget": "Se alcanzó el límite diario de IA. Inténtalo de nuevo mañana.", + "errorCredential": "Hay que volver a conectar el proveedor de IA en los ajustes.", + "errorConsent": "Activa el consentimiento de IA en los ajustes para chatear sobre este documento.", + "errorNetwork": "Parece que no tienes conexión. Comprueba tu red e inténtalo de nuevo." } }, "selfDescription": { diff --git a/messages/fr.json b/messages/fr.json index c271324c2..5eaf31839 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8243,6 +8243,25 @@ "share": { "title": "Partager ce document", "description": "Créez un lien clinicien en lecture seule avec ce document joint. Le lien et sa phrase secrète n’apparaissent qu’une seule fois." + }, + "chat": { + "title": "Discuter de ce document", + "open": "Discuter de ce document", + "notIndexed": "Lisez d'abord ce document avec l'IA pour pouvoir en discuter.", + "empty": "Posez une question sur ce document pour commencer.", + "placeholder": "Une question sur ce document…", + "inputLabel": "Votre question sur ce document", + "send": "Envoyer", + "thinking": "Lecture du document…", + "safety": "Les réponses décrivent ce document et ne constituent pas un avis médical.", + "close": "Fermer le chat", + "errorHistory": "Impossible de charger la conversation. Réessayez.", + "errorGeneric": "Une erreur s'est produite. Réessayez.", + "errorRateLimited": "Trop de messages. Patientez un instant et réessayez.", + "errorBudget": "La limite quotidienne d'IA est atteinte. Réessayez demain.", + "errorCredential": "Le fournisseur d'IA doit être reconnecté dans les paramètres.", + "errorConsent": "Activez le consentement à l'IA dans les paramètres pour discuter de ce document.", + "errorNetwork": "Vous semblez hors ligne. Vérifiez votre connexion et réessayez." } }, "selfDescription": { diff --git a/messages/it.json b/messages/it.json index b983b8a06..8f2cd55ca 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8243,6 +8243,25 @@ "share": { "title": "Condividi questo documento", "description": "Crea un link di sola lettura per il medico con questo documento allegato. Il link e la relativa passphrase compaiono una sola volta." + }, + "chat": { + "title": "Chatta su questo documento", + "open": "Chatta su questo documento", + "notIndexed": "Leggi prima questo documento con l'IA per poterne parlare.", + "empty": "Fai una domanda su questo documento per iniziare.", + "placeholder": "Una domanda su questo documento…", + "inputLabel": "La tua domanda su questo documento", + "send": "Invia", + "thinking": "Lettura del documento…", + "safety": "Le risposte descrivono questo documento e non sono un consiglio medico.", + "close": "Chiudi chat", + "errorHistory": "Impossibile caricare la conversazione. Riprova.", + "errorGeneric": "Qualcosa è andato storto. Riprova.", + "errorRateLimited": "Troppi messaggi. Attendi un momento e riprova.", + "errorBudget": "Limite giornaliero di IA raggiunto. Riprova domani.", + "errorCredential": "Il provider IA va ricollegato nelle impostazioni.", + "errorConsent": "Attiva il consenso all'IA nelle impostazioni per chattare su questo documento.", + "errorNetwork": "Sembri offline. Controlla la connessione e riprova." } }, "selfDescription": { diff --git a/messages/pl.json b/messages/pl.json index a41d97b3c..5feb87735 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8243,6 +8243,25 @@ "share": { "title": "Udostępnij ten dokument", "description": "Utwórz link tylko do odczytu dla lekarza z dołączonym tym dokumentem. Link i hasło pojawią się tylko raz." + }, + "chat": { + "title": "Porozmawiaj o tym dokumencie", + "open": "Porozmawiaj o tym dokumencie", + "notIndexed": "Najpierw przeczytaj ten dokument z pomocą AI, aby o nim porozmawiać.", + "empty": "Zadaj pytanie o ten dokument, aby zacząć.", + "placeholder": "Zapytaj o ten dokument…", + "inputLabel": "Twoje pytanie o ten dokument", + "send": "Wyślij", + "thinking": "Czytanie dokumentu…", + "safety": "Odpowiedzi opisują ten dokument i nie są poradą medyczną.", + "close": "Zamknij czat", + "errorHistory": "Nie udało się wczytać rozmowy. Spróbuj ponownie.", + "errorGeneric": "Coś poszło nie tak. Spróbuj ponownie.", + "errorRateLimited": "Zbyt wiele wiadomości. Poczekaj chwilę i spróbuj ponownie.", + "errorBudget": "Osiągnięto dzienny limit AI. Spróbuj ponownie jutro.", + "errorCredential": "Dostawcę AI trzeba ponownie połączyć w ustawieniach.", + "errorConsent": "Włącz zgodę na AI w ustawieniach, aby porozmawiać o tym dokumencie.", + "errorNetwork": "Wygląda na to, że jesteś offline. Sprawdź połączenie i spróbuj ponownie." } }, "selfDescription": { diff --git a/src/components/documents/document-ai-section.tsx b/src/components/documents/document-ai-section.tsx index 25d4c7f30..a6aad46f4 100644 --- a/src/components/documents/document-ai-section.tsx +++ b/src/components/documents/document-ai-section.tsx @@ -23,6 +23,7 @@ * form below stays fully usable. */ import { FileText, ScanText, ShieldAlert, WandSparkles } from "lucide-react"; +import type { ReactNode } from "react"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; @@ -69,6 +70,7 @@ export function DocumentAiSection({ contentIndexSource, indexPending, onIndex, + chatSlot, }: { aiEnabled: boolean; indexEnabled: boolean; @@ -102,6 +104,14 @@ export function DocumentAiSection({ contentIndexSource: DocumentContentIndexSourceValue | null; indexPending: boolean; onIndex: () => void; + /** + * The scoped "chat about this document" panel, rendered inside the AI area + * beneath the reading actions. Only supplied (and only meaningful) when a + * provider is available — the panel itself gates on whether the document is + * indexed. A pure slot so this section stays presentational + statically + * renderable (the stateful, query-driven panel lives in the sheet). + */ + chatSlot?: ReactNode; }) { const { t } = useTranslations(); const aiRead = hasContentIndex && isAiReadSource(contentIndexSource); @@ -231,6 +241,8 @@ export function DocumentAiSection({ onClose={onCloseSummary} /> ) : null} + + {chatSlot} ) : ( diff --git a/src/components/documents/document-chat-panel.tsx b/src/components/documents/document-chat-panel.tsx new file mode 100644 index 000000000..30907155f --- /dev/null +++ b/src/components/documents/document-chat-panel.tsx @@ -0,0 +1,329 @@ +"use client"; + +/** + * v1.27.33 (Document vault P4) — the "Chat about this document" panel that lives + * inside the detail sheet's AI area, beneath the "Read with AI" actions. It opens + * a scoped conversation grounded ONLY in this one document's stored text: a + * message list + an input that streams the assistant reply token-by-token over + * the SSE route, with prior turns loaded via GET. + * + * Rendered by the sheet ONLY when a provider is available (it fills + * `DocumentAiSection`'s `chatSlot`, which sits in the provider-available branch), + * so the panel itself only gates on whether the document is content-INDEXED — + * the text is the sole grounding. Not indexed → a calm "read it with AI first" + * hint (never an error), pointing at the action directly above. + * + * Rendering posture (matches the rest of the AI surfaces): assistant prose is + * PLAIN React text via `ProseBlocks` / `StreamedProse` — there is no markdown + * library and no `dangerouslySetInnerHTML` (the project's XSS rule). Any citation + * the model emits ("per the report's Impression…") is just text. A one-line + * safety note states answers describe the document and are not medical advice, + * consistent with the summary panel's "not a diagnosis". + */ +import { MessageSquare, Send } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { ProseBlocks } from "@/components/insights/prose-blocks"; +import { StreamedProse } from "@/components/insights/coach-panel/streamed-prose"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +import { + useDocumentChatThread, + useSendDocumentChatMessage, + type DocumentChatMessage, +} from "./use-document-chat"; + +/** Map a server / client error code to a calm translation key. */ +function documentChatErrorKey(code: string | null): string { + switch (code) { + case "documents.chat.provider.rate_limited": + case "documents.inbound.rateLimited": + return "documents.chat.errorRateLimited"; + case "documents.chat.budget.exceeded": + return "documents.chat.errorBudget"; + case "documents.chat.provider.credential_expired": + return "documents.chat.errorCredential"; + case "consent.ai.required": + return "documents.chat.errorConsent"; + case "documents.chat.network": + return "documents.chat.errorNetwork"; + default: + return "documents.chat.errorGeneric"; + } +} + +/** One settled turn — user bubble right, assistant prose left (plain text). */ +function ChatTurn({ message }: { message: DocumentChatMessage }) { + if (message.role === "user") { + return ( +
+
+ {/* User text is verbatim: no chart-token strip, no Learn linkify. */} + +
+
+ ); + } + return ( +
+
+ +
+
+ ); +} + +export function DocumentChatPanel({ + documentId, + indexed, +}: { + documentId: string; + /** True when the document has a content index (its text grounds the chat). */ + indexed: boolean; +}) { + const { t, locale } = useTranslations(); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(""); + const listRef = useRef(null); + + const thread = useDocumentChatThread(documentId, open && indexed); + const { streaming, isStreaming, optimisticUser, send, reset } = + useSendDocumentChatMessage(documentId); + + const messages = thread.data?.messages ?? []; + const conversationId = thread.data?.conversationId ?? undefined; + + // The optimistic bubble is dropped once its persisted twin lands (matched by + // content on the freshest user turn), so the user never sees it twice. + const lastUser = [...messages].reverse().find((m) => m.role === "user"); + const showOptimistic = + optimisticUser !== null && lastUser?.content !== optimisticUser; + + // Keep the newest turn in view as messages / tokens land. + useEffect(() => { + const el = listRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [messages.length, streaming.content, showOptimistic, isStreaming]); + + const chatLocale = locale === "de" ? "de" : "en"; + + const submit = () => { + const message = draft.trim(); + if (!message || isStreaming) return; + setDraft(""); + void send({ conversationId, message, locale: chatLocale }); + }; + + // ── Not indexed: calm pointer to the read action above (never an error) ── + if (!indexed) { + return ( +
+
+ +

{t("documents.chat.title")}

+
+

+ {t("documents.chat.notIndexed")} +

+
+ ); + } + + // ── Collapsed entry: available, opens the scoped chat on tap ── + if (!open) { + return ( +
+ +
+ ); + } + + const streamErrorKey = streaming.errorCode + ? documentChatErrorKey(streaming.errorCode) + : null; + const isEmpty = + !thread.isPending && + messages.length === 0 && + !showOptimistic && + !isStreaming && + streaming.content.length === 0; + + return ( +
+
+ +

{t("documents.chat.title")}

+
+ +
+ {thread.isPending ? ( +
+ + +
+ ) : null} + + {isEmpty ? ( +

+ {t("documents.chat.empty")} +

+ ) : null} + + {messages.map((m) => ( + + ))} + + {showOptimistic ? ( + + ) : null} + + {isStreaming && streaming.content.length === 0 && !streamErrorKey ? ( +

+ {t("documents.chat.thinking")} +

+ ) : null} + + {streaming.content.length > 0 ? ( +
+
+ +
+
+ ) : null} +
+ + {streamErrorKey ? ( +

+ {t(streamErrorKey)} +

+ ) : null} + + {thread.isError ? ( +

+ {t("documents.chat.errorHistory")} +

+ ) : null} + +
{ + e.preventDefault(); + submit(); + }} + > + setDraft(e.target.value)} + placeholder={t("documents.chat.placeholder")} + aria-label={t("documents.chat.inputLabel")} + maxLength={4000} + disabled={isStreaming} + data-slot="document-chat-input" + autoComplete="off" + /> + +
+ + {/* Safety-visible: answers describe the document, not medical advice — + consistent with the summary panel's "not a diagnosis". */} +

+ {t("documents.chat.safety")} +

+ + {/* Retiring the panel discards the live stream state (the persisted + history stays on disk and reloads next open). */} + +
+ ); +} diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index cb901236b..f79630a6c 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -59,6 +59,7 @@ import { type InboundDocumentKindValue, } from "@/lib/validations/inbound-documents"; import { DocumentAiSection } from "./document-ai-section"; +import { DocumentChatPanel } from "./document-chat-panel"; import type { DocumentAiTarget } from "./document-ai-transport"; import { DocumentShareSheet } from "./document-share-sheet"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; @@ -535,6 +536,12 @@ export function DocumentDetailSheet({ contentIndexSource={doc.contentIndexSource} indexPending={indexDoc.isPending} onIndex={runIndex} + chatSlot={ + + } /> {mutationError ? ( diff --git a/src/components/documents/use-document-chat.ts b/src/components/documents/use-document-chat.ts new file mode 100644 index 000000000..f5da98510 --- /dev/null +++ b/src/components/documents/use-document-chat.ts @@ -0,0 +1,299 @@ +"use client"; + +/** + * v1.27.33 (Document vault P4) — client hooks for the scoped "chat about this + * document" panel: the prior-thread history (GET) and the SSE-streaming send + * (POST). Deliberately lean — one active thread per document, no tools, no + * snapshot; the whole feature is a single grounded completion per turn on the + * server, so the client only needs a message list + a live streaming tail. + * + * Streaming reuses the Coach's pure SSE frame parser (`parseSseChunk`) — the + * document route emits the same `token` / `done` / `error` wire frames (a subset + * of `CoachStreamEvent`). Assistant prose renders as PLAIN React text on the + * panel (no markdown library, the project's XSS posture); this hook only carries + * the raw string. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import type { + CoachConversationDetailDTO, + CoachConversationsPage, +} from "@/lib/ai/coach/types"; +import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; + +import { parseSseChunk } from "@/components/insights/coach-panel/use-coach"; + +/** One persisted turn in a document chat, trimmed to what the panel renders. */ +export interface DocumentChatMessage { + id: string; + role: "user" | "assistant"; + content: string; + createdAt: string; +} + +/** The document's active chat thread: the newest conversation + its messages. */ +export interface DocumentChatThread { + conversationId: string | null; + messages: DocumentChatMessage[]; +} + +const EMPTY_THREAD: DocumentChatThread = { + conversationId: null, + messages: [], +}; + +/** + * Load the document's active chat thread. The list endpoint returns the + * document's conversations newest-first; the panel keeps ONE active thread, so + * this resolves the newest conversation and fetches its decrypted messages in a + * single query. Returns an empty thread when the document has never been chatted + * about (no conversation yet) — the panel then starts a fresh one on first send. + */ +export function useDocumentChatThread( + documentId: string | null, + enabled: boolean, +) { + return useQuery({ + queryKey: queryKeys.inboundDocumentChat(documentId ?? "none"), + enabled: enabled && documentId !== null, + staleTime: 30_000, + queryFn: async () => { + if (!documentId) return EMPTY_THREAD; + const page = await apiGet( + `/api/documents/inbound/${documentId}/chat`, + ); + const newest = page.conversations[0]; + if (!newest) return EMPTY_THREAD; + const detail = await apiGet( + `/api/documents/inbound/${documentId}/chat?conversationId=${encodeURIComponent( + newest.id, + )}`, + ); + return { + conversationId: detail.id, + messages: detail.messages.map((m) => ({ + id: m.id, + role: m.role === "assistant" ? "assistant" : "user", + content: m.content, + createdAt: m.createdAt, + })), + }; + }, + }); +} + +/** Live state of the assistant turn currently streaming into the panel. */ +export interface DocumentChatStreamingState { + /** Concatenated tokens received so far. */ + content: string; + /** True until the `done` frame closes the stream. */ + inProgress: boolean; + /** Server-side error code from an `error` frame / rejected POST, if any. */ + errorCode: string | null; +} + +const EMPTY_STREAMING: DocumentChatStreamingState = { + content: "", + inProgress: false, + errorCode: null, +}; + +export interface SendDocumentChatParams { + conversationId?: string; + message: string; + locale?: "en" | "de"; +} + +/** + * The streaming send hook. `send()` POSTs the turn to the document chat route, + * walks the SSE byte stream, and updates `streaming` after every frame. On the + * `done` frame the document's chat thread cache is invalidated so the persisted + * (encrypted-on-disk) history reloads with the canonical message ids. An + * `optimisticUser` bubble surfaces the just-sent message immediately, before the + * assistant's placeholder, and is cleared once the persisted twin lands. + */ +export function useSendDocumentChatMessage(documentId: string) { + const queryClient = useQueryClient(); + const [streaming, setStreaming] = + useState(EMPTY_STREAMING); + const [optimisticUser, setOptimisticUser] = useState(null); + const abortRef = useRef(null); + + const reset = useCallback(() => { + setStreaming(EMPTY_STREAMING); + setOptimisticUser(null); + }, []); + + useEffect(() => { + // Cancel any in-flight stream when the panel unmounts / document switches. + return () => abortRef.current?.abort(); + }, []); + + const send = useCallback( + async (params: SendDocumentChatParams): Promise => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setOptimisticUser(params.message); + setStreaming({ content: "", inProgress: true, errorCode: null }); + + if (typeof navigator !== "undefined" && navigator.onLine === false) { + setStreaming({ + content: "", + inProgress: false, + errorCode: "documents.chat.network", + }); + return null; + } + + // apiFetchRaw: the POST streams SSE — the envelope helpers would buffer + // and unwrap a body that never carries the envelope. + let response: Response; + try { + response = await apiFetchRaw( + `/api/documents/inbound/${documentId}/chat`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify({ + conversationId: params.conversationId, + message: params.message, + locale: params.locale, + }), + signal: controller.signal, + }, + ); + } catch (err) { + if ((err as Error).name === "AbortError") return null; + setStreaming({ + content: "", + inProgress: false, + errorCode: "documents.chat.network", + }); + return null; + } + + const contentType = response.headers.get("Content-Type") ?? ""; + const isEventStream = contentType + .toLowerCase() + .startsWith("text/event-stream"); + + // A rejected turn (rate limit, budget, consent, not-indexed) is a JSON + // error envelope, not an SSE body. Surface its structured `error` code so + // the panel shows the right copy; keep the optimistic bubble standing next + // to it (no persisted twin is coming). + if (!response.ok && !isEventStream) { + let structured: string | null = null; + try { + const envelope = (await response.clone().json()) as { + error?: unknown; + meta?: { errorCode?: unknown }; + }; + if (envelope?.meta && typeof envelope.meta.errorCode === "string") { + structured = envelope.meta.errorCode; + } else if (typeof envelope?.error === "string") { + structured = envelope.error; + } + } catch { + // body was not JSON; fall through to the http-status fallback + } + setStreaming({ + content: "", + inProgress: false, + errorCode: structured ?? `documents.chat.http.${response.status}`, + }); + return null; + } + + if (!response.body) { + setStreaming({ + content: "", + inProgress: false, + errorCode: `documents.chat.http.${response.status}`, + }); + return null; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let collectedContent = ""; + let resolvedConversationId: string | null = null; + let lastError: string | null = null; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + const { events, rest } = parseSseChunk(buffer, text); + buffer = rest; + for (const evt of events) { + switch (evt.type) { + case "token": + collectedContent += evt.token; + setStreaming((prev) => ({ + ...prev, + content: prev.content + evt.token, + })); + break; + case "done": + resolvedConversationId = evt.conversationId; + break; + case "error": + lastError = evt.code; + break; + default: + // Additive wire frames (provenance / suggestion / …) never + // occur on the document route; ignore them defensively. + break; + } + } + } + const tail = parseSseChunk(buffer, "\n\n"); + for (const evt of tail.events) { + if (evt.type === "done") resolvedConversationId = evt.conversationId; + else if (evt.type === "error") lastError = evt.code; + } + } catch (err) { + if ((err as Error).name !== "AbortError") { + lastError = "documents.chat.stream"; + } + } + + setStreaming({ + content: collectedContent, + inProgress: false, + errorCode: lastError, + }); + + if (resolvedConversationId) { + // Reload the encrypted-on-disk thread (canonical ids + any outbound + // editorialisation). The persisted twin replaces the optimistic bubble. + await queryClient.invalidateQueries({ + queryKey: queryKeys.inboundDocumentChat(documentId), + }); + setOptimisticUser(null); + } + return resolvedConversationId; + }, + [documentId, queryClient], + ); + + const cancel = useCallback(() => abortRef.current?.abort(), []); + + return { + streaming, + isStreaming: streaming.inProgress, + optimisticUser, + send, + cancel, + reset, + }; +} diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index c49fb59a4..b69fb6660 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -66,4 +66,14 @@ export const documentKeys = { */ inboundDocumentAiCapability: () => ["documents", "inbound", "ai-capability"] as const, + /** + * The scoped "chat about this document" thread + * (`GET /api/documents/inbound/{id}/chat`). Keyed on the document id so two + * open detail sheets never share a thread cache slot; a sent turn's SSE + * `done` frame invalidates exactly this key so the persisted history reloads. + * Stays under the `["documents", "inbound"]` prefix so a document delete / + * bulk write still evicts it. + */ + inboundDocumentChat: (documentId: string) => + ["documents", "inbound", documentId, "chat"] as const, };