272 lines
9.7 KiB
TypeScript
272 lines
9.7 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useMemo, useState } from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import { cn } from '@/lib/cn';
|
|
import type { Message } from '@/lib/types';
|
|
|
|
interface MessageBubbleProps {
|
|
message: Message;
|
|
}
|
|
|
|
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
|
|
const isUser = message.role === 'user';
|
|
const isSystem = message.role === 'system';
|
|
const [copied, setCopied] = useState(false);
|
|
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
|
const { response, thinking } = useMemo(
|
|
() => parseThinking(message.content, message.thinking),
|
|
[message.content, message.thinking],
|
|
);
|
|
|
|
const handleCopy = useCallback(async (): Promise<void> => {
|
|
try {
|
|
await navigator.clipboard.writeText(response);
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1800);
|
|
} catch (error) {
|
|
console.error('[MessageBubble] Failed to copy message:', error);
|
|
}
|
|
}, [response]);
|
|
|
|
if (isSystem) {
|
|
return (
|
|
<div className="flex justify-center">
|
|
<div
|
|
className="max-w-[42rem] rounded-full border px-3 py-1.5 text-xs backdrop-blur-sm"
|
|
style={{
|
|
borderColor: 'var(--color-border)',
|
|
backgroundColor: 'color-mix(in srgb, var(--color-surface) 70%, transparent)',
|
|
color: 'var(--color-muted)',
|
|
}}
|
|
>
|
|
<span>{response}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn('group flex', isUser ? 'justify-end' : 'justify-start')}>
|
|
<div
|
|
className={cn(
|
|
'flex max-w-[min(78ch,85%)] flex-col gap-2',
|
|
isUser ? 'items-end' : 'items-start',
|
|
)}
|
|
>
|
|
<div className={cn('flex items-center gap-2 text-[11px]', isUser && 'flex-row-reverse')}>
|
|
<span className="font-medium text-[var(--color-text-2)]">
|
|
{isUser ? 'You' : 'Assistant'}
|
|
</span>
|
|
{!isUser && message.model ? (
|
|
<span
|
|
className="rounded-full border px-2 py-0.5 font-medium text-[var(--color-text-2)]"
|
|
style={{
|
|
backgroundColor: 'color-mix(in srgb, var(--color-surface-2) 82%, transparent)',
|
|
borderColor: 'var(--color-border)',
|
|
}}
|
|
title={message.provider ? `Provider: ${message.provider}` : undefined}
|
|
>
|
|
{message.model}
|
|
</span>
|
|
) : null}
|
|
{!isUser && typeof message.totalTokens === 'number' && message.totalTokens > 0 ? (
|
|
<span
|
|
className="rounded-full border px-2 py-0.5 text-[var(--color-muted)]"
|
|
style={{ borderColor: 'var(--color-border)' }}
|
|
>
|
|
{formatTokenCount(message.totalTokens)}
|
|
</span>
|
|
) : null}
|
|
<span className="text-[var(--color-muted)]">{formatTimestamp(message.createdAt)}</span>
|
|
</div>
|
|
|
|
{thinking && !isUser ? (
|
|
<div
|
|
className="w-full overflow-hidden rounded-2xl border"
|
|
style={{
|
|
backgroundColor: 'color-mix(in srgb, var(--color-surface-2) 88%, transparent)',
|
|
borderColor: 'var(--color-border)',
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setThinkingExpanded((prev) => !prev)}
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-[var(--color-text-2)] transition-colors hover:bg-black/5"
|
|
aria-expanded={thinkingExpanded}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'inline-block text-[10px] transition-transform',
|
|
thinkingExpanded && 'rotate-90',
|
|
)}
|
|
>
|
|
▶
|
|
</span>
|
|
<span>Chain of thought</span>
|
|
<span className="ml-auto text-[var(--color-muted)]">
|
|
{thinkingExpanded ? 'Hide' : 'Show'}
|
|
</span>
|
|
</button>
|
|
{thinkingExpanded ? (
|
|
<pre
|
|
className="overflow-x-auto border-t px-3 py-3 font-mono text-xs leading-6 whitespace-pre-wrap"
|
|
style={{
|
|
borderColor: 'var(--color-border)',
|
|
backgroundColor: 'var(--color-bg-deep)',
|
|
color: 'var(--color-text-2)',
|
|
}}
|
|
>
|
|
{thinking}
|
|
</pre>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<div
|
|
className={cn(
|
|
'relative w-full rounded-3xl px-4 py-3 text-sm shadow-[var(--shadow-ms-sm)]',
|
|
!isUser && 'border',
|
|
)}
|
|
style={{
|
|
backgroundColor: isUser ? 'var(--color-ms-blue-500)' : 'var(--color-surface)',
|
|
color: isUser ? '#fff' : 'var(--color-text)',
|
|
borderColor: isUser ? 'transparent' : 'var(--color-border)',
|
|
}}
|
|
>
|
|
<div className="max-w-none">
|
|
<ReactMarkdown
|
|
components={{
|
|
p: ({ children }) => <p className="mb-3 leading-7 last:mb-0">{children}</p>,
|
|
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 last:mb-0">{children}</ul>,
|
|
ol: ({ children }) => (
|
|
<ol className="mb-3 list-decimal pl-5 last:mb-0">{children}</ol>
|
|
),
|
|
li: ({ children }) => <li className="mb-1">{children}</li>,
|
|
a: ({ href, children }) => (
|
|
<a
|
|
href={href}
|
|
className="font-medium underline underline-offset-4"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
{children}
|
|
</a>
|
|
),
|
|
pre: ({ children }) => <div className="mb-3 last:mb-0">{children}</div>,
|
|
code: ({ className, children, ...props }) => {
|
|
const language = className?.replace('language-', '');
|
|
const content = String(children).replace(/\n$/, '');
|
|
const isInline = !className;
|
|
|
|
if (isInline) {
|
|
return (
|
|
<code
|
|
className="rounded-md px-1.5 py-0.5 font-mono text-[0.9em]"
|
|
style={{
|
|
backgroundColor:
|
|
'color-mix(in srgb, var(--color-bg-deep) 76%, transparent)',
|
|
}}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</code>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="overflow-hidden rounded-2xl border"
|
|
style={{
|
|
backgroundColor: 'var(--color-bg-deep)',
|
|
borderColor: 'var(--color-border)',
|
|
}}
|
|
>
|
|
<div
|
|
className="border-b px-3 py-2 font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--color-muted)]"
|
|
style={{ borderColor: 'var(--color-border)' }}
|
|
>
|
|
{language || 'code'}
|
|
</div>
|
|
<pre className="overflow-x-auto p-3">
|
|
<code
|
|
className={cn('font-mono text-[13px] leading-6', className)}
|
|
{...props}
|
|
>
|
|
{content}
|
|
</code>
|
|
</pre>
|
|
</div>
|
|
);
|
|
},
|
|
blockquote: ({ children }) => (
|
|
<blockquote
|
|
className="mb-3 border-l-2 pl-4 italic last:mb-0"
|
|
style={{ borderColor: 'var(--color-ms-blue-500)' }}
|
|
>
|
|
{children}
|
|
</blockquote>
|
|
),
|
|
}}
|
|
>
|
|
{response}
|
|
</ReactMarkdown>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleCopy()}
|
|
className="absolute -right-2 -top-2 rounded-full border p-2 opacity-0 shadow-[var(--shadow-ms-md)] transition-all group-hover:opacity-100 focus:opacity-100"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface)',
|
|
borderColor: 'var(--color-border)',
|
|
color: copied ? 'var(--color-success)' : 'var(--color-text-2)',
|
|
}}
|
|
aria-label={copied ? 'Copied' : 'Copy message'}
|
|
title={copied ? 'Copied' : 'Copy message'}
|
|
>
|
|
{copied ? '✓' : '⧉'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function parseThinking(
|
|
content: string,
|
|
thinking?: string,
|
|
): { response: string; thinking: string | null } {
|
|
if (thinking) {
|
|
return { response: content, thinking };
|
|
}
|
|
|
|
const regex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
|
|
const matches = [...content.matchAll(regex)];
|
|
if (matches.length === 0) {
|
|
return { response: content, thinking: null };
|
|
}
|
|
|
|
return {
|
|
response: content.replace(regex, '').trim(),
|
|
thinking:
|
|
matches
|
|
.map((match) => match[1]?.trim() ?? '')
|
|
.filter(Boolean)
|
|
.join('\n\n') || null,
|
|
};
|
|
}
|
|
|
|
function formatTimestamp(createdAt: string): string {
|
|
return new Date(createdAt).toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
function formatTokenCount(totalTokens: number): string {
|
|
if (totalTokens >= 1_000_000) return `${(totalTokens / 1_000_000).toFixed(1)}M tokens`;
|
|
if (totalTokens >= 1_000) return `${(totalTokens / 1_000).toFixed(1)}k tokens`;
|
|
return `${totalTokens} tokens`;
|
|
}
|