Spaces:
Runtime error
Runtime error
| /** | |
| * MessageBubble — renders a single user or assistant message in the chat thread. | |
| * | |
| * - User bubbles: violet gradient background, white text | |
| * - Assistant bubbles: white card, rounded, with shadow; Markdown content; | |
| * citation chips, save/export buttons, and metadata bar below completed messages | |
| * | |
| * Requirements: 4.4, 4.7, 4.8, 5.1, 7.1, 7.6, 11.2, 12.3, 12.4 | |
| */ | |
| import { useState, useEffect, useRef } from "react"; | |
| import ReactMarkdown from "react-markdown"; | |
| import { remarkPlugins, rehypePlugins, components } from "../lib/markdown"; | |
| import { CitationChips } from "./CitationChips"; | |
| import type { Message, QueryResult } from "../types"; | |
| // Messages shown while waiting for the pipeline to return tokens. | |
| // The last entry appears once the first token arrives. | |
| const RETRIEVAL_MESSAGES = [ | |
| "Looking at the legislation", | |
| "Reviewing relevant regulations", | |
| "Assessing requirements", | |
| "Scanning specific guidelines", | |
| "Understanding policy recommendations", | |
| ]; | |
| const GENERATING_MESSAGE = "Generating tailored response"; | |
| const CYCLE_INTERVAL_MS = 2500; | |
| export interface MessageBubbleProps { | |
| message: Message; | |
| index: number; | |
| conversationId: string; | |
| result?: QueryResult; | |
| isStreaming?: boolean; | |
| isSaved?: boolean; | |
| onSave?: (index: number) => void; | |
| onExport?: (index: number) => void; | |
| } | |
| /** | |
| * Renders a single chat message bubble. | |
| * | |
| * User messages use a violet gradient background with white text. | |
| * Assistant messages use a full-width white card with rounded corners and shadow, | |
| * with Markdown rendering, citation chips, a collapsible detail panel, | |
| * and a metadata bar with timing info + Save Response pill button. | |
| */ | |
| export function MessageBubble({ | |
| message, | |
| index, | |
| conversationId: _conversationId, | |
| result, | |
| isStreaming = false, | |
| isSaved = false, | |
| onSave, | |
| onExport, | |
| }: MessageBubbleProps) { | |
| const isUser = message.role === "user"; | |
| const isCompleted = !isStreaming && message.role === "assistant"; | |
| const hasContent = message.content.trim().length > 0; | |
| // Cycle through retrieval messages while waiting for first tokens | |
| const [msgIndex, setMsgIndex] = useState(0); | |
| const intervalRef = useRef<number | null>(null); | |
| useEffect(() => { | |
| if (isStreaming && !hasContent) { | |
| // Start cycling | |
| intervalRef.current = setInterval(() => { | |
| setMsgIndex((i) => (i + 1) % RETRIEVAL_MESSAGES.length); | |
| }, CYCLE_INTERVAL_MS); | |
| } else { | |
| // Stop cycling when tokens arrive or streaming ends | |
| if (intervalRef.current !== null) { | |
| clearInterval(intervalRef.current); | |
| intervalRef.current = null; | |
| } | |
| } | |
| return () => { | |
| if (intervalRef.current !== null) clearInterval(intervalRef.current); | |
| }; | |
| }, [isStreaming, hasContent]); | |
| if (isUser) { | |
| return ( | |
| <div className="flex justify-end mb-4"> | |
| <div | |
| className="max-w-[80%] px-4 py-3 text-white" | |
| style={{ | |
| background: "linear-gradient(135deg, #542FE8 0%, #1E3798 100%)", | |
| borderRadius: "14px", | |
| fontSize: "0.95rem", | |
| fontWeight: 400, | |
| lineHeight: 1.55, | |
| }} | |
| > | |
| {message.content} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="mb-6"> | |
| <div className="w-full"> | |
| {/* White card */} | |
| <div | |
| className="px-6 py-5" | |
| style={{ | |
| backgroundColor: "#FFFFFF", | |
| borderRadius: "16px", | |
| boxShadow: "0 4px 24px rgba(0,0,0,0.12)", | |
| minHeight: isStreaming ? "80px" : undefined, | |
| }} | |
| > | |
| {/* Retrieval phase — cycling status messages before first token */} | |
| {isStreaming && !hasContent && ( | |
| <div className="flex items-center gap-2 py-2"> | |
| <span className="text-sm text-gray-400">{RETRIEVAL_MESSAGES[msgIndex]}</span> | |
| <div className="flex gap-1"> | |
| <span | |
| className="w-2 h-2 rounded-full bg-gray-300" | |
| style={{ animation: "pulse 1.4s ease-in-out 0s infinite" }} | |
| /> | |
| <span | |
| className="w-2 h-2 rounded-full bg-gray-300" | |
| style={{ animation: "pulse 1.4s ease-in-out 0.2s infinite" }} | |
| /> | |
| <span | |
| className="w-2 h-2 rounded-full bg-gray-300" | |
| style={{ animation: "pulse 1.4s ease-in-out 0.4s infinite" }} | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| {/* Generation phase label — shown while tokens are streaming in */} | |
| {isStreaming && hasContent && ( | |
| <div className="flex items-center gap-2 pb-3"> | |
| <span className="text-xs text-gray-400">{GENERATING_MESSAGE}</span> | |
| <div className="flex gap-1"> | |
| <span className="w-1.5 h-1.5 rounded-full bg-gray-300" style={{ animation: "pulse 1.4s ease-in-out 0s infinite" }} /> | |
| <span className="w-1.5 h-1.5 rounded-full bg-gray-300" style={{ animation: "pulse 1.4s ease-in-out 0.2s infinite" }} /> | |
| <span className="w-1.5 h-1.5 rounded-full bg-gray-300" style={{ animation: "pulse 1.4s ease-in-out 0.4s infinite" }} /> | |
| </div> | |
| </div> | |
| )} | |
| {/* Markdown-rendered assistant content */} | |
| {hasContent && ( | |
| <div className="max-w-none" style={{ color: "#1C2942" }}> | |
| <ReactMarkdown | |
| remarkPlugins={remarkPlugins} | |
| rehypePlugins={rehypePlugins} | |
| components={components} | |
| > | |
| {message.content} | |
| </ReactMarkdown> | |
| </div> | |
| )} | |
| {/* Citation chips + collapsible detail — only for completed messages */} | |
| {isCompleted && ( | |
| <div className="mt-4"> | |
| {result && result.citations.length > 0 && ( | |
| <CitationChips citations={result.citations} /> | |
| )} | |
| {/* Collapsible "Full citation detail" card */} | |
| {result && result.citations.length > 0 && ( | |
| <CitationDetailPanel citations={result.citations} /> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| {/* Bottom metadata bar — timing, sections, domains, tokens + Save Response */} | |
| {isCompleted && result && ( | |
| <div className="flex items-center justify-between mt-3 px-1"> | |
| <MetadataBar result={result} /> | |
| <div className="flex items-center gap-2"> | |
| {onExport && ( | |
| <button | |
| type="button" | |
| onClick={() => onExport(index)} | |
| className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600 transition-colors" | |
| title="Download response" | |
| aria-label="Download response" | |
| > | |
| <DownloadIcon /> | |
| </button> | |
| )} | |
| {onSave && ( | |
| <button | |
| type="button" | |
| onClick={() => onSave(index)} | |
| className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium text-white transition-all active:scale-95" | |
| style={{ | |
| backgroundColor: isSaved ? "#3B2498" : "#4A2DB8", | |
| }} | |
| title={isSaved ? "Response saved" : "Save response"} | |
| aria-label={isSaved ? "Response saved" : "Save response"} | |
| > | |
| <span>{isSaved ? "Saved" : "Save Response"}</span> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Internal sub-components | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Collapsible full citation detail panel with chevron and card styling. | |
| */ | |
| function CitationDetailPanel({ | |
| citations, | |
| }: { | |
| citations: QueryResult["citations"]; | |
| }) { | |
| const [open, setOpen] = useState(false); | |
| return ( | |
| <div className="mt-3"> | |
| <button | |
| type="button" | |
| onClick={() => setOpen((prev) => !prev)} | |
| className="w-full flex items-center gap-2 px-4 py-3 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors" | |
| aria-expanded={open} | |
| > | |
| <ChevronIcon open={open} /> | |
| <span>Full citation detail</span> | |
| </button> | |
| {open && ( | |
| <div className="mt-2 space-y-2"> | |
| {citations.map((c) => ( | |
| <div | |
| key={c.index} | |
| className="text-xs text-gray-600 border border-gray-200 rounded-md p-3 bg-gray-50" | |
| > | |
| <div className="flex items-center gap-2"> | |
| <span className="font-semibold text-gray-700">[{c.index}]</span> | |
| <span className="font-medium">{c.domain}</span> | |
| </div> | |
| <div className="mt-1 text-gray-800">{c.title}</div> | |
| {c.source_url && ( | |
| <div className="mt-1 truncate"> | |
| <a | |
| href={c.source_url} | |
| target="_blank" | |
| rel="noopener" | |
| className="text-blue-600 hover:underline" | |
| > | |
| {c.source_url} | |
| </a> | |
| </div> | |
| )} | |
| {c.line_num !== undefined && ( | |
| <div className="mt-1 text-gray-500">Line {c.line_num}</div> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| /** | |
| * Metadata bar showing timing · sections · domains · tokens. | |
| */ | |
| function MetadataBar({ result }: { result: QueryResult }) { | |
| const parts: string[] = []; | |
| if (result.timing?.total !== undefined) { | |
| parts.push(`${result.timing.total.toFixed(0)}s`); | |
| } | |
| if (result.sections_retrieved !== undefined && result.sections_retrieved > 0) { | |
| parts.push(`${result.sections_retrieved} section${result.sections_retrieved > 1 ? "s" : ""}`); | |
| } | |
| if (result.domains_searched && result.domains_searched.length > 0) { | |
| parts.push( | |
| result.domains_searched | |
| .map((d) => | |
| d | |
| .replace(/_/g, " ") | |
| .replace(/\b\w/g, (c) => c.toUpperCase()) | |
| ) | |
| .join(", ") | |
| ); | |
| } | |
| if (result.token_usage?.total_tokens) { | |
| parts.push(`${result.token_usage.total_tokens.toLocaleString()} tokens`); | |
| } | |
| if (parts.length === 0) return null; | |
| return ( | |
| <p className="text-xs text-gray-500 leading-relaxed"> | |
| {parts.join(" · ")} | |
| </p> | |
| ); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Icon components | |
| // --------------------------------------------------------------------------- | |
| function ChevronIcon({ open }: { open: boolean }) { | |
| return ( | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| fill="none" | |
| viewBox="0 0 24 24" | |
| strokeWidth={2} | |
| stroke="currentColor" | |
| className="w-4 h-4 text-gray-400 transition-transform" | |
| style={{ transform: open ? "rotate(90deg)" : "rotate(0deg)" }} | |
| aria-hidden="true" | |
| > | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> | |
| </svg> | |
| ); | |
| } | |
| function DownloadIcon() { | |
| return ( | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| fill="none" | |
| viewBox="0 0 24 24" | |
| strokeWidth={1.5} | |
| stroke="currentColor" | |
| className="w-4 h-4" | |
| aria-hidden="true" | |
| > | |
| <path | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" | |
| /> | |
| </svg> | |
| ); | |
| } | |
| export default MessageBubble; | |