diff --git a/frontend/web/src/components/chat-body/ChatBody.tsx b/frontend/web/src/components/chat-body/ChatBody.tsx index 41dc652..fa2bf23 100644 --- a/frontend/web/src/components/chat-body/ChatBody.tsx +++ b/frontend/web/src/components/chat-body/ChatBody.tsx @@ -1,304 +1,298 @@ -import ReactMarkdown from 'react-markdown'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { IoCopyOutline, IoCheckmark, IoDocumentText, IoImage, IoDownload, IoSearch } from 'react-icons/io5'; -import { FaFileCsv } from "react-icons/fa6"; -import { useState } from 'react'; -import styles from './ChatBody.module.css'; -import Image from 'next/image'; - -interface Message { - id: string; - text: string; - isUser: boolean; - timestamp: Date; - isStreaming?: boolean; - webSearchUsed?: boolean; - file?: { - type: string; - url: string; - name: string; - }; -} - -interface CodeProps { - node?: any; - inline?: boolean; - className?: string; - children?: React.ReactNode; -} - -const CodeBlock = ({ className, children, ...props }: CodeProps) => { - const [copied, setCopied] = useState(false); - const match = /language-(\w+)/.exec(className || ''); - const language = match ? match[1] : 'text'; - const codeString = String(children).replace(/\n$/, ''); - - const copyToClipboard = async () => { - try { - await navigator.clipboard.writeText(codeString); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy text: ', err); - } - }; - - if (!codeString.includes("\n")) { - return ( - - {codeString} - - ); - } - - return ( -
- - {codeString} - - -
- {language !== 'text' && ( -
- {language} -
- )} - -
-
- ); -}; - -const getFileType = (mimeType: string): 'image' | 'pdf' | 'csv' | 'unknown' => { - if (mimeType.startsWith('image/')) return 'image'; - if (mimeType === 'application/pdf') return 'pdf'; - if (mimeType === 'text/csv' || mimeType === 'application/csv') return 'csv'; - return 'unknown'; -}; - -const getFileIcon = (fileType: 'image' | 'pdf' | 'csv' | 'unknown') => { - switch (fileType) { - case 'image': - return ; - case 'pdf': - return ; - case 'csv': - return ; - default: - return ; - } -}; - -const formatFileSize = (fileName: string): string => { - const extension = fileName.split('.').pop()?.toLowerCase() || ''; - const typeMap: Record = { - 'pdf': 'PDF', - 'csv': 'CSV', - 'jpg': 'JPG', - 'jpeg': 'JPEG', - 'png': 'PNG', - 'gif': 'GIF', - 'webp': 'WebP' - }; - return typeMap[extension] || 'File'; -}; - -const FileDisplay = ({ file }: { file: { type: string; url: string; name: string } }) => { - const fileType = getFileType(file.type); - - if (fileType === 'image') { - return ( -
-
- {file.name} -
- {file.name} - - - -
-
-
- ); - } else { - return ( -
-
-
- {getFileIcon(fileType)} -
- {file.name} - - {formatFileSize(file.name)} - -
-
- - - -
-
- ); - } -}; - -export const ChatBody = ({ messages }: { messages: Message[] }) => { - return ( -
-
- {messages.length === 0 ? ( -

Hi There!

- ) : ( - messages.map((msg) => ( -
- {msg.file && ( - - )} - {msg.isUser ? ( -
{msg.text}
- ) : ( - <> - {msg.webSearchUsed && ( -
- - Web search enabled -
- )} -
- <>{children}, - h1: ({ children }) =>

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - p: ({ children, node }) => { - const firstChild = node?.children?.[0]; - const hasOnlyCodeBlock = - node?.children?.length === 1 && - firstChild?.type === 'element' && - firstChild?.tagName === 'code'; - - const isBlockLevelCode = - hasOnlyCodeBlock && - firstChild?.type === 'element' && - Array.isArray(firstChild.properties?.className) && - firstChild.properties?.className?.some((cls) => - typeof cls === 'string' && cls.startsWith('language-') - ); - const hasBlockElements = node?.children?.some((child: any) => - child?.type === 'element' && - ['div', 'pre', 'blockquote', 'table', 'ul', 'ol', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(child.tagName) - ); - - if (isBlockLevelCode || hasBlockElements) { - return <>{children}; - } - - return

{children}

; - }, - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - li: ({ children }) =>
  • {children}
  • , - blockquote: ({ children }) => ( -
    - {children} -
    - ), - strong: ({ children }) => {children}, - em: ({ children }) => {children}, - a: ({ children, href }) => ( - - {children} - - ), - table: ({ children }) => ( -
    - - {children} -
    -
    - ), - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - - {children} - - ), - hr: () =>
    , - }} - > - {msg.text} -
    -
    - - )} - {msg.isStreaming && ( - - )} -
    - )) - )} -
    -
    - ); +import ReactMarkdown from 'react-markdown'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { IoCopyOutline, IoCheckmark, IoDocumentText, IoImage, IoDownload, IoSearch } from 'react-icons/io5'; +import { FaFileCsv } from "react-icons/fa6"; +import { useState } from 'react'; +import styles from './ChatBody.module.css'; +import Image from 'next/image'; +import SourceBubble from '../source-bubble/SourceBubble'; + +interface Message { + id: string; + text: string; + isUser: boolean; + timestamp: Date; + isStreaming?: boolean; + webSearchUsed?: boolean; + file?: { + type: string; + url: string; + name: string; + }; +} + +interface CodeProps { + node?: any; + inline?: boolean; + className?: string; + children?: React.ReactNode; +} + +const CodeBlock = ({ className, children, ...props }: CodeProps) => { + const [copied, setCopied] = useState(false); + const match = /language-(\w+)/.exec(className || ''); + const language = match ? match[1] : 'text'; + const codeString = String(children).replace(/\n$/, ''); + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(codeString); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy text: ', err); + } + }; + + if (!codeString.includes("\n")) { + return ( + + {codeString} + + ); + } + + return ( +
    + + {codeString} + + +
    + {language !== 'text' && ( +
    + {language} +
    + )} + +
    +
    + ); +}; + +const getFileType = (mimeType: string): 'image' | 'pdf' | 'csv' | 'unknown' => { + if (mimeType.startsWith('image/')) return 'image'; + if (mimeType === 'application/pdf') return 'pdf'; + if (mimeType === 'text/csv' || mimeType === 'application/csv') return 'csv'; + return 'unknown'; +}; + +const getFileIcon = (fileType: 'image' | 'pdf' | 'csv' | 'unknown') => { + switch (fileType) { + case 'image': + return ; + case 'pdf': + return ; + case 'csv': + return ; + default: + return ; + } +}; + +const formatFileSize = (fileName: string): string => { + const extension = fileName.split('.').pop()?.toLowerCase() || ''; + const typeMap: Record = { + 'pdf': 'PDF', + 'csv': 'CSV', + 'jpg': 'JPG', + 'jpeg': 'JPEG', + 'png': 'PNG', + 'gif': 'GIF', + 'webp': 'WebP' + }; + return typeMap[extension] || 'File'; +}; + +const FileDisplay = ({ file }: { file: { type: string; url: string; name: string } }) => { + const fileType = getFileType(file.type); + + if (fileType === 'image') { + return ( +
    +
    + {file.name} +
    + {file.name} + + + +
    +
    +
    + ); + } else { + return ( +
    +
    +
    + {getFileIcon(fileType)} +
    + {file.name} + + {formatFileSize(file.name)} + +
    +
    + + + +
    +
    + ); + } +}; + +export const ChatBody = ({ messages }: { messages: Message[] }) => { + return ( +
    +
    + {messages.length === 0 ? ( +

    Hi There!

    + ) : ( + messages.map((msg) => ( +
    + {msg.file && ( + + )} + {msg.isUser ? ( +
    {msg.text}
    + ) : ( + <> + {msg.webSearchUsed && ( +
    + + Web search enabled +
    + )} +
    + <>{children}, + h1: ({ children }) =>

    {children}

    , + h2: ({ children }) =>

    {children}

    , + h3: ({ children }) =>

    {children}

    , + p: ({ children, node }) => { + const firstChild = node?.children?.[0]; + const hasOnlyCodeBlock = + node?.children?.length === 1 && + firstChild?.type === 'element' && + firstChild?.tagName === 'code'; + + const isBlockLevelCode = + hasOnlyCodeBlock && + firstChild?.type === 'element' && + Array.isArray(firstChild.properties?.className) && + firstChild.properties?.className?.some((cls) => + typeof cls === 'string' && cls.startsWith('language-') + ); + const hasBlockElements = node?.children?.some((child: any) => + child?.type === 'element' && + ['div', 'pre', 'blockquote', 'table', 'ul', 'ol', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(child.tagName) + ); + + if (isBlockLevelCode || hasBlockElements) { + return <>{children}; + } + + return

    {children}

    ; + }, + ul: ({ children }) =>
      {children}
    , + ol: ({ children }) =>
      {children}
    , + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) => ( +
    + {children} +
    + ), + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + a: ({ children, href }) => ( + + ), + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + hr: () =>
    , + }} + > + {msg.text} +
    +
    + + )} + {msg.isStreaming && ( + + )} +
    + )) + )} +
    +
    + ); } \ No newline at end of file diff --git a/frontend/web/src/components/source-bubble/SourceBubble.module.css b/frontend/web/src/components/source-bubble/SourceBubble.module.css new file mode 100644 index 0000000..bc1f8e8 --- /dev/null +++ b/frontend/web/src/components/source-bubble/SourceBubble.module.css @@ -0,0 +1,79 @@ +.container { + position: relative; + display: inline-block; + margin: 0 2px; +} + +.bubble { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + background: rgba(59, 130, 246, 0.15); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 50%; + text-decoration: none; + transition: all 0.2s ease; + position: relative; + vertical-align: middle; +} + +.bubble:hover { + background: rgba(59, 130, 246, 0.25); + border-color: rgba(59, 130, 246, 0.5); + transform: scale(1.1); +} + +.iconContainer { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; +} + +.favicon { + width: 12px; + height: 12px; + border-radius: 2px; +} + +.defaultIcon { + width: 10px; + height: 10px; + color: rgba(147, 197, 253, 0.8); +} + +.tooltip { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%) translateY(-4px); + background: rgba(17, 24, 39, 0.95); + color: white; + padding: 4px 8px; + border-radius: 6px; + font-size: 12px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + backdrop-filter: blur(8px); + border: 1px solid rgba(75, 85, 99, 0.3); + z-index: 1000; +} + +.tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 4px solid transparent; + border-top-color: rgba(17, 24, 39, 0.95); +} + +.tooltip.visible { + opacity: 1; +} \ No newline at end of file diff --git a/frontend/web/src/components/source-bubble/SourceBubble.tsx b/frontend/web/src/components/source-bubble/SourceBubble.tsx new file mode 100644 index 0000000..bc8b10b --- /dev/null +++ b/frontend/web/src/components/source-bubble/SourceBubble.tsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; +import styles from './SourceBubble.module.css'; + +interface SourceBubbleProps { + href: string; + children: React.ReactNode; +} + +const SourceBubble: React.FC = ({ href }) => { + const [isHovered, setIsHovered] = useState(false); + const [imageError, setImageError] = useState(false); + + const getDomain = (url: string) => { + try { + return new URL(url).hostname.replace('www.', ''); + } catch { + return 'link'; + } + }; + + const getFaviconUrl = (url: string) => { + try { + const domain = new URL(url).hostname; + return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`; + } catch { + return null; + } + }; + + const domain = getDomain(href); + const faviconUrl = getFaviconUrl(href); + + return ( + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + {faviconUrl && !imageError ? ( + setImageError(true)} + /> + ) : ( + + + + )} + + + {domain} + + + + ); +}; + +export default SourceBubble; \ No newline at end of file