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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user