feat: added thinking time statistics; added LaTeX rendering support
This commit is contained in:
@@ -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%;
|
||||
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user