mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
09bc94a1f0
commit
635c45011b
11 files changed
+771
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user