perf(playground): improve chat markdown rendering
- refine assistant and user message surfaces so chat content matches the app UI. - normalize markdown typography, tables, images, lists, blockquotes, and details rendering. - add indentation cues for collapsible reasoning and source sections.
This commit is contained in:
+1
-1
@@ -29,7 +29,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-auto', className)}
|
||||
className={cn('relative min-h-0 flex-1 overflow-hidden', className)}
|
||||
initial='smooth'
|
||||
resize='smooth'
|
||||
role='log'
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-4 text-sm',
|
||||
'border-border/70 mt-3 ml-2 border-l pl-4 text-sm',
|
||||
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-muted-foreground data-closed:animate-out data-open:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
|
||||
+371
-2
@@ -18,7 +18,15 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { type ComponentProps, memo, type ReactNode } from 'react'
|
||||
import {
|
||||
Children,
|
||||
type ComponentProps,
|
||||
type JSX,
|
||||
isValidElement,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Streamdown, type Components } from 'streamdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
@@ -33,6 +41,11 @@ type CodeComponentProps = ComponentProps<'code'> & {
|
||||
'data-block'?: boolean
|
||||
}
|
||||
|
||||
type MarkdownElementProps<T extends keyof JSX.IntrinsicElements> =
|
||||
ComponentProps<T> & {
|
||||
node?: unknown
|
||||
}
|
||||
|
||||
function getCodeText(children: ReactNode) {
|
||||
if (typeof children === 'string') {
|
||||
return children.replace(/\n$/, '')
|
||||
@@ -49,7 +62,354 @@ function getCodeLanguage(className?: string) {
|
||||
return className?.match(/language-([\w#+.-]+)/)?.[1] ?? 'plaintext'
|
||||
}
|
||||
|
||||
function isSummaryElement(child: ReactNode) {
|
||||
return isValidElement(child) && child.type === 'summary'
|
||||
}
|
||||
|
||||
function MarkdownImage({
|
||||
alt,
|
||||
className,
|
||||
node: _node,
|
||||
src,
|
||||
...props
|
||||
}: MarkdownElementProps<'img'>) {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
if (!src || hasError) {
|
||||
return (
|
||||
<span className='border-border/70 text-muted-foreground my-4 inline-flex rounded-md border px-3 py-2 text-xs italic'>
|
||||
{alt || 'Image not available'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
alt={alt}
|
||||
className={cn(
|
||||
'border-border/70 my-4 block h-auto max-h-96 max-w-full rounded-lg border object-contain',
|
||||
className
|
||||
)}
|
||||
loading='lazy'
|
||||
onError={() => setHasError(true)}
|
||||
src={src}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const responseComponents: Components = {
|
||||
h1({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h1'>) {
|
||||
return (
|
||||
<h1
|
||||
className={cn(
|
||||
'mt-6 mb-3 text-xl font-semibold tracking-normal',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
},
|
||||
h2({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h2'>) {
|
||||
return (
|
||||
<h2
|
||||
className={cn(
|
||||
'mt-6 mb-3 text-lg font-semibold tracking-normal',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
},
|
||||
h3({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h3'>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
'mt-5 mb-2 text-base font-semibold tracking-normal',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
},
|
||||
h4({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h4'>) {
|
||||
return (
|
||||
<h4
|
||||
className={cn('mt-5 mb-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
},
|
||||
h5({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h5'>) {
|
||||
return (
|
||||
<h5
|
||||
className={cn(
|
||||
'text-muted-foreground mt-4 mb-2 text-sm font-semibold',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
)
|
||||
},
|
||||
h6({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h6'>) {
|
||||
return (
|
||||
<h6
|
||||
className={cn(
|
||||
'text-muted-foreground mt-4 mb-2 text-xs font-semibold uppercase',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h6>
|
||||
)
|
||||
},
|
||||
ul({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
className={cn(
|
||||
'my-3 list-outside list-disc space-y-1.5 pl-5',
|
||||
'[&.contains-task-list]:list-none [&.contains-task-list]:pl-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
ol({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'ol'>) {
|
||||
return (
|
||||
<ol
|
||||
className={cn(
|
||||
'my-3 list-outside list-decimal space-y-1.5 pl-5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
)
|
||||
},
|
||||
li({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'marker:text-muted-foreground pl-1 leading-7',
|
||||
'[&.task-list-item]:flex [&.task-list-item]:items-start [&.task-list-item]:gap-2 [&.task-list-item]:pl-0',
|
||||
'[&.task-list-item>input]:accent-primary [&.task-list-item>input]:mt-1.5 [&.task-list-item>input]:size-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
details({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'details'>) {
|
||||
const childArray = Children.toArray(children)
|
||||
const summaryChildren = childArray.filter(isSummaryElement)
|
||||
const contentChildren = childArray.filter(
|
||||
(child) => !isSummaryElement(child)
|
||||
)
|
||||
|
||||
return (
|
||||
<details className={cn('my-4', className)} {...props}>
|
||||
{summaryChildren}
|
||||
{contentChildren.length > 0 && (
|
||||
<div className='border-border/70 ml-5 border-l pl-4'>
|
||||
{contentChildren}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)
|
||||
},
|
||||
summary({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'summary'>) {
|
||||
return (
|
||||
<summary
|
||||
className={cn(
|
||||
'text-foreground marker:text-muted-foreground mb-2 cursor-pointer text-sm font-semibold',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</summary>
|
||||
)
|
||||
},
|
||||
blockquote({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'blockquote'>) {
|
||||
return (
|
||||
<blockquote
|
||||
className={cn(
|
||||
'border-border text-muted-foreground my-4 border-l-2 pl-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
},
|
||||
hr({ className, node: _node, ...props }: MarkdownElementProps<'hr'>) {
|
||||
return <hr className={cn('border-border/70 my-6', className)} {...props} />
|
||||
},
|
||||
img: MarkdownImage,
|
||||
table({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'table'>) {
|
||||
return (
|
||||
<div className='border-border/70 my-4 w-full overflow-x-auto rounded-lg border'>
|
||||
<table
|
||||
className={cn(
|
||||
'w-full min-w-max border-separate border-spacing-0 text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
thead({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'thead'>) {
|
||||
return (
|
||||
<thead className={cn('bg-muted/60', className)} {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
)
|
||||
},
|
||||
tbody({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'tbody'>) {
|
||||
return (
|
||||
<tbody className={cn('divide-border/70 divide-y', className)} {...props}>
|
||||
{children}
|
||||
</tbody>
|
||||
)
|
||||
},
|
||||
tr({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'tr'>) {
|
||||
return (
|
||||
<tr className={cn('border-border/70', className)} {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
)
|
||||
},
|
||||
th({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
},
|
||||
td({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'td'>) {
|
||||
return (
|
||||
<td className={cn('px-3 py-2 align-top', className)} {...props}>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
},
|
||||
code({ children, className, ...props }: CodeComponentProps) {
|
||||
if (!props['data-block']) {
|
||||
return (
|
||||
@@ -107,7 +467,16 @@ export const Response = memo(
|
||||
return (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
'size-full min-w-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
'size-full min-w-0 text-pretty',
|
||||
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
'[&_p]:my-3 [&_p]:leading-7',
|
||||
'[&_strong]:text-foreground [&_strong]:font-semibold',
|
||||
'[&_a]:text-primary [&_a]:underline-offset-4 hover:[&_a]:underline',
|
||||
'[&_details>summary~*]:border-border/70 [&_details]:my-4 [&_details>summary~*]:ml-5 [&_details>summary~*]:border-l [&_details>summary~*]:pl-4',
|
||||
'[&_summary]:text-foreground [&_summary::marker]:text-muted-foreground [&_summary]:mb-2 [&_summary]:cursor-pointer [&_summary]:text-sm [&_summary]:font-semibold',
|
||||
'[&_[data-streamdown=table-wrapper]]:border-0 [&_[data-streamdown=table-wrapper]]:bg-transparent [&_[data-streamdown=table-wrapper]]:p-0 [&_[data-streamdown=table-wrapper]]:shadow-none',
|
||||
'[&_[data-streamdown=table-wrapper]>div:first-child]:hidden',
|
||||
'[&_[data-streamdown=table-wrapper]>div:last-child]:border-border/70 [&_[data-streamdown=table-wrapper]>div:last-child]:rounded-lg',
|
||||
className
|
||||
)}
|
||||
components={{ ...responseComponents, ...components }}
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ export const SourcesContent = ({
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-3 flex w-fit flex-col gap-2',
|
||||
'border-border/70 mt-3 ml-2 flex w-fit flex-col gap-2 border-l pl-4',
|
||||
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 data-closed:animate-out data-open:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -98,11 +98,15 @@ export function PlaygroundChat({
|
||||
|
||||
return (
|
||||
<Message
|
||||
className='group flex-row-reverse'
|
||||
className={
|
||||
message.from === 'assistant'
|
||||
? 'group flex-row-reverse py-3'
|
||||
: 'group flex-row-reverse py-1.5'
|
||||
}
|
||||
from={message.from}
|
||||
key={message.key}
|
||||
>
|
||||
<div className='w-full min-w-0 flex-1 basis-full py-1'>
|
||||
<div className='w-full min-w-0 flex-1 basis-full'>
|
||||
{isEditing ? (
|
||||
<PlaygroundMessageEditor
|
||||
editText={editText}
|
||||
@@ -124,7 +128,7 @@ export function PlaygroundChat({
|
||||
onDelete={onDeleteMessage}
|
||||
isGenerating={isGenerating}
|
||||
alwaysVisible={alwaysShowActions}
|
||||
className='mt-1'
|
||||
className='mt-2'
|
||||
/>
|
||||
}
|
||||
message={message}
|
||||
|
||||
+2
-2
@@ -73,9 +73,9 @@ export function Playground() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='relative flex size-full flex-col overflow-hidden'>
|
||||
<div className='relative flex size-full min-h-0 flex-col overflow-hidden'>
|
||||
{/* Full-width scroll container: scrolling works even over side whitespace */}
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
<div className='flex min-h-0 flex-1 flex-col overflow-hidden'>
|
||||
<PlaygroundChat
|
||||
messages={messages}
|
||||
onRegenerateMessage={handleRegenerateMessage}
|
||||
|
||||
+24
-10
@@ -22,25 +22,39 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export function getMessageContentStyles() {
|
||||
return [
|
||||
// Assistant content fills the row; user bubble auto-width
|
||||
// Assistant content reads like a document column; user bubble stays compact.
|
||||
'group-[.is-assistant]:w-full',
|
||||
'group-[.is-assistant]:max-w-none',
|
||||
'group-[.is-assistant]:max-w-[78ch]',
|
||||
'group-[.is-user]:w-fit',
|
||||
// User bubble: rounded and themed background
|
||||
|
||||
// User bubble: compact surface that stays calm in both light and dark themes.
|
||||
'group-[.is-user]:rounded-2xl',
|
||||
'group-[.is-user]:rounded-br-md',
|
||||
'group-[.is-user]:border',
|
||||
'group-[.is-user]:border-border/70',
|
||||
'group-[.is-user]:bg-muted/70',
|
||||
'group-[.is-user]:px-4',
|
||||
'group-[.is-user]:py-2.5',
|
||||
'group-[.is-user]:text-foreground',
|
||||
'group-[.is-user]:bg-secondary',
|
||||
'dark:group-[.is-user]:bg-muted',
|
||||
'group-[.is-user]:rounded-3xl',
|
||||
// Assistant bubble: flat serif style (one-sided style)
|
||||
'group-[.is-assistant]:text-foreground',
|
||||
'group-[.is-user]:shadow-sm',
|
||||
'group-[.is-user]:shadow-black/5',
|
||||
|
||||
// Assistant response: flat reading surface using the active UI font axis.
|
||||
'group-[.is-assistant]:bg-transparent',
|
||||
'group-[.is-assistant]:p-0',
|
||||
'group-[.is-assistant]:font-serif',
|
||||
'group-[.is-assistant]:rounded-none',
|
||||
'group-[.is-assistant]:overflow-visible',
|
||||
'group-[.is-assistant]:[font-family:var(--font-body)]',
|
||||
'group-[.is-assistant]:text-foreground/90',
|
||||
|
||||
// Preferred readable widths and wrapping
|
||||
'leading-relaxed',
|
||||
'text-[0.95rem]',
|
||||
'leading-6',
|
||||
'break-words',
|
||||
'whitespace-pre-wrap',
|
||||
'sm:text-[0.975rem]',
|
||||
'sm:leading-7',
|
||||
|
||||
// Cap user bubble width so it does not look like a banner
|
||||
'group-[.is-user]:max-w-[85%]',
|
||||
'sm:group-[.is-user]:max-w-[62ch]',
|
||||
|
||||
Reference in New Issue
Block a user