feat(web): port chat UI — model selector, keybindings, thinking display, styled header
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Message } from '@/lib/types';
|
||||
|
||||
@@ -9,27 +11,261 @@ interface MessageBubbleProps {
|
||||
|
||||
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('flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div className={cn('group flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[75%] rounded-xl px-4 py-3 text-sm',
|
||||
isUser
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border border-surface-border bg-surface-elevated text-text-primary',
|
||||
'flex max-w-[min(78ch,85%)] flex-col gap-2',
|
||||
isUser ? 'items-end' : 'items-start',
|
||||
)}
|
||||
>
|
||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||
<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('mt-1 text-right text-xs', isUser ? 'text-blue-200' : 'text-text-muted')}
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
{new Date(message.createdAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
<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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user