feat: added thinking time statistics; added LaTeX rendering support

This commit is contained in:
Xiaohan-Tian
2026-05-15 12:07:45 -07:00
parent 42b9662bb4
commit ac2d924711
6 changed files with 426 additions and 8 deletions
+10
View File
@@ -278,6 +278,16 @@
color: #ccc;
}
.message-content .katex-display {
margin: 8px 0;
overflow-x: auto;
overflow-y: hidden;
}
.message-content .katex {
max-width: 100%;
}
.message-content table {
border-collapse: collapse;
width: 100%;
+73 -2
View File
@@ -1,8 +1,12 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import AssistantMessage from './AssistantMessage';
describe('AssistantMessage', () => {
afterEach(() => {
vi.useRealTimers();
});
it.each([
'<span class="processing-wave">Thinking...</span> click here to abort.',
'<span class="processing-wave">Processing...</span> 3 tokens received. click here to abort.'
@@ -22,4 +26,71 @@ describe('AssistantMessage', () => {
fireEvent.click(abortButton);
expect(onAbort).toHaveBeenCalledTimes(1);
});
it('shows and updates the thinking timer while waiting for tokens', () => {
vi.useFakeTimers();
render(
<AssistantMessage
content={'<span class="processing-wave">Thinking...</span> click here to abort.'}
isStreaming
onAbort={vi.fn()}
/>
);
expect(screen.getByText('Thinking for 0s...')).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(12_000);
});
expect(screen.getByText('Thinking for 12s...')).toBeInTheDocument();
});
it('shows minute formatting after one minute of thinking', () => {
vi.useFakeTimers();
render(
<AssistantMessage
content={'<span class="processing-wave">Thinking...</span> click here to abort.'}
isStreaming
onAbort={vi.fn()}
/>
);
act(() => {
vi.advanceTimersByTime(65_000);
});
expect(screen.getByText('Thinking for 1m 05s...')).toBeInTheDocument();
});
it('renders inline LaTeX with KaTeX markup', () => {
const { container } = render(
<AssistantMessage content={'C Major (I) $\\rightarrow$ C4, E4, G4'} />
);
expect(container.querySelector('.katex')).toBeInTheDocument();
expect(container.querySelector('.katex-mathml')).toBeInTheDocument();
expect(screen.queryByText('$\\rightarrow$')).not.toBeInTheDocument();
});
it('renders block LaTeX as display math', () => {
const { container } = render(
<AssistantMessage content={'$$\n\\frac{1}{2}mv^2\n$$'} />
);
expect(container.querySelector('.katex-display')).toBeInTheDocument();
});
it('renders markdown code blocks alongside LaTeX', () => {
const { container } = render(
<AssistantMessage content={'Inline math $x^2$ and code:\n```ts\nconst value = 1;\n```'} />
);
expect(container.querySelector('.katex')).toBeInTheDocument();
const codeElement = container.querySelector('code.language-ts');
expect(codeElement).toBeInTheDocument();
expect(codeElement).toHaveTextContent('const value = 1;');
});
});
+43 -4
View File
@@ -1,6 +1,8 @@
import React, { memo } from 'react';
import React, { memo, useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import type { PerformanceInfo } from '../../agent/llm/StreamingTypes';
@@ -40,11 +42,45 @@ const formatTps = (value?: number): string | null => {
return value.toFixed(1);
};
const THINKING_LABEL = 'Thinking...';
const PROCESSING_LABEL = 'Processing...';
const formatThinkingDuration = (elapsedSeconds: number): string => {
if (elapsedSeconds < 60) {
return `Thinking for ${elapsedSeconds}s...`;
}
const minutes = Math.floor(elapsedSeconds / 60);
const seconds = elapsedSeconds % 60;
return `Thinking for ${minutes}m ${seconds.toString().padStart(2, '0')}s...`;
};
const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreaming, onAbort, performanceInfo }) => {
const prefillTps = formatTps(performanceInfo?.prefillTps);
const generationTps = formatTps(performanceInfo?.generationTps);
const hasPerformanceInfo = Boolean(prefillTps || generationTps);
const processingWaveLabels = ['Thinking...', 'Processing...'];
const [thinkingElapsedSeconds, setThinkingElapsedSeconds] = useState(0);
const processingWaveLabels = [THINKING_LABEL, PROCESSING_LABEL];
const isThinking = isStreaming && content.includes(`<span class="processing-wave">${THINKING_LABEL}</span>`);
useEffect(() => {
if (!isThinking) {
setThinkingElapsedSeconds(0);
return;
}
setThinkingElapsedSeconds(0);
const startedAt = Date.now();
const intervalId = window.setInterval(() => {
const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1000);
setThinkingElapsedSeconds(elapsedSeconds);
}, 1000);
return () => {
window.clearInterval(intervalId);
};
}, [isThinking]);
const renderContent = () => {
// Handle special abort link for streaming messages
@@ -59,7 +95,9 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
processingWaveMarkup,
''
);
const waveLabel = processingWaveLabels.find(label => processingWaveMarkup.includes(label)) ?? 'Thinking...';
const waveLabel = processingWaveMarkup.includes(THINKING_LABEL)
? formatThinkingDuration(thinkingElapsedSeconds)
: processingWaveLabels.find(label => processingWaveMarkup.includes(label)) ?? THINKING_LABEL;
return (
<span>
@@ -87,7 +125,8 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
code: CodeComponent,
}}