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

- 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:
Antigravity
2026-05-14 17:43:21 +00:00
parent 195e845f0a
commit 1fcea6ed7d
228 changed files with 57656 additions and 1059 deletions

View File

@@ -93,6 +93,19 @@ const ACTION_IDS = [
{ id: 'describe-images', icon: ImageIcon, apiPath: '/api/ai/describe-image', body: (_content: string, images?: string[], lang?: string, _format?: string) => ({ imageUrls: images || [], mode: 'description', language: lang || 'fr' }), resultKey: 'descriptions', i18nKey: 'ai.action.describeImages', isImageAction: true },
]
/** API language names sent to `/api/ai/reformulate` for translate targets */
const TRANSLATE_LANGUAGE_OPTIONS: { api: string; labelKey: string }[] = [
{ api: 'French', labelKey: 'languages.targets.french' },
{ api: 'English', labelKey: 'languages.targets.english' },
{ api: 'Spanish', labelKey: 'languages.targets.spanish' },
{ api: 'German', labelKey: 'languages.targets.german' },
{ api: 'Persian', labelKey: 'languages.targets.persian' },
{ api: 'Portuguese', labelKey: 'languages.targets.portuguese' },
{ api: 'Italian', labelKey: 'languages.targets.italian' },
{ api: 'Chinese', labelKey: 'languages.targets.chinese' },
{ api: 'Japanese', labelKey: 'languages.targets.japanese' },
]
// ── Types ─────────────────────────────────────────────────────────────────────
interface GenerateResult {
@@ -238,8 +251,8 @@ export function ContextualAIChat({
tone: selectedTone,
images: noteImages || [],
}
body.noteId = noteId
} else if (chatScope !== 'all') {
// scope is a notebook ID
body.notebookId = chatScope
}
return body
@@ -297,7 +310,7 @@ export function ContextualAIChat({
noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description
).join('\n\n')
if (data.combinedSummary) {
resultText += `\n\n---\n**Résumé:** ${data.combinedSummary}`
resultText += `\n\n---\n${t('ai.inlineSummaryMarkdown')} ${data.combinedSummary}`
}
setActionPreview({ label: t(action.i18nKey), text: resultText })
} catch (e: any) {
@@ -355,7 +368,7 @@ export function ContextualAIChat({
setGenerateResult(null)
const toastId = mToast.loading(
type === 'slides' ? '⏳ Génération de la présentation...' : '⏳ Génération du diagramme...',
type === 'slides' ? t('ai.generateSlidesLoading') : t('ai.generateDiagramLoading'),
{ duration: Infinity }
)
@@ -373,7 +386,7 @@ export function ContextualAIChat({
})
const data = await res.json()
if (!res.ok || !data.success) {
mToast.error(data.error || 'Erreur', { id: toastId })
mToast.error(data.error || t('ai.errorShort'), { id: toastId })
setGenerateLoading(null)
return
}
@@ -391,17 +404,17 @@ export function ContextualAIChat({
generatePollRef.current = null
setGenerateLoading(null)
setGenerateResult({ type, canvasId: poll.canvasId, noteId: poll.noteId })
mToast.success('Prêt !', { id: toastId })
mToast.success(t('ai.readyToast'), { id: toastId })
} else if (poll.status === 'failure') {
clearInterval(generatePollRef.current!)
generatePollRef.current = null
setGenerateLoading(null)
mToast.error(poll.error || 'Erreur', { id: toastId })
mToast.error(poll.error || t('ai.errorShort'), { id: toastId })
}
} catch { }
}, 3000)
} catch {
mToast.error('Erreur', { id: toastId })
mToast.error(t('ai.errorShort'), { id: toastId })
setGenerateLoading(null)
}
}
@@ -516,7 +529,7 @@ export function ContextualAIChat({
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Erreur IA')
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
setResourcePreview({ text: data.enrichedContent, source: mode })
} catch (e: any) {
mToast.error(e.message || t('ai.resource.enrichErrorShort'))
@@ -583,9 +596,9 @@ export function ContextualAIChat({
<div className="flex border-b border-border shrink-0 px-2">
{[
{ id: 'actions', label: 'Actions', icon: <Sparkles size={16} /> },
{ id: 'chat', label: 'Discussion', icon: <MessageSquare size={16} /> },
{ id: 'resource', label: 'Ressource', icon: <Link2 size={16} /> },
{ id: 'actions' as const, label: t('ai.assistantTabActions'), icon: <Sparkles size={16} /> },
{ id: 'chat' as const, label: t('ai.chatTab'), icon: <MessageSquare size={16} /> },
{ id: 'resource' as const, label: t('ai.resourceTab'), icon: <Link2 size={16} /> },
].map(tab => (
<button
key={tab.id}
@@ -627,7 +640,7 @@ export function ContextualAIChat({
<div className="absolute inset-0 z-20 flex flex-col bg-memento-paper/95 dark:bg-background/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
<div className="px-6 py-4 border-b border-border/40 flex items-center justify-between shrink-0">
<p className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">
{resourcePreview.source === 'chat' ? 'Injecter depuis Discussion' : 'Aperçu IA'}
{resourcePreview.source === 'chat' ? t('ai.resourcePreviewInjectFromChat') : t('ai.resourcePreviewAiTitle')}
</p>
<button onClick={() => setResourcePreview(null)} className="text-foreground/40 hover:text-foreground">
<X size={18} />
@@ -661,7 +674,7 @@ export function ContextualAIChat({
<div className="w-20 h-20 rounded-full bg-card/40 backdrop-blur-sm border border-dashed border-border flex items-center justify-center shadow-sm">
<MessageSquare size={32} className="text-memento-blue/60" />
</div>
<p className="text-xs font-serif italic text-foreground/40 leading-relaxed max-w-[200px]">Posez une question à l'Assistant pour commencer.</p>
<p className="text-xs font-serif italic text-foreground/40 leading-relaxed max-w-[200px]">{t('ai.askToStart')}</p>
</div>
)}
{messages.map((msg: UIMessage) => {
@@ -676,9 +689,9 @@ export function ContextualAIChat({
</div>
{isAssistant && onApplyToNote && (hoveredMsgId === msg.id || messages.at(-1)?.id === msg.id) && (
<div className="flex gap-2 mt-3 opacity-0 group-hover:opacity-100 transition-all">
<button onClick={() => handleInjectFromChat(content, 'replace')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-foreground text-background hover:opacity-90">REPLACER</button>
<button onClick={() => handleInjectFromChat(content, 'complete')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-card/40 backdrop-blur-sm border border-border text-foreground hover:bg-card/60">COMPLÉTER</button>
<button onClick={() => handleInjectFromChat(content, 'merge')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-card/40 backdrop-blur-sm border border-border text-foreground hover:bg-card/60">FUSIONNER</button>
<button onClick={() => handleInjectFromChat(content, 'replace')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-foreground text-background hover:opacity-90">{t('ai.injectReplace')}</button>
<button onClick={() => handleInjectFromChat(content, 'complete')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-card/40 backdrop-blur-sm border border-border text-foreground hover:bg-card/60">{t('ai.injectComplete')}</button>
<button onClick={() => handleInjectFromChat(content, 'merge')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-card/40 backdrop-blur-sm border border-border text-foreground hover:bg-card/60">{t('ai.injectMerge')}</button>
</div>
)}
</div>
@@ -699,7 +712,7 @@ export function ContextualAIChat({
<div className="px-6 py-8 border-t border-border shrink-0 space-y-6">
<div className="flex flex-col gap-4">
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.25em] font-bold text-foreground/40 px-1">CONTEXTE</label>
<label className="text-[10px] uppercase tracking-[0.25em] font-bold text-foreground/40 px-1">{t('ai.chatPanelContext')}</label>
<div className="flex flex-col gap-2">
<button
onClick={() => setChatScope('note')}
@@ -709,19 +722,19 @@ export function ContextualAIChat({
)}
>
<BookOpen size={14} className="text-blueprint/60" />
<span>{t('ai.activeNote') || 'Cette note'}</span>
<span className="ml-auto text-[8px] bg-blueprint/10 text-blueprint px-1.5 py-0.5 rounded uppercase font-bold">Auto</span>
<span>{t('ai.thisNote')}</span>
<span className="ml-auto text-[8px] bg-blueprint/10 text-blueprint px-1.5 py-0.5 rounded uppercase font-bold">{t('ai.scopeAutoBadge')}</span>
</button>
<div className="flex items-center gap-2 px-2">
<div className="h-px flex-1 bg-border/40" />
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest">+ Carnet</span>
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest">{t('ai.chatPanelNotebookPlus')}</span>
<div className="h-px flex-1 bg-border/40" />
</div>
<HierarchicalNotebookSelector
notebooks={(notebooks || []).filter(nb => !nb.trashedAt)}
selectedId={chatScope !== 'note' && chatScope !== 'all' ? chatScope : null}
onSelect={(id) => setChatScope(id)}
placeholder="Inclure un carnet..."
placeholder={t('ai.chatNotebookSelectPlaceholder')}
className="w-full"
size="sm"
dropUp
@@ -729,7 +742,7 @@ export function ContextualAIChat({
</div>
</div>
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.25em] font-bold text-foreground/40 px-1">TON D'ÉCRITURE</label>
<label className="text-[10px] uppercase tracking-[0.25em] font-bold text-foreground/40 px-1">{t('ai.chatPanelWritingTone')}</label>
<div className="grid grid-cols-4 gap-1.5">
{TONES.map((tone) => {
const Icon = tone.icon
@@ -758,7 +771,7 @@ export function ContextualAIChat({
<textarea
rows={4}
className="w-full bg-card/60 border border-border rounded-2xl p-5 pr-14 text-sm outline-none focus:border-memento-blue transition-all resize-none leading-relaxed font-light custom-scrollbar shadow-sm text-foreground"
placeholder="Posez votre question sur cette note..."
placeholder={t('ai.chatNoteQuestionPlaceholder')}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } }}
@@ -768,7 +781,7 @@ export function ContextualAIChat({
<button
onClick={() => setWebSearch(!webSearch)}
className={cn("p-2.5 rounded-xl transition-colors", webSearch ? "text-memento-blue bg-memento-blue/10" : "text-foreground/20 hover:text-foreground")}
title="Web Search"
title={t('ai.webSearchLabel')}
>
<Globe size={18} />
</button>
@@ -777,7 +790,7 @@ export function ContextualAIChat({
</button>
</div>
</div>
<p className="text-[9px] text-foreground/30 text-center mt-2 uppercase tracking-[0.2em] font-bold italic">Maj+Entrée = nouvelle ligne</p>
<p className="text-[9px] text-foreground/30 text-center mt-2 uppercase tracking-[0.2em] font-bold italic">{t('ai.newLineHint')}</p>
</div>
</motion.div>
)}
@@ -855,18 +868,18 @@ export function ContextualAIChat({
>
<div className="mt-2 p-5 bg-card/40 backdrop-blur-sm border border-memento-blue/30 rounded-2xl space-y-5 shadow-sm">
<div className="grid grid-cols-3 gap-2">
{['Français', 'English', 'Español', 'Deutsch', 'Persan', 'Portugais', 'Italiano', 'Chinois', 'Japonais'].map((lang) => (
{TRANSLATE_LANGUAGE_OPTIONS.map(({ api, labelKey }) => (
<button
key={lang}
onClick={() => setTranslateTarget(lang)}
key={api}
onClick={() => setTranslateTarget(api)}
className={cn(
"py-2 px-1 rounded-lg border text-[10px] font-bold uppercase tracking-tighter transition-all",
translateTarget === lang
? "bg-memento-blue border-memento-blue text-white shadow-md shadow-memento-blue/20"
translateTarget === api
? "bg-memento-blue border-memento-blue text-white shadow-md shadow-memento-blue/20"
: "bg-card/60 border-border text-foreground/60 hover:border-foreground/20"
)}
>
{lang}
{t(labelKey)}
</button>
))}
</div>
@@ -880,7 +893,7 @@ export function ContextualAIChat({
setCustomLangInput(e.target.value)
setTranslateTarget(e.target.value)
}}
placeholder="ex: Arabe, Russe..."
placeholder={t('languages.customPlaceholder')}
className="w-full bg-card/60 border border-border rounded-xl px-4 py-2.5 text-[11px] outline-none focus:border-memento-blue transition-all text-foreground"
/>
</div>
@@ -951,8 +964,8 @@ export function ContextualAIChat({
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.style')}</span>
<select value={slideStyle} onChange={e => setSlideStyle(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-memento-blue/10 transition-all cursor-pointer text-foreground">
<option value="professional">{t('ai.generate.styleProfessional')}</option>
<option value="creative">Creative</option>
<option value="brutalist">Brutalist</option>
<option value="creative">{t('ai.generate.styleCreative')}</option>
<option value="brutalist">{t('ai.generate.styleBrutalist')}</option>
</select>
</div>
</div>
@@ -968,14 +981,14 @@ export function ContextualAIChat({
>
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold text-memento-blue uppercase tracking-widest flex items-center gap-1.5">
<Check size={12} /> Présentation prête
<Check size={12} /> {t('ai.presentationReadyBadge')}
</span>
<a
href={`/lab?id=${generateResult.canvasId}`}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 bg-card/60 rounded-lg text-foreground/50 hover:text-foreground hover:bg-card transition-colors"
title="Voir dans L'Atelier"
title={t('ai.openInLabTitle')}
>
<ExternalLink size={12} />
</a>
@@ -1001,13 +1014,13 @@ export function ContextualAIChat({
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch {
mToast.error('Échec du téléchargement')
mToast.error(t('ai.downloadFailedToast'))
}
}}
className="flex items-center justify-center gap-2 w-full py-2.5 bg-memento-blue text-white rounded-lg text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 transition-opacity shadow-sm"
>
<Download size={13} />
Télécharger .pptx
{t('ai.pptxDownloadButton')}
</button>
</motion.div>
)}