Spaces:
Running
Running
| 'use client' | |
| import { useState, useRef, useEffect } from 'react' | |
| import { Button } from '@/components/ui/button' | |
| import { Textarea } from '@/components/ui/textarea' | |
| import { ScrollArea } from '@/components/ui/scroll-area' | |
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' | |
| import { Badge } from '@/components/ui/badge' | |
| import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' | |
| import { Bot, User, Send, Loader2, FileText, Lightbulb, StickyNote, Clock } from 'lucide-react' | |
| import ReactMarkdown from 'react-markdown' | |
| import remarkGfm from 'remark-gfm' | |
| import { | |
| SourceChatMessage, | |
| SourceChatContextIndicator, | |
| BaseChatSession | |
| } from '@/lib/types/api' | |
| import { ModelSelector } from './ModelSelector' | |
| import { ContextIndicator } from '@/components/common/ContextIndicator' | |
| import { SessionManager } from '@/components/source/SessionManager' | |
| import { MessageActions } from '@/components/source/MessageActions' | |
| import { convertReferencesToCompactMarkdown, createCompactReferenceLinkComponent } from '@/lib/utils/source-references' | |
| import { useModalManager } from '@/lib/hooks/use-modal-manager' | |
| import { toast } from 'sonner' | |
| interface NotebookContextStats { | |
| sourcesInsights: number | |
| sourcesFull: number | |
| notesCount: number | |
| tokenCount?: number | |
| charCount?: number | |
| } | |
| interface ChatPanelProps { | |
| messages: SourceChatMessage[] | |
| isStreaming: boolean | |
| contextIndicators: SourceChatContextIndicator | null | |
| onSendMessage: (message: string, modelOverride?: string) => void | |
| modelOverride?: string | |
| onModelChange?: (model?: string) => void | |
| // Session management props | |
| sessions?: BaseChatSession[] | |
| currentSessionId?: string | null | |
| onCreateSession?: (title: string) => void | |
| onSelectSession?: (sessionId: string) => void | |
| onDeleteSession?: (sessionId: string) => void | |
| onUpdateSession?: (sessionId: string, title: string) => void | |
| loadingSessions?: boolean | |
| // Generic props for reusability | |
| title?: string | |
| contextType?: 'source' | 'notebook' | |
| // Notebook context stats (for notebook chat) | |
| notebookContextStats?: NotebookContextStats | |
| // Notebook ID for saving notes | |
| notebookId?: string | |
| } | |
| export function ChatPanel({ | |
| messages, | |
| isStreaming, | |
| contextIndicators, | |
| onSendMessage, | |
| modelOverride, | |
| onModelChange, | |
| sessions = [], | |
| currentSessionId, | |
| onCreateSession, | |
| onSelectSession, | |
| onDeleteSession, | |
| onUpdateSession, | |
| loadingSessions = false, | |
| title = 'Chat with Source', | |
| contextType = 'source', | |
| notebookContextStats, | |
| notebookId | |
| }: ChatPanelProps) { | |
| const [input, setInput] = useState('') | |
| const [sessionManagerOpen, setSessionManagerOpen] = useState(false) | |
| const scrollAreaRef = useRef<HTMLDivElement>(null) | |
| const messagesEndRef = useRef<HTMLDivElement>(null) | |
| const { openModal } = useModalManager() | |
| const handleReferenceClick = (type: string, id: string) => { | |
| const modalType = type === 'source_insight' ? 'insight' : type as 'source' | 'note' | 'insight' | |
| try { | |
| openModal(modalType, id) | |
| // Note: The modal system uses URL parameters and doesn't throw errors for missing items. | |
| // The modal component itself will handle displaying "not found" states. | |
| // This try-catch is here for future enhancements or unexpected errors. | |
| } catch { | |
| const typeLabel = type === 'source_insight' ? 'insight' : type | |
| toast.error(`This ${typeLabel} could not be found`) | |
| } | |
| } | |
| // Auto-scroll to bottom when new messages arrive | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) | |
| }, [messages]) | |
| const handleSend = () => { | |
| if (input.trim() && !isStreaming) { | |
| onSendMessage(input.trim(), modelOverride) | |
| setInput('') | |
| } | |
| } | |
| const handleKeyDown = (e: React.KeyboardEvent) => { | |
| // Detect platform for correct modifier key | |
| const isMac = typeof navigator !== 'undefined' && navigator.userAgent.toUpperCase().indexOf('MAC') >= 0 | |
| const isModifierPressed = isMac ? e.metaKey : e.ctrlKey | |
| if (e.key === 'Enter' && isModifierPressed) { | |
| e.preventDefault() | |
| handleSend() | |
| } | |
| } | |
| // Detect platform for placeholder text | |
| const isMac = typeof navigator !== 'undefined' && navigator.userAgent.toUpperCase().indexOf('MAC') >= 0 | |
| const keyHint = isMac ? '⌘+Enter' : 'Ctrl+Enter' | |
| return ( | |
| <> | |
| <Card className="flex flex-col h-full flex-1 overflow-hidden"> | |
| <CardHeader className="pb-3 flex-shrink-0"> | |
| <div className="flex items-center justify-between"> | |
| <CardTitle className="flex items-center gap-2"> | |
| <Bot className="h-5 w-5" /> | |
| {title} | |
| </CardTitle> | |
| {onSelectSession && onCreateSession && onDeleteSession && ( | |
| <Dialog open={sessionManagerOpen} onOpenChange={setSessionManagerOpen}> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="gap-2" | |
| onClick={() => setSessionManagerOpen(true)} | |
| disabled={loadingSessions} | |
| > | |
| <Clock className="h-4 w-4" /> | |
| <span className="text-xs">Sessions</span> | |
| </Button> | |
| <DialogContent className="sm:max-w-[420px] p-0 overflow-hidden"> | |
| <DialogTitle className="sr-only">Chat Sessions</DialogTitle> | |
| <SessionManager | |
| sessions={sessions} | |
| currentSessionId={currentSessionId ?? null} | |
| onCreateSession={(title) => onCreateSession?.(title)} | |
| onSelectSession={(sessionId) => { | |
| onSelectSession(sessionId) | |
| setSessionManagerOpen(false) | |
| }} | |
| onUpdateSession={(sessionId, title) => onUpdateSession?.(sessionId, title)} | |
| onDeleteSession={(sessionId) => onDeleteSession?.(sessionId)} | |
| loadingSessions={loadingSessions} | |
| /> | |
| </DialogContent> | |
| </Dialog> | |
| )} | |
| </div> | |
| </CardHeader> | |
| <CardContent className="flex-1 flex flex-col min-h-0 p-0"> | |
| <ScrollArea className="flex-1 min-h-0 px-4" ref={scrollAreaRef}> | |
| <div className="space-y-4 py-4"> | |
| {messages.length === 0 ? ( | |
| <div className="text-center text-muted-foreground py-8"> | |
| <Bot className="h-12 w-12 mx-auto mb-4 opacity-50" /> | |
| <p className="text-sm"> | |
| Start a conversation about this {contextType} | |
| </p> | |
| <p className="text-xs mt-2">Ask questions to understand the content better</p> | |
| </div> | |
| ) : ( | |
| messages.map((message) => ( | |
| <div | |
| key={message.id} | |
| className={`flex gap-3 ${ | |
| message.type === 'human' ? 'justify-end' : 'justify-start' | |
| }`} | |
| > | |
| {message.type === 'ai' && ( | |
| <div className="flex-shrink-0"> | |
| <div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center"> | |
| <Bot className="h-4 w-4" /> | |
| </div> | |
| </div> | |
| )} | |
| <div className="flex flex-col gap-2 max-w-[80%]"> | |
| <div | |
| className={`rounded-lg px-4 py-2 ${ | |
| message.type === 'human' | |
| ? 'bg-primary text-primary-foreground' | |
| : 'bg-muted' | |
| }`} | |
| > | |
| {message.type === 'ai' ? ( | |
| <AIMessageContent | |
| content={message.content} | |
| onReferenceClick={handleReferenceClick} | |
| /> | |
| ) : ( | |
| <p className="text-sm break-words overflow-wrap-anywhere">{message.content}</p> | |
| )} | |
| </div> | |
| {message.type === 'ai' && ( | |
| <MessageActions | |
| content={message.content} | |
| notebookId={notebookId} | |
| /> | |
| )} | |
| </div> | |
| {message.type === 'human' && ( | |
| <div className="flex-shrink-0"> | |
| <div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center"> | |
| <User className="h-4 w-4 text-primary-foreground" /> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )) | |
| )} | |
| {isStreaming && ( | |
| <div className="flex gap-3 justify-start"> | |
| <div className="flex-shrink-0"> | |
| <div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center"> | |
| <Bot className="h-4 w-4" /> | |
| </div> | |
| </div> | |
| <div className="rounded-lg px-4 py-2 bg-muted"> | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| </div> | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| </ScrollArea> | |
| {/* Context Indicators */} | |
| {contextIndicators && ( | |
| <div className="border-t px-4 py-2"> | |
| <div className="flex flex-wrap gap-2 text-xs"> | |
| {contextIndicators.sources?.length > 0 && ( | |
| <Badge variant="outline" className="gap-1"> | |
| <FileText className="h-3 w-3" /> | |
| {contextIndicators.sources.length} source{contextIndicators.sources.length > 1 ? 's' : ''} | |
| </Badge> | |
| )} | |
| {contextIndicators.insights?.length > 0 && ( | |
| <Badge variant="outline" className="gap-1"> | |
| <Lightbulb className="h-3 w-3" /> | |
| {contextIndicators.insights.length} insight{contextIndicators.insights.length > 1 ? 's' : ''} | |
| </Badge> | |
| )} | |
| {contextIndicators.notes?.length > 0 && ( | |
| <Badge variant="outline" className="gap-1"> | |
| <StickyNote className="h-3 w-3" /> | |
| {contextIndicators.notes.length} note{contextIndicators.notes.length > 1 ? 's' : ''} | |
| </Badge> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* Notebook Context Indicator */} | |
| {notebookContextStats && ( | |
| <ContextIndicator | |
| sourcesInsights={notebookContextStats.sourcesInsights} | |
| sourcesFull={notebookContextStats.sourcesFull} | |
| notesCount={notebookContextStats.notesCount} | |
| tokenCount={notebookContextStats.tokenCount} | |
| charCount={notebookContextStats.charCount} | |
| /> | |
| )} | |
| {/* Input Area */} | |
| <div className="flex-shrink-0 p-4 space-y-3 border-t"> | |
| {/* Model selector */} | |
| {onModelChange && ( | |
| <div className="flex items-center justify-between"> | |
| <span className="text-xs text-muted-foreground">Model</span> | |
| <ModelSelector | |
| currentModel={modelOverride} | |
| onModelChange={onModelChange} | |
| disabled={isStreaming} | |
| /> | |
| </div> | |
| )} | |
| <div className="flex gap-2 items-end"> | |
| <Textarea | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={`Ask a question about this ${contextType}... (${keyHint} to send)`} | |
| disabled={isStreaming} | |
| className="flex-1 min-h-[40px] max-h-[100px] resize-none py-2 px-3" | |
| rows={1} | |
| /> | |
| <Button | |
| onClick={handleSend} | |
| disabled={!input.trim() || isStreaming} | |
| size="icon" | |
| className="h-[40px] w-[40px] flex-shrink-0" | |
| > | |
| {isStreaming ? ( | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| ) : ( | |
| <Send className="h-4 w-4" /> | |
| )} | |
| </Button> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </> | |
| ) | |
| } | |
| // Helper component to render AI messages with clickable references | |
| function AIMessageContent({ | |
| content, | |
| onReferenceClick | |
| }: { | |
| content: string | |
| onReferenceClick: (type: string, id: string) => void | |
| }) { | |
| // Convert references to compact markdown with numbered citations | |
| const markdownWithCompactRefs = convertReferencesToCompactMarkdown(content) | |
| // Create custom link component for compact references | |
| const LinkComponent = createCompactReferenceLinkComponent(onReferenceClick) | |
| return ( | |
| <div className="prose prose-sm prose-neutral dark:prose-invert max-w-none break-words prose-headings:font-semibold prose-a:text-blue-600 prose-a:break-all prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-p:mb-4 prose-p:leading-7 prose-li:mb-2"> | |
| <ReactMarkdown | |
| remarkPlugins={[remarkGfm]} | |
| components={{ | |
| a: LinkComponent, | |
| p: ({ children }) => <p className="mb-4">{children}</p>, | |
| h1: ({ children }) => <h1 className="mb-4 mt-6">{children}</h1>, | |
| h2: ({ children }) => <h2 className="mb-3 mt-5">{children}</h2>, | |
| h3: ({ children }) => <h3 className="mb-3 mt-4">{children}</h3>, | |
| h4: ({ children }) => <h4 className="mb-2 mt-4">{children}</h4>, | |
| h5: ({ children }) => <h5 className="mb-2 mt-3">{children}</h5>, | |
| h6: ({ children }) => <h6 className="mb-2 mt-3">{children}</h6>, | |
| li: ({ children }) => <li className="mb-1">{children}</li>, | |
| ul: ({ children }) => <ul className="mb-4 space-y-1">{children}</ul>, | |
| ol: ({ children }) => <ol className="mb-4 space-y-1">{children}</ol>, | |
| table: ({ children }) => ( | |
| <div className="my-4 overflow-x-auto"> | |
| <table className="min-w-full border-collapse border border-border">{children}</table> | |
| </div> | |
| ), | |
| thead: ({ children }) => <thead className="bg-muted">{children}</thead>, | |
| tbody: ({ children }) => <tbody>{children}</tbody>, | |
| tr: ({ children }) => <tr className="border-b border-border">{children}</tr>, | |
| th: ({ children }) => <th className="border border-border px-3 py-2 text-left font-semibold">{children}</th>, | |
| td: ({ children }) => <td className="border border-border px-3 py-2">{children}</td>, | |
| }} | |
| > | |
| {markdownWithCompactRefs} | |
| </ReactMarkdown> | |
| </div> | |
| ) | |
| } | |