mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +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
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user