From d8025a2503d2c84c43b268d5a168575fca129c5f Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Mon, 5 May 2025 03:50:58 -0700 Subject: [PATCH] improvement(chat-client): ui/ux with markdown styling and streaming --- .../[subdomain]/components/chat-client.tsx | 131 +++++++++------ .../markdown-renderer/markdown-renderer.tsx | 153 ++++++++++++++++++ 2 files changed, 233 insertions(+), 51 deletions(-) create mode 100644 sim/app/chat/[subdomain]/components/components/markdown-renderer/markdown-renderer.tsx diff --git a/sim/app/chat/[subdomain]/components/chat-client.tsx b/sim/app/chat/[subdomain]/components/chat-client.tsx index 9075f52262..50f80d8010 100644 --- a/sim/app/chat/[subdomain]/components/chat-client.tsx +++ b/sim/app/chat/[subdomain]/components/chat-client.tsx @@ -1,13 +1,21 @@ 'use client' -import { KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react' +import { + Children, + isValidElement, + KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { ArrowUp, Loader2, Lock, Mail } from 'lucide-react' -import ReactMarkdown from 'react-markdown' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { OTPInputForm } from '@/components/ui/input-otp-form' import { ScrollArea } from '@/components/ui/scroll-area' import { cn } from '@/lib/utils' +import MarkdownRenderer from './components/markdown-renderer/markdown-renderer' // Define message type interface ChatMessage { @@ -31,40 +39,6 @@ interface ChatConfig { authType?: 'public' | 'password' | 'email' } -// Markdown renderer component with proper styling -function MarkdownRenderer({ content }: { content: string }) { - return ( -
- {content} -
- ) -} - // ChatGPT-style message component function ClientChatMessage({ message }: { message: ChatMessage }) { // Check if content is a JSON object @@ -427,6 +401,9 @@ export default function ChatClient({ subdomain }: { subdomain: string }) { }, ]) + // Stop showing loading indicator once streaming begins + setIsLoading(false) + // Ensure the response body exists and is a ReadableStream const reader = response.body?.getReader() if (reader) { @@ -453,7 +430,11 @@ export default function ChatClient({ subdomain }: { subdomain: string }) { console.log('Message response:', responseData) // Handle different response formats from API - if (responseData.multipleOutputs && responseData.contents && Array.isArray(responseData.contents)) { + if ( + responseData.multipleOutputs && + responseData.contents && + Array.isArray(responseData.contents) + ) { // For multiple outputs, create separate assistant messages for each const assistantMessages = responseData.contents.map((content: any) => { // Format the content appropriately @@ -566,17 +547,65 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
{authRequired === 'password' ? ( -
- - setPassword(e.target.value)} - onKeyDown={handleAuthKeyDown} - placeholder="Enter password" - className="pl-10" - disabled={isAuthenticating} - /> +
+
+
+
+ +
+
+ +

Password Required

+

+ Enter the password to access this chat +

+ +
{ + e.preventDefault() + handleAuthenticate() + }} + > +
+
+ + setPassword(e.target.value)} + placeholder="Enter password" + disabled={isAuthenticating} + className="w-full" + /> +
+ + {authError && ( +
{authError}
+ )} + + +
+
+
) : (
@@ -704,14 +733,14 @@ export default function ChatClient({ subdomain }: { subdomain: string }) { @keyframes growShrink { 0%, 100% { - transform: scale(0.9) + transform: scale(0.9); } 50% { - transform: scale(1.1) + transform: scale(1.1); } } .loading-dot { - animation: growShrink 1.5s infinite ease-in-out + animation: growShrink 1.5s infinite ease-in-out; } `} diff --git a/sim/app/chat/[subdomain]/components/components/markdown-renderer/markdown-renderer.tsx b/sim/app/chat/[subdomain]/components/components/markdown-renderer/markdown-renderer.tsx new file mode 100644 index 0000000000..9cbfbf4acf --- /dev/null +++ b/sim/app/chat/[subdomain]/components/components/markdown-renderer/markdown-renderer.tsx @@ -0,0 +1,153 @@ +import ReactMarkdown from 'react-markdown' + +export default function MarkdownRenderer({ content }: { content: string }) { + const customComponents = { + // Paragraph + p: ({ children }: React.HTMLAttributes) => ( +

{children}

+ ), + + // Headings + h1: ({ children }: React.HTMLAttributes) => ( +

{children}

+ ), + h2: ({ children }: React.HTMLAttributes) => ( +

{children}

+ ), + h3: ({ children }: React.HTMLAttributes) => ( +

{children}

+ ), + h4: ({ children }: React.HTMLAttributes) => ( +

{children}

+ ), + + // Lists + ul: ({ children }: React.HTMLAttributes) => ( +
    {children}
+ ), + ol: ({ children }: React.HTMLAttributes) => ( +
    {children}
+ ), + li: ({ children }: React.HTMLAttributes) => ( +
  • {children}
  • + ), + + // Code blocks + pre: ({ children }: React.HTMLAttributes) => ( +
    +        {children}
    +      
    + ), + + // Inline code + code: ({ + inline, + className, + children, + ...props + }: React.HTMLAttributes & { className?: string; inline?: boolean }) => { + if (inline) { + return ( + + {children} + + ) + } + + // Extract language from className (format: language-xxx) + const match = /language-(\w+)/.exec(className || '') + const language = match ? match[1] : '' + + return ( +
    + {language && ( +
    + {language} +
    + )} + + {children} + +
    + ) + }, + + // Blockquotes + blockquote: ({ children }: React.HTMLAttributes) => ( +
    +
    {children}
    +
    + ), + + // Horizontal rule + hr: () =>
    , + + // Links + a: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( + + {children} + + ), + + // Tables + table: ({ children }: React.TableHTMLAttributes) => ( +
    + {children}
    +
    + ), + thead: ({ children }: React.HTMLAttributes) => ( + + {children} + + ), + tbody: ({ children }: React.HTMLAttributes) => ( + + {children} + + ), + tr: ({ children, ...props }: React.HTMLAttributes) => ( + + {children} + + ), + th: ({ children }: React.ThHTMLAttributes) => ( + + {children} + + ), + td: ({ children }: React.TdHTMLAttributes) => ( + {children} + ), + + // Images + img: ({ src, alt, ...props }: React.ImgHTMLAttributes) => ( + {alt + ), + } + + // Process text to clean up unnecessary whitespace and formatting issues + const processedContent = content + .replace(/\n{2,}/g, '\n\n') // Replace multiple newlines with exactly double newlines + .replace(/^(#{1,6})\s+(.+?)\n{2,}/gm, '$1 $2\n') // Reduce space after headings to single newline + .trim() + + return ( +
    + {processedContent} +
    + ) +}