Files
Momento/memento-note/components/document-qa-overlay.tsx
Antigravity 1fcea6ed7d
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export
- Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG)
- Add document Q&A overlay with streaming chat and PDF preview
- Add note attachments UI with status polling, grid layout, and auto-scroll
- Add task extraction AI tool and agent executor improvements
- Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings
- Fix brainstorm 'Create Note' button: add success toast and redirect to created note
- Fix memory echo notification infinite polling
- Fix chat route to always include document_search tool
- Add brainstorm i18n keys across all 14 locales
- Add socket server for real-time brainstorm collaboration
- Add hierarchical notebook selector and organize notebook dialog improvements
- Add sidebar brainstorm section with session management
- Update prisma schema with brainstorm tables, attachments, and document chunks
2026-05-14 17:43:21 +00:00

212 lines
8.1 KiB
TypeScript

'use client'
import { useState, useRef, useEffect } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { useLanguage } from '@/lib/i18n'
import { X, Send, FileText, Sparkles, Loader2, User, Plus, Square } from 'lucide-react'
interface Attachment {
id: string
fileName: string
}
interface DocumentQAOverlayProps {
attachment: Attachment
noteId: string
noteContent?: string
onClose: () => void
onApplyToNote?: (content: string) => void
}
function getMessageContent(msg: any): string {
if (typeof msg.content === 'string') return msg.content
if (msg.parts && Array.isArray(msg.parts)) {
return msg.parts
.filter((p: any) => p.type === 'text' && typeof p.text === 'string')
.map((p: any) => p.text)
.join('')
}
return ''
}
export function DocumentQAOverlay({ attachment, noteId, noteContent, onClose, onApplyToNote }: DocumentQAOverlayProps) {
const { t } = useLanguage()
const messagesEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [input, setInput] = useState('')
const pdfUrl = `/api/notes/${noteId}/attachments/${attachment.id}?download=true`
const transport = useRef(new DefaultChatTransport({
api: '/api/chat',
body: {
noteId,
noteContext: {
title: attachment.fileName,
content: noteContent || '',
tone: 'professional',
},
webSearch: false,
},
})).current
const { messages, sendMessage, status, stop } = useChat({ transport })
const isLoading = status === 'submitted' || status === 'streaming'
const lastAssistantContent = [...messages].reverse().find(m => m.role === 'assistant')
const lastAssistantText = lastAssistantContent ? getMessageContent(lastAssistantContent) : ''
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [onClose])
const handleSend = async () => {
const text = input.trim()
if (!text || isLoading) return
setInput('')
try {
await sendMessage({ text })
} catch {
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-6 bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-6xl h-[88vh] bg-background border border-border rounded-2xl shadow-2xl flex overflow-hidden">
{/* Left: PDF Preview */}
<div className="flex-1 flex flex-col border-r border-border">
<div className="p-4 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 text-primary rounded-lg">
<FileText size={18} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold truncate max-w-[300px]">{attachment.fileName}</h3>
<p className="text-[9px] uppercase font-bold tracking-widest text-muted-foreground">
PDF
</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-muted rounded-full text-muted-foreground transition-colors">
<X size={18} />
</button>
</div>
<div className="flex-1 bg-muted/30 overflow-hidden">
<iframe
src={pdfUrl}
className="w-full h-full border-0"
title={attachment.fileName}
/>
</div>
</div>
{/* Right: Chat */}
<div className="w-[400px] flex flex-col">
<div className="p-4 border-b border-border flex items-center gap-2">
<Sparkles size={16} className="text-primary" />
<h4 className="text-[11px] font-bold uppercase tracking-widest">
{t('attachments.docExpert') || 'Document Expert'}
</h4>
</div>
<div className="flex-1 p-4 overflow-y-auto space-y-3">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles size={24} className="text-primary/60" />
</div>
<p className="text-xs text-muted-foreground max-w-[220px] leading-relaxed">
{t('attachments.docQaWelcome') || `Posez une question sur "${attachment.fileName}".`}
</p>
</div>
)}
{messages.map((msg, i) => {
const content = getMessageContent(msg)
if (!content) return null
return (
<div key={msg.id || i} className={`flex gap-2.5 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<Sparkles size={10} className="text-primary" />
</div>
)}
<div
className={`max-w-[85%] rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed whitespace-pre-wrap ${
msg.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted/50 border border-border'
}`}
>
{content}
</div>
{msg.role === 'user' && (
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center shrink-0 mt-0.5">
<User size={10} className="text-muted-foreground" />
</div>
)}
</div>
)
})}
{isLoading && !lastAssistantText && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
<span className="text-xs">{t('attachments.thinking') || 'Thinking...'}</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
{lastAssistantText && onApplyToNote && (
<div className="px-4 py-2 border-t border-border">
<button
onClick={() => onApplyToNote(lastAssistantText)}
className="w-full flex items-center justify-center gap-2 py-2 text-[11px] font-bold uppercase tracking-widest text-primary hover:bg-primary/10 rounded-lg transition-colors"
>
<Plus size={14} />
{t('attachments.addToNote') || 'Ajouter à la note'}
</button>
</div>
)}
<div className="p-3 border-t border-border">
<div className="relative">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('attachments.askPlaceholder') || 'Ask about this document...'}
className="w-full bg-muted/50 border border-border rounded-xl p-3 pr-11 text-sm outline-none focus:border-primary transition-all resize-none"
rows={2}
disabled={isLoading}
/>
<button
onClick={isLoading ? stop : handleSend}
disabled={!isLoading && !input.trim()}
className="absolute right-2 bottom-2 p-2 bg-primary text-primary-foreground rounded-lg shadow-sm disabled:opacity-50 transition-all"
>
{isLoading ? <Square size={14} /> : <Send size={14} />}
</button>
</div>
</div>
</div>
</div>
</div>
)
}