Spaces:
Running
Running
| import { useEffect, useRef, useState } from 'react' | |
| import ReactMarkdown from 'react-markdown' | |
| import remarkGfm from 'remark-gfm' | |
| import { queryDocuments } from '../api/api' | |
| import CitationPanel from './CitationPanel' | |
| import { useToast } from './Toast' | |
| import { useAuth } from '../contexts/AuthContext' | |
| const WELCOME = { | |
| role: 'assistant', | |
| content: `Hello! I'm **ASK Docs**, your intelligent document assistant.\n\nUpload a document using the sidebar, then ask me anything about it — I'll answer with contextual accuracy, grounded in your document content.`, | |
| citations: [], | |
| } | |
| function mdToHtml(raw) { | |
| const escaped = raw | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| const lines = escaped.split('\n') | |
| const out = [] | |
| let inUl = false, inOl = false, inPre = false | |
| for (let i = 0; i < lines.length; i++) { | |
| let l = lines[i] | |
| // fenced code blocks | |
| if (l.trim().startsWith('```')) { | |
| if (!inPre) { out.push('<pre>'); inPre = true } | |
| else { out.push('</pre>'); inPre = false } | |
| continue | |
| } | |
| if (inPre) { out.push(l + '\n'); continue } | |
| // close lists on blank line | |
| if (!l.trim()) { | |
| if (inUl) { out.push('</ul>'); inUl = false } | |
| if (inOl) { out.push('</ol>'); inOl = false } | |
| out.push('<br/>') | |
| continue | |
| } | |
| // headings | |
| if (/^### /.test(l)) { out.push(`<h3>${l.slice(4)}</h3>`); continue } | |
| if (/^## /.test(l)) { out.push(`<h2>${l.slice(3)}</h2>`); continue } | |
| if (/^# /.test(l)) { out.push(`<h1>${l.slice(2)}</h1>`); continue } | |
| // unordered list | |
| if (/^[-*] /.test(l)) { | |
| if (!inUl) { out.push('<ul>'); inUl = true } | |
| l = `<li>${l.slice(2)}</li>` | |
| out.push(inlineMd(l)); continue | |
| } | |
| // ordered list | |
| if (/^\d+\. /.test(l)) { | |
| if (!inOl) { out.push('<ol>'); inOl = true } | |
| l = `<li>${l.replace(/^\d+\. /, '')}</li>` | |
| out.push(inlineMd(l)); continue | |
| } | |
| // close open lists | |
| if (inUl) { out.push('</ul>'); inUl = false } | |
| if (inOl) { out.push('</ol>'); inOl = false } | |
| out.push('<p>' + inlineMd(l) + '</p>') | |
| } | |
| if (inUl) out.push('</ul>') | |
| if (inOl) out.push('</ol>') | |
| return out.join('') | |
| } | |
| function inlineMd(s) { | |
| return s | |
| .replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>') | |
| .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') | |
| .replace(/\*(.+?)\*/g, '<em>$1</em>') | |
| .replace(/`([^`]+)`/g, '<code>$1</code>') | |
| .replace(/~~(.+?)~~/g, '<del>$1</del>') | |
| } | |
| function buildChatHtml(messages, userName, userPhoto) { | |
| const count = messages.length - 1 | |
| const date = new Date().toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'short' }) | |
| const rows = messages.slice(1).map((msg) => { | |
| const isUser = msg.role === 'user' | |
| const avatar = isUser | |
| ? (userPhoto | |
| ? `<img src="${userPhoto}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;flex-shrink:0;border:2px solid #f3e8f0"/>` | |
| : `<div style="width:32px;height:32px;border-radius:50%;background:#8B004A;color:#fff; | |
| display:flex;align-items:center;justify-content:center;font-size:13px; | |
| font-weight:700;flex-shrink:0">${(userName || 'U')[0].toUpperCase()}</div>`) | |
| : `<div style="width:32px;height:32px;border-radius:50%;background:#8B004A;color:#fff; | |
| display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0">✦</div>` | |
| const name = isUser ? (userName || 'You') : 'ASK Docs' | |
| const body = isUser | |
| ? `<p style="margin:0;font-size:13px;line-height:1.65;color:#292524;white-space:pre-wrap">${msg.content.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}</p>` | |
| : mdToHtml(msg.content) | |
| const citBlock = (!isUser && msg.citations?.length) | |
| ? `<div style="margin-top:12px;padding-top:10px;border-top:1px dashed #e7d5e0"> | |
| <p style="margin:0 0 6px;font-size:9px;font-weight:700;color:#8B004A; | |
| text-transform:uppercase;letter-spacing:.08em">Sources</p> | |
| ${msg.citations.map((c, ci) => ` | |
| <div style="display:flex;align-items:baseline;gap:6px;margin-bottom:4px"> | |
| <span style="min-width:16px;height:16px;border-radius:50%;background:#8B004A; | |
| color:#fff;font-size:8px;font-weight:700;display:inline-flex; | |
| align-items:center;justify-content:center">${ci+1}</span> | |
| <span style="font-size:10px;color:#57534e"> | |
| <strong style="color:#292524">${c.documentName}</strong> | |
| · Page ${c.pageNumber} | |
| · ${Math.round(c.score * 100)}% match | |
| </span> | |
| </div>`).join('')} | |
| </div>` | |
| : '' | |
| return ` | |
| <div style="display:flex;gap:12px;margin-bottom:20px;align-items:flex-start; | |
| ${isUser ? 'flex-direction:row-reverse' : ''}"> | |
| ${avatar} | |
| <div style="flex:1;max-width:88%"> | |
| <p style="margin:0 0 5px;font-size:10px;font-weight:700;color:#8B004A; | |
| text-transform:uppercase;letter-spacing:.07em; | |
| ${isUser ? 'text-align:right' : ''}">${name}</p> | |
| <div style="padding:12px 16px;border-radius:${isUser ? '16px 4px 16px 16px' : '4px 16px 16px 16px'}; | |
| background:${isUser ? '#f9f0f5' : '#ffffff'}; | |
| border:1px solid ${isUser ? '#e8d0e0' : '#e7e5e4'}; | |
| box-shadow:0 1px 3px rgba(0,0,0,.06)"> | |
| <div class="md">${body}</div> | |
| ${citBlock} | |
| </div> | |
| </div> | |
| </div>` | |
| }).join('') | |
| return `<!DOCTYPE html> | |
| <html lang="en"><head> | |
| <meta charset="utf-8"/> | |
| <title>ASK Docs — Chat Export</title> | |
| <style> | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif; | |
| background:#f2efe7;color:#1c1917;padding:0} | |
| .page{max-width:760px;margin:0 auto;background:#fff;min-height:100vh; | |
| box-shadow:0 0 40px rgba(0,0,0,.08)} | |
| .header{background:linear-gradient(135deg,#8B004A 0%,#b5196b 100%); | |
| padding:28px 36px;color:#fff} | |
| .header-top{display:flex;align-items:center;gap:12px;margin-bottom:10px} | |
| .logo{width:36px;height:36px;border-radius:10px;background:rgba(255,255,255,.2); | |
| display:flex;align-items:center;justify-content:center;font-size:18px} | |
| .header h1{font-size:22px;font-weight:700;letter-spacing:-.3px} | |
| .header-meta{display:flex;gap:20px;font-size:11px;opacity:.75} | |
| .header-meta span{display:flex;align-items:center;gap:4px} | |
| .body{padding:28px 36px} | |
| .md h1{font-size:16px;font-weight:700;color:#8B004A;margin:10px 0 4px} | |
| .md h2{font-size:14px;font-weight:700;color:#8B004A;margin:8px 0 4px} | |
| .md h3{font-size:13px;font-weight:600;color:#57384d;margin:6px 0 3px} | |
| .md p{font-size:12.5px;line-height:1.7;color:#292524;margin-bottom:6px} | |
| .md ul,.md ol{padding-left:18px;margin-bottom:8px} | |
| .md li{font-size:12.5px;line-height:1.6;color:#292524;margin-bottom:2px} | |
| .md strong{font-weight:700;color:#1c1917} | |
| .md em{font-style:italic;color:#57534e} | |
| .md code{background:#fdf4f7;color:#8B004A;padding:1px 5px;border-radius:4px; | |
| font-size:11px;font-family:'Courier New',monospace;border:1px solid #f3dce8} | |
| .md pre{background:#1a0a10;color:#f2efe7;padding:14px 16px;border-radius:10px; | |
| font-size:11px;font-family:'Courier New',monospace;overflow:auto; | |
| margin:8px 0;line-height:1.6;white-space:pre-wrap;word-break:break-word} | |
| .md del{text-decoration:line-through;color:#a8a29e} | |
| .footer{padding:16px 36px;background:#faf8f6;border-top:1px solid #f0ece6; | |
| text-align:center;font-size:10px;color:#a8a29e} | |
| @media print{ | |
| body{background:#fff} | |
| .page{box-shadow:none;max-width:100%} | |
| .header{-webkit-print-color-adjust:exact;print-color-adjust:exact} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="page"> | |
| <div class="header"> | |
| <div class="header-top"> | |
| <div class="logo">✦</div> | |
| <div> | |
| <h1>ASK Docs</h1> | |
| <p style="font-size:11px;opacity:.7;margin-top:1px">AI Document Assistant</p> | |
| </div> | |
| </div> | |
| <div class="header-meta"> | |
| <span>📅 ${date}</span> | |
| <span>💬 ${count} message${count !== 1 ? 's' : ''}</span> | |
| ${userName ? `<span>👤 ${userName}</span>` : ''} | |
| </div> | |
| </div> | |
| <div class="body"> | |
| ${rows} | |
| </div> | |
| <div class="footer"> | |
| Generated by ASK Docs · AI-powered document intelligence | |
| </div> | |
| </div> | |
| </body></html>` | |
| } | |
| const CHAT_KEY = 'askdocs_chat' | |
| export default function ChatInterface() { | |
| const toast = useToast() | |
| const { user } = useAuth() | |
| // Load from sessionStorage so chat survives F5 reloads. | |
| // sessionStorage is cleared automatically when the tab is closed. | |
| const [messages, setMessages] = useState(() => { | |
| try { | |
| const stored = sessionStorage.getItem(CHAT_KEY) | |
| if (stored) return JSON.parse(stored) | |
| } catch {} | |
| return [WELCOME] | |
| }) | |
| const [input, setInput] = useState('') | |
| const [loading, setLoading] = useState(false) | |
| const bottomRef = useRef(null) | |
| const textareaRef = useRef(null) | |
| // Persist messages to sessionStorage whenever they change | |
| useEffect(() => { | |
| try { sessionStorage.setItem(CHAT_KEY, JSON.stringify(messages)) } catch {} | |
| }, [messages]) | |
| const downloadPDF = () => { | |
| if (messages.length <= 1) { toast.error('No chat to export yet.'); return } | |
| const win = window.open('', '_blank') | |
| if (!win) { toast.error('Allow pop-ups to download the chat PDF.'); return } | |
| const userName = user?.firebaseUser?.displayName || '' | |
| const userPhoto = user?.firebaseUser?.photoURL || '' | |
| win.document.open() | |
| win.document.write(buildChatHtml(messages, userName, userPhoto)) | |
| win.document.close() | |
| win.focus() | |
| setTimeout(() => { win.print(); win.close() }, 500) | |
| } | |
| useEffect(() => { | |
| bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) | |
| }, [messages, loading]) | |
| useEffect(() => { | |
| const ta = textareaRef.current | |
| if (!ta) return | |
| ta.style.height = 'auto' | |
| ta.style.height = `${Math.min(ta.scrollHeight, 160)}px` | |
| }, [input]) | |
| const send = async () => { | |
| const question = input.trim() | |
| if (!question || loading) return | |
| setMessages(m => [...m, { role: 'user', content: question, citations: [] }]) | |
| setInput('') | |
| setLoading(true) | |
| try { | |
| const data = await queryDocuments(question) | |
| setMessages(m => [...m, { role: 'assistant', content: data.answer, citations: data.citations ?? [] }]) | |
| } catch (err) { | |
| const msg = err.response?.data?.error ?? err.message ?? 'Something went wrong.' | |
| toast.error(msg) | |
| setMessages(m => [...m, { role: 'assistant', content: `**Error:** ${msg}`, citations: [] }]) | |
| } finally { | |
| setLoading(false) | |
| } | |
| } | |
| const handleKey = (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } | |
| } | |
| return ( | |
| <div className="flex flex-col h-full"> | |
| {/* Toolbar */} | |
| <div className="flex items-center justify-between mb-3 shrink-0"> | |
| <p className="text-[11px] text-stone-400 font-medium"> | |
| {messages.length - 1} message{messages.length - 1 !== 1 ? 's' : ''} | |
| </p> | |
| {messages.length > 1 && ( | |
| <div className="flex items-center gap-3"> | |
| <button onClick={downloadPDF} | |
| className="text-[11px] text-stone-400 hover:text-brand flex items-center gap-1 | |
| transition-colors font-medium"> | |
| <DownloadIcon /> Download PDF | |
| </button> | |
| <button onClick={() => setMessages([WELCOME])} | |
| className="text-[11px] text-stone-400 hover:text-brand flex items-center gap-1 | |
| transition-colors font-medium"> | |
| <ClearIcon /> Clear chat | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| {/* Messages */} | |
| <div className="flex-1 overflow-y-auto space-y-4 pr-1 pb-2"> | |
| {messages.map((msg, i) => ( | |
| <MessageBubble key={i} msg={msg} /> | |
| ))} | |
| {loading && <ThinkingBubble />} | |
| {/* Nudge shown only when no conversation has started yet */} | |
| {messages.length === 1 && !loading && ( | |
| <div className="mx-auto mt-2 max-w-xs rounded-xl border border-brand/20 | |
| bg-brand/[0.04] px-4 py-3 text-center"> | |
| <p className="text-xs font-semibold text-brand">Upload a document first</p> | |
| <p className="text-[11px] text-stone-400 mt-0.5"> | |
| Use the sidebar to upload a PDF, DOCX, image, or spreadsheet, then ask away. | |
| </p> | |
| </div> | |
| )} | |
| <div ref={bottomRef} /> | |
| </div> | |
| {/* Input area */} | |
| <div className="mt-3 shrink-0"> | |
| <div className={`flex gap-2 items-end rounded-2xl border-2 transition-all duration-200 | |
| bg-white shadow-card | |
| ${input ? 'border-brand/40 shadow-brand-sm' : 'border-stone-200'}`}> | |
| <textarea | |
| ref={textareaRef} | |
| rows={1} | |
| value={input} | |
| onChange={e => setInput(e.target.value)} | |
| onKeyDown={handleKey} | |
| placeholder="Ask a question about your documents…" | |
| disabled={loading} | |
| className="flex-1 resize-none bg-transparent px-4 py-3 text-sm text-stone-800 | |
| placeholder-stone-400 focus:outline-none leading-relaxed min-h-[44px] max-h-40" | |
| /> | |
| <button onClick={send} disabled={!input.trim() || loading} | |
| className="shrink-0 m-1.5 w-9 h-9 flex items-center justify-center rounded-xl | |
| bg-brand hover:bg-brand-600 disabled:opacity-30 disabled:pointer-events-none | |
| text-white transition-all duration-150 active:scale-95 shadow-brand-sm | |
| disabled:shadow-none glow-brand-sm"> | |
| <SendIcon /> | |
| </button> | |
| </div> | |
| <p className="text-[10px] text-stone-400 mt-1.5 text-center"> | |
| Enter to send · Shift+Enter for new line | |
| </p> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function MessageBubble({ msg }) { | |
| const [copied, setCopied] = useState(false) | |
| const isUser = msg.role === 'user' | |
| const copy = () => { | |
| navigator.clipboard.writeText(msg.content) | |
| setCopied(true) | |
| setTimeout(() => setCopied(false), 1500) | |
| } | |
| return ( | |
| <div className={`msg-enter flex ${isUser ? 'justify-end' : 'justify-start'} gap-2.5`}> | |
| {!isUser && <AssistantAvatar />} | |
| <div className={`max-w-[85%] group relative`}> | |
| <div className={`rounded-2xl px-4 py-3 text-sm leading-relaxed | |
| ${isUser | |
| ? 'bg-brand text-white rounded-br-none shadow-brand-sm' | |
| : 'bg-white border border-stone-200/80 text-stone-800 rounded-bl-none shadow-card'}`}> | |
| {isUser ? ( | |
| <p className="whitespace-pre-wrap">{msg.content}</p> | |
| ) : ( | |
| <> | |
| <div className="ai-prose max-w-none text-sm"> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]}> | |
| {msg.content} | |
| </ReactMarkdown> | |
| </div> | |
| <CitationPanel citations={msg.citations} /> | |
| </> | |
| )} | |
| </div> | |
| <button onClick={copy} | |
| className={`absolute top-2 | |
| ${isUser ? 'left-0 -translate-x-full pr-1.5' : 'right-0 translate-x-full pl-1.5'} | |
| opacity-0 group-hover:opacity-100 transition-opacity`}> | |
| <div className="w-6 h-6 rounded-lg bg-white border border-stone-200 shadow-card | |
| hover:border-brand/30 flex items-center justify-center transition-colors"> | |
| {copied ? <CheckIcon /> : <CopyIcon />} | |
| </div> | |
| </button> | |
| </div> | |
| {isUser && <UserAvatar />} | |
| </div> | |
| ) | |
| } | |
| function ThinkingBubble() { | |
| return ( | |
| <div className="msg-enter flex justify-start gap-2.5"> | |
| <AssistantAvatar /> | |
| <div className="bg-white border border-stone-200/80 rounded-2xl rounded-bl-none | |
| px-4 py-3.5 shadow-card"> | |
| <div className="flex gap-1.5 items-center"> | |
| {[0, 1, 2].map(i => ( | |
| <span key={i} className="w-2 h-2 rounded-full bg-brand/60 thinking-dot" | |
| style={{ animationDelay: `${i * 0.2}s` }} /> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| const AssistantAvatar = () => ( | |
| <div className="w-7 h-7 rounded-xl bg-brand flex items-center justify-center | |
| shrink-0 mt-0.5 shadow-brand-sm"> | |
| <svg className="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" | |
| strokeWidth={2} viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" | |
| d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/> | |
| </svg> | |
| </div> | |
| ) | |
| const UserAvatar = () => { | |
| const { user } = useAuth() | |
| const photoURL = user?.firebaseUser?.photoURL | |
| const displayName = user?.firebaseUser?.displayName | |
| const [imgFailed, setImgFailed] = useState(false) | |
| if (photoURL && !imgFailed) { | |
| return ( | |
| <img src={photoURL} alt={displayName || 'You'} referrerPolicy="no-referrer" | |
| onError={() => setImgFailed(true)} | |
| className="w-7 h-7 rounded-xl object-cover shrink-0 mt-0.5 ring-2 ring-brand/20" /> | |
| ) | |
| } | |
| return ( | |
| <div className="w-7 h-7 rounded-xl bg-brand/20 flex items-center justify-center shrink-0 mt-0.5"> | |
| {displayName | |
| ? <span className="text-[11px] font-bold text-brand">{displayName[0].toUpperCase()}</span> | |
| : <svg className="w-4 h-4 text-stone-500" fill="currentColor" viewBox="0 0 20 20"> | |
| <path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd"/> | |
| </svg> | |
| } | |
| </div> | |
| ) | |
| } | |
| const SendIcon = () => ( | |
| <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"/> | |
| </svg> | |
| ) | |
| const ClearIcon = () => ( | |
| <svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"/> | |
| </svg> | |
| ) | |
| const CopyIcon = () => ( | |
| <svg className="w-3 h-3 text-stone-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"/> | |
| </svg> | |
| ) | |
| const CheckIcon = () => ( | |
| <svg className="w-3 h-3 text-brand" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5"/> | |
| </svg> | |
| ) | |
| const DownloadIcon = () => ( | |
| <svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> | |
| <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> | |
| ) | |