99 lines
4.0 KiB
TypeScript
99 lines
4.0 KiB
TypeScript
import { memo, useMemo, useState } from "react";
|
|
import { Box, Collapse, Group, Text, UnstyledButton } from "@mantine/core";
|
|
import { IconChevronDown } from "@tabler/icons-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
|
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
|
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
|
|
|
interface ReasoningBlockProps {
|
|
/** The streamed/persisted reasoning (thinking) text. May be empty when the
|
|
* provider reports only a reasoning token COUNT without the text. */
|
|
text: string;
|
|
/** Authoritative reasoning token count from `usage.reasoningTokens`, when the
|
|
* step/turn has finished. When absent (or 0) the count is estimated from the
|
|
* text length so it ticks live as the reasoning streams in. */
|
|
tokens?: number;
|
|
}
|
|
|
|
/**
|
|
* Collapsible "Thinking" block for an assistant `reasoning` part. Mirrors Claude
|
|
* Code's surfacing of the model's thinking: a header that shows the thinking
|
|
* token count (authoritative when the step has reported usage, else a live
|
|
* estimate from the streamed text) and an expandable body with the reasoning
|
|
* prose. Collapsed by default so it never crowds out the answer.
|
|
*
|
|
* Providers that don't stream reasoning TEXT still render this block from the
|
|
* authoritative count alone (header only, empty body) so the cost is visible.
|
|
*/
|
|
function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
|
|
// Authoritative count wins; otherwise estimate live from the streamed text.
|
|
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
|
|
const trimmed = text.trim();
|
|
// Memoize the markdown render so toggling `open` (or a parent re-render caused
|
|
// by an unrelated streamed delta) does not re-parse the reasoning text; it
|
|
// recomputes only when the reasoning text itself changes (while it streams in).
|
|
// collapseBlankLines collapses the blank-line gaps the model emits between every
|
|
// list item / paragraph so the reasoning renders compactly (tight lists, joined
|
|
// paragraphs) — ONLY here, not in the normal answer.
|
|
const html = useMemo(
|
|
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
|
|
[trimmed],
|
|
);
|
|
|
|
return (
|
|
<Box className={classes.reasoningBlock} mb={6}>
|
|
<UnstyledButton
|
|
onClick={() => setOpen((o) => !o)}
|
|
// No body to expand when the provider reported only a token count.
|
|
disabled={!trimmed}
|
|
aria-expanded={open}
|
|
>
|
|
<Group gap={6} wrap="nowrap" align="center">
|
|
<IconChevronDown
|
|
size={12}
|
|
style={{
|
|
transform: open ? "none" : "rotate(-90deg)",
|
|
transition: "transform 150ms ease",
|
|
opacity: trimmed ? 1 : 0.4,
|
|
}}
|
|
/>
|
|
<Text size="xs" c="dimmed">
|
|
{count > 0
|
|
? t("Thinking · {{count}} tokens", { count })
|
|
: t("Thinking")}
|
|
</Text>
|
|
</Group>
|
|
</UnstyledButton>
|
|
|
|
{trimmed && (
|
|
<Collapse in={open}>
|
|
{html ? (
|
|
<div
|
|
className={classes.reasoningText}
|
|
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
/>
|
|
) : (
|
|
<Text
|
|
className={classes.reasoningText}
|
|
style={{ whiteSpace: "pre-wrap" }}
|
|
>
|
|
{trimmed}
|
|
</Text>
|
|
)}
|
|
</Collapse>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default
|
|
// shallow compare), so a parent re-render during streaming of OTHER content does
|
|
// not re-run the markdown parse for an already-finalized reasoning block.
|
|
export default memo(ReasoningBlock);
|