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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 03:16:06 +02:00
1 parent 09bc94a1f0
commit 635c45011b
11 files changed
+771

No files matched your search

+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
@@ -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}
</>
) : (
<AiUnavailableHint reason={unavailableReason} />
@@ -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 (
<div
data-slot="document-chat-message"
data-role="user"
className="flex justify-end"
>
<div className="bg-primary/10 border-primary/20 text-foreground max-w-[85%] rounded-xl rounded-tr-sm border px-3 py-2 text-sm leading-relaxed">
{/* User text is verbatim: no chart-token strip, no Learn linkify. */}
<ProseBlocks text={message.content} strip={false} linkify={false} />
</div>
</div>
);
}
return (
<div
data-slot="document-chat-message"
data-role="assistant"
className="flex justify-start"
>
<div className="bg-card border-border text-foreground max-w-[85%] rounded-xl rounded-tl-sm border px-3 py-2 text-sm">
<ProseBlocks text={message.content} />
</div>
</div>
);
}
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<HTMLDivElement | null>(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 (
<div
data-slot="document-chat"
className="border-border/60 space-y-2 border-t pt-3"
>
<div className="flex items-center gap-2">
<MessageSquare
className="text-foreground size-4 shrink-0"
aria-hidden
/>
<p className="text-sm font-semibold">{t("documents.chat.title")}</p>
</div>
<p
data-slot="document-chat-not-indexed"
className="text-muted-foreground text-xs"
>
{t("documents.chat.notIndexed")}
</p>
</div>
);
}
// ── Collapsed entry: available, opens the scoped chat on tap ──
if (!open) {
return (
<div
data-slot="document-chat"
className="border-border/60 space-y-2 border-t pt-3"
>
<Button
type="button"
variant="outline"
size="sm"
data-slot="document-chat-open"
onClick={() => setOpen(true)}
>
<MessageSquare className="size-4" aria-hidden />
{t("documents.chat.open")}
</Button>
</div>
);
}
const streamErrorKey = streaming.errorCode
? documentChatErrorKey(streaming.errorCode)
: null;
const isEmpty =
!thread.isPending &&
messages.length === 0 &&
!showOptimistic &&
!isStreaming &&
streaming.content.length === 0;
return (
<div
data-slot="document-chat"
data-state="open"
className="border-border/60 space-y-3 border-t pt-3"
>
<div className="flex items-center gap-2">
<MessageSquare
className="text-foreground size-4 shrink-0"
aria-hidden
/>
<p className="text-sm font-semibold">{t("documents.chat.title")}</p>
</div>
<div
ref={listRef}
data-slot="document-chat-log"
role="log"
aria-live="polite"
aria-relevant="additions text"
aria-label={t("documents.chat.title")}
className="max-h-72 space-y-3 overflow-y-auto overscroll-contain"
>
{thread.isPending ? (
<div className="space-y-2" data-slot="document-chat-loading">
<Skeleton className="ml-auto h-8 w-2/3 rounded-xl" />
<Skeleton className="h-12 w-4/5 rounded-xl" />
</div>
) : null}
{isEmpty ? (
<p
data-slot="document-chat-empty"
className="text-muted-foreground text-xs"
>
{t("documents.chat.empty")}
</p>
) : null}
{messages.map((m) => (
<ChatTurn key={m.id} message={m} />
))}
{showOptimistic ? (
<ChatTurn
message={{
id: "optimistic",
role: "user",
content: optimisticUser,
createdAt: "",
}}
/>
) : null}
{isStreaming && streaming.content.length === 0 && !streamErrorKey ? (
<p
data-slot="document-chat-thinking"
className="text-muted-foreground text-xs"
>
{t("documents.chat.thinking")}
</p>
) : null}
{streaming.content.length > 0 ? (
<div
data-slot="document-chat-message"
data-role="assistant"
className="flex justify-start"
>
<div className="bg-card border-border text-foreground max-w-[85%] rounded-xl rounded-tl-sm border px-3 py-2 text-sm">
<StreamedProse
content={streaming.content}
streaming={isStreaming}
/>
</div>
</div>
) : null}
</div>
{streamErrorKey ? (
<p role="alert" className="text-destructive text-sm">
{t(streamErrorKey)}
</p>
) : null}
{thread.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.chat.errorHistory")}
</p>
) : null}
<form
className="flex items-end gap-2"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<Input
value={draft}
onChange={(e) => 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"
/>
<Button
type="submit"
size="icon"
data-slot="document-chat-send"
disabled={isStreaming || draft.trim().length === 0}
aria-label={t("documents.chat.send")}
>
<Send className="size-4" aria-hidden />
</Button>
</form>
{/* Safety-visible: answers describe the document, not medical advice —
consistent with the summary panel's "not a diagnosis". */}
<p
data-slot="document-chat-safety"
className="text-muted-foreground text-xs"
>
{t("documents.chat.safety")}
</p>
{/* Retiring the panel discards the live stream state (the persisted
history stays on disk and reloads next open). */}
<button
type="button"
data-slot="document-chat-close"
onClick={() => {
reset();
setOpen(false);
}}
className={cn(
"text-muted-foreground hover:text-foreground text-xs underline-offset-4 hover:underline",
)}
>
{t("documents.chat.close")}
</button>
</div>
);
}
@@ -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={
<DocumentChatPanel
documentId={doc.id}
indexed={doc.hasContentIndex}
/>
}
/>
{mutationError ? (
@@ -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<DocumentChatThread>({
queryKey: queryKeys.inboundDocumentChat(documentId ?? "none"),
enabled: enabled && documentId !== null,
staleTime: 30_000,
queryFn: async () => {
if (!documentId) return EMPTY_THREAD;
const page = await apiGet<CoachConversationsPage>(
`/api/documents/inbound/${documentId}/chat`,
);
const newest = page.conversations[0];
if (!newest) return EMPTY_THREAD;
const detail = await apiGet<CoachConversationDetailDTO>(
`/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<DocumentChatStreamingState>(EMPTY_STREAMING);
const [optimisticUser, setOptimisticUser] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<string | null> => {
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,
};
}
+10
View File
@@ -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,
};