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
212 lines
8.1 KiB
TypeScript
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>
|
|
)
|
|
}
|