Files
Jyotisha/frontend/src/components/chat-message-content.tsx
T

117 lines
3.5 KiB
TypeScript

"use client";
import { memo, useEffect, useState, type ReactNode } from "react";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { plainParagraphs } from "@/components/chat-message-paragraphs";
import { TechniqueAuditDisclosure } from "@/components/technique-audit-disclosure";
import { splitStableMarkdown } from "@/lib/chat-markdown-split";
import type { TechniqueAuditRow } from "@/lib/consultation-agent-events";
import {
resolveTechniqueAuditRows,
splitSpokenAnswerAndTechniqueAudit,
} from "@/lib/consultation-technique-audit";
type MarkdownRenderer = (text: string) => ReactNode;
let markdownRenderer: MarkdownRenderer | undefined;
let markdownRendererRequest: Promise<MarkdownRenderer> | undefined;
function loadMarkdownRenderer() {
markdownRendererRequest ??= import("@/components/chat-markdown-view").then((module) => {
markdownRenderer = module.renderChatMarkdown;
return module.renderChatMarkdown;
});
return markdownRendererRequest;
}
prefetchOnIdle(loadMarkdownRenderer);
function useMarkdownRenderer() {
const [renderer, setRenderer] = useState<MarkdownRenderer | undefined>(() => markdownRenderer);
useEffect(() => {
if (renderer) return;
let active = true;
void loadMarkdownRenderer().then((loaded) => {
if (active) setRenderer(() => loaded);
});
return () => { active = false; };
}, [renderer]);
return renderer;
}
function renderProse(text: string, renderMarkdown: MarkdownRenderer | undefined): ReactNode {
if (!text) return null;
return renderMarkdown
? renderMarkdown(text)
: (plainParagraphs(text) ?? []).map((paragraph, index) => (
<p key={index}>{paragraph}</p>
));
}
/**
* The completed part of a streaming answer. `memo` keeps React from calling the
* markdown parser again while `text` is unchanged, so a frame that only grew
* the tail costs one small parse instead of one over the whole answer.
*/
const StableMarkdownPrefix = memo(function StableMarkdownPrefix({
text,
renderMarkdown,
}: Readonly<{
text: string;
renderMarkdown: MarkdownRenderer | undefined;
}>) {
return <>{renderProse(text, renderMarkdown)}</>;
});
export function StreamingMarkdown({
text,
renderMarkdown,
}: Readonly<{
text: string;
renderMarkdown: MarkdownRenderer | undefined;
}>) {
const split = splitStableMarkdown(text);
return (
<>
{split.stable ? <StableMarkdownPrefix text={split.stable} renderMarkdown={renderMarkdown} /> : null}
{renderProse(split.tail, renderMarkdown)}
</>
);
}
export function ChatMessageContent({
text,
auditRows,
streaming = false,
}: {
text: string;
auditRows?: readonly TechniqueAuditRow[];
streaming?: boolean;
}) {
const renderMarkdown = useMarkdownRenderer();
const split = splitSpokenAnswerAndTechniqueAudit(text);
const rows = resolveTechniqueAuditRows(split, auditRows);
const spoken = split.spoken;
const showFoldedAudit = rows.length > 0 && split.rows.length === 0;
return (
<div className="message-answer">
{spoken ? (
<div className="message-markdown">
{streaming
? <StreamingMarkdown text={spoken} renderMarkdown={renderMarkdown} />
: renderMarkdown
? renderMarkdown(spoken)
: (plainParagraphs(spoken) ?? []).map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
</div>
) : null}
{showFoldedAudit ? <TechniqueAuditDisclosure rows={rows} /> : null}
</div>
);
}