117 lines
3.5 KiB
TypeScript
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>
|
|
);
|
|
}
|