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 (
-
- );
- } 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 }) => ,
- ol: ({ children }) => {children}
,
- li: ({ children }) => {children},
- blockquote: ({ children }) => (
-
- {children}
-
- ),
- strong: ({ children }) => {children},
- em: ({ children }) => {children},
- a: ({ children, href }) => (
-
- {children}
-
- ),
- table: ({ 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 (
+
+ );
+ } 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 }) => ,
+ ol: ({ children }) => {children}
,
+ li: ({ children }) => {children},
+ blockquote: ({ children }) => (
+
+ {children}
+
+ ),
+ strong: ({ children }) => {children},
+ em: ({ children }) => {children},
+ a: ({ children, href }) => (
+
+ ),
+ table: ({ 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