feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
- 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
This commit is contained in:
211
memento-note/components/document-qa-overlay.tsx
Normal file
211
memento-note/components/document-qa-overlay.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user