20 Commits

Author SHA1 Message Date
Antigravity
0d8252aec0 fix(ci): typings dagre (@types/dagre) et pptx (PptxGenJSModule, fill bullets)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m8s
Corrige le typecheck Next Docker: module dagre sans .d.ts, et annotations
Presentation vs constructeur pptxgenjs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 22:04:07 +00:00
Antigravity
33ad874e5d fix(agents): TYPE_DEFAULTS.tools en string[] pour run-for-note
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m1s
Prisma attend une chaîne JSON pour Agent.tools ; on sérialise au create.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 22:01:12 +00:00
Antigravity
3b72e4afbb fix(prisma): colonnes Agent sourceNoteIds, slideTheme, slideStyle
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m33s
Aligne prisma/schema.prisma avec agent-actions.ts (typecheck Docker/Next).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:58:18 +00:00
Antigravity
ccc94a4b35 fix(ci): next build --webpack pour dagre/pptxgenjs/elkjs (Turbopack ne résout pas)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m36s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:53:03 +00:00
Antigravity
fb6823e25e fix(lab): ouvrir le bon diagramme sans F5 (?id alias ?canvas + tri canvases)
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:52:30 +00:00
Antigravity
7326cfc98f fix: import type + lazy singleton pour dagre et pptxgenjs (Turbopack build)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:48:54 +00:00
Antigravity
4f950740eb feat: options de génération (thème/style/type) + masquer agents one-shot
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
- Les agents créés par run-for-note ont frequency:'one-shot' et sont
  filtrés de la page /agents (NOT frequency:'one-shot' dans getAgents)
- Panneau slides : sélecteurs Thème (11 palettes) + Style (sharp/soft/rounded/pill)
- Panneau diagramme : sélecteurs Type (auto/flowchart/mindmap/timeline/
  org-chart/architecture/process-map) + Style (sketchy/austere/sketch+)
- Les valeurs sélectionnées sont transmises à l'API et injectées dans le prompt

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:45:57 +00:00
Antigravity
db200bbc9f fix: serverExternalPackages pour pptxgenjs, dagre, elkjs (Turbopack)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:41:07 +00:00
Antigravity
08a49a04ce fix: désactiver thinking mode DeepSeek pour fiabiliser le tool calling
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:37:55 +00:00
Antigravity
ab914f0587 fix: reasoning_content DeepSeek — retry avec maxSteps=1 si erreur thinking mode
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:33:16 +00:00
Antigravity
b55f558a62 fix: import auth depuis @/auth (pas @/lib/auth)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:27:43 +00:00
Antigravity
19d0b2759a fix: toast position bottom-right pour ne pas bloquer la navigation
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:27:07 +00:00
Antigravity
34a977b5c4 fix: runAgent fire-and-forget + polling sur la page /agents
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
Le bouton Play des cartes agents appelait runAgent (Server Action) qui
attendait executeAgent (~2-5 min) → spinner bloqué sans résultat.

- runAgent retourne immédiatement { success, agentId }
- agent-card.tsx lance un polling toutes les 3s sur GET
  /api/agents/run-for-note?agentId= jusqu'au statut terminal
- Toast persistant Sonner pendant la génération, mis à jour au résultat
- Cleanup automatique du polling au démontage

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:26:35 +00:00
Antigravity
75b08ef53b fix: génération asynchrone (fire-and-forget + polling)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Le problème : la route POST /api/agents/run-for-note bloquait le thread
HTTP pendant toute la durée de génération (2-5 min), provoquant un
spinner infini sans résultat.

Solution :
- POST retourne immédiatement avec { agentId } et lance executeAgent
  en arrière-plan (fire-and-forget, sans await)
- GET /api/agents/run-for-note?agentId= retourne le statut de la
  dernière agentAction (running | success | failure) + canvasId/noteId
- Le client poll le statut toutes les 3 secondes jusqu'au résultat,
  le toast persistant Sonner se met à jour automatiquement
- Cleanup du polling au démontage du composant

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:21:28 +00:00
Antigravity
98e246e257 fix: force git reset on deploy + toast persistant de génération
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
- Remplace git pull par git fetch + git reset --hard origin/main dans
  le workflow CI pour éviter les conflits avec les fichiers locaux
- Ajoute un toast Sonner persistant (duration: Infinity) dès le clic sur
  Générer, qui survit à la navigation et se met à jour en succès/erreur
  avec action directe (Télécharger / Ouvrir)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:15:38 +00:00
Antigravity
ff0b56f805 feat(ai-note): passer noteId au ContextualAIChat en mode liste (note-inline-editor)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:11:21 +00:00
Antigravity
d1e08f64c8 feat(ai-note): ajouter boutons Générer slides/diagramme dans le panneau IA
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s
- Nouveau endpoint POST /api/agents/run-for-note : crée un agent one-shot
  (slide-generator ou excalidraw-generator) avec la note courante comme source
  et l'exécute immédiatement
- ContextualAIChat : prop noteId + section "Générer depuis cette note" avec
  deux boutons gradient (violet=slides, cyan=diagramme), spinner pendant la
  génération, bouton de téléchargement .pptx ou lien "Ouvrir dans le Lab"
  après succès
- note-editor.tsx : passage de note.id à ContextualAIChat
- i18n fr/en : nouvelles clés ai.generate.*

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:07:43 +00:00
Antigravity
e7f28abccc fix(pptx): pre-fetch images server-side before passing to pptxgenjs
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s
pptxgenjs sur Node.js ne peut pas charger les URLs qui nécessitent une auth
ou des cookies. On fetch maintenant chaque imageUrl côté serveur et on la
convertit en data URI base64 avant d'appeler buildPresentation.
Si le fetch échoue (timeout 8s, 4xx/5xx), l'imageUrl est supprimée et
le placeholder texte s'affiche à la place.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 20:57:29 +00:00
Antigravity
129d5541e6 feat(agents): refonte complète slide-generator + excalidraw-generator
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s
Slide generator (generate_pptx):
- Pivot vers génération PowerPoint natif (pptxgenjs) au lieu de Reveal.js HTML
- 4 nouveaux layouts diagramme : timeline, process, comparison, metrics
- 2 nouveaux layouts image : image-content (texte + image), image-full (plein cadre)
- Redesign visuel de tous les layouts (cover split, section full-bleed, header band)
- Palettes corrigées : bg blanc sur toutes les palettes, contrastes réels
- fit:shrink systématique sur tous les textes pour éviter les débordements
- Extraction automatique des images des notes (Markdown/HTML) et injection dans le prompt IA
- Prompt IA renforcé : impose "style" et "theme" explicitement dans le JSON, impose ≥2 layouts diagramme
- Fix overlap timeline : zones de texte calculées sans collision avec les cercles
- Notification agent mise à jour : bouton download .pptx au lieu d'ouvrir HTML

Excalidraw generator:
- Layout Dagre/ELK pour graphes auto-positionnés
- Styles visuels : coloré, austère, sketch-plus (Virgil font)
- Zones/containers pour architecture-cloud
- Sanitisation du graphe et métriques de qualité de layout

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 20:55:15 +00:00
Antigravity
21fb56de3f feat(sidebar): drag-and-drop notebook reordering
- Add GripVertical handle (visible on hover) as reorder affordance
- Rows are now draggable; uses 'application/x-notebook' dataTransfer type
  to coexist with existing note-to-notebook drag ('text/plain')
- Drop indicator: thin primary-colored line above the insertion target
- Dragged item fades to 40% opacity during drag
- On drop: calls updateNotebookOrderOptimistic → POST /api/notebooks/reorder → persisted in DB
- draggingNbRef (useRef) used for stale-closure-safe detection in dragover handler
2026-05-03 21:06:34 +00:00
30 changed files with 8467 additions and 3656 deletions

View File

@@ -121,7 +121,8 @@ jobs:
echo "=== Git pull ==="
git config --global --add safe.directory /opt/memento
git pull origin main
git fetch origin main
git reset --hard origin/main
echo "=== Building ==="
docker compose build memento-note

View File

@@ -14,18 +14,18 @@ export const dynamic = 'force-dynamic'
export const revalidate = 0
export default async function LabPage(props: {
searchParams: Promise<{ id?: string }>
searchParams: Promise<{ id?: string; canvas?: string }>
}) {
const searchParams = await props.searchParams
const id = searchParams.id
/** Canonical param is id; notifications / older UI used canvas= */
const requestedId = searchParams.id ?? searchParams.canvas
const session = await auth()
if (!session?.user?.id) redirect('/login')
const canvases = await getCanvases()
// Resolve current canvas correctly
const currentCanvasId = searchParams.id || (canvases.length > 0 ? canvases[0].id : undefined)
const currentCanvasId = requestedId || (canvases.length > 0 ? canvases[0].id : undefined)
const currentCanvas = currentCanvasId ? canvases.find(c => c.id === currentCanvasId) : undefined
// Wrapper for server action creation

View File

@@ -19,6 +19,7 @@ export async function createAgent(data: {
role: string
sourceUrls?: string[]
sourceNotebookId?: string
sourceNoteIds?: string[]
targetNotebookId?: string
frequency?: string
tools?: string[]
@@ -28,6 +29,8 @@ export async function createAgent(data: {
scheduledTime?: string
scheduledDay?: number
timezone?: string
slideTheme?: string
slideStyle?: string
}) {
const session = await auth()
if (!session?.user?.id) {
@@ -43,6 +46,7 @@ export async function createAgent(data: {
role: data.role,
sourceUrls: data.sourceUrls ? JSON.stringify(data.sourceUrls) : null,
sourceNotebookId: data.sourceNotebookId || null,
sourceNoteIds: data.sourceNoteIds ? JSON.stringify(data.sourceNoteIds) : null,
targetNotebookId: data.targetNotebookId || null,
frequency: data.frequency || 'manual',
tools: data.tools ? JSON.stringify(data.tools) : '[]',
@@ -52,6 +56,8 @@ export async function createAgent(data: {
scheduledTime: data.scheduledTime || '08:00',
scheduledDay: data.scheduledDay ?? null,
timezone: data.timezone || null,
slideTheme: data.slideTheme || null,
slideStyle: data.slideStyle || null,
userId: session.user.id,
}
})
@@ -88,6 +94,7 @@ export async function updateAgent(id: string, data: {
role?: string
sourceUrls?: string[]
sourceNotebookId?: string | null
sourceNoteIds?: string[] | null
targetNotebookId?: string | null
frequency?: string
isEnabled?: boolean
@@ -98,6 +105,8 @@ export async function updateAgent(id: string, data: {
scheduledTime?: string
scheduledDay?: number | null
timezone?: string
slideTheme?: string | null
slideStyle?: string | null
}) {
const session = await auth()
if (!session?.user?.id) {
@@ -117,6 +126,7 @@ export async function updateAgent(id: string, data: {
if (data.role !== undefined) updateData.role = data.role
if (data.sourceUrls !== undefined) updateData.sourceUrls = JSON.stringify(data.sourceUrls)
if (data.sourceNotebookId !== undefined) updateData.sourceNotebookId = data.sourceNotebookId
if (data.sourceNoteIds !== undefined) updateData.sourceNoteIds = data.sourceNoteIds ? JSON.stringify(data.sourceNoteIds) : null
if (data.targetNotebookId !== undefined) updateData.targetNotebookId = data.targetNotebookId
if (data.frequency !== undefined) updateData.frequency = data.frequency
if (data.isEnabled !== undefined) updateData.isEnabled = data.isEnabled
@@ -127,6 +137,8 @@ export async function updateAgent(id: string, data: {
if (data.scheduledTime !== undefined) updateData.scheduledTime = data.scheduledTime
if (data.scheduledDay !== undefined) updateData.scheduledDay = data.scheduledDay
if (data.timezone !== undefined) updateData.timezone = data.timezone
if (data.slideTheme !== undefined) updateData.slideTheme = data.slideTheme
if (data.slideStyle !== undefined) updateData.slideStyle = data.slideStyle
// Recalculate nextRun when scheduling fields change
const shouldRecalcNextRun =
@@ -192,7 +204,7 @@ export async function getAgents() {
try {
const agents = await prisma.agent.findMany({
where: { userId: session.user.id },
where: { userId: session.user.id, NOT: { frequency: 'one-shot' } },
include: {
_count: { select: { actions: true } },
actions: {
@@ -220,21 +232,21 @@ export async function runAgent(id: string) {
if (!session?.user?.id) {
throw new Error('Non autorise')
}
const userId = session.user.id
try {
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
const result = await executeAgent(id, session.user.id)
revalidatePath('/agents')
revalidatePath('/')
return result
} catch (error) {
console.error('Error running agent:', error)
return {
success: false,
actionId: '',
error: error instanceof Error ? error.message : 'Erreur inconnue'
}
// Verify ownership
const agent = await prisma.agent.findUnique({ where: { id }, select: { id: true, userId: true } })
if (!agent || agent.userId !== userId) {
return { success: false, agentId: id, error: 'Agent introuvable' }
}
// Fire and forget — return immediately so the UI doesn't block
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(id, userId))
.then(() => { /* revalidation is handled client-side via polling */ })
.catch(err => console.error('[runAgent] Background error:', err))
return { success: true, agentId: id, status: 'running' }
}
// --- History ---

View File

@@ -34,7 +34,8 @@ export async function getCanvases() {
return prisma.canvas.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' }
/** Most recently updated first — default canvas matches latest work (agent output, edits) */
orderBy: { updatedAt: 'desc' },
})
}

View File

@@ -0,0 +1,140 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
type GenerateType = 'slide-generator' | 'excalidraw-generator'
const TYPE_DEFAULTS: Record<GenerateType, {
role: string
tools: string[]
maxSteps: number
}> = {
'slide-generator': {
role: 'Crée une présentation PowerPoint professionnelle et visuelle à partir du contenu de la note fournie.',
tools: ['note_search', 'note_read', 'generate_pptx'],
maxSteps: 8,
},
'excalidraw-generator': {
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
tools: ['note_search', 'note_read', 'generate_excalidraw'],
maxSteps: 6,
},
}
// ─── POST : kick off generation (fire-and-forget) ──────────────────────────
export async function POST(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
}
const userId = session.user.id
const body = await req.json()
const { noteId, type, theme, style } = body as {
noteId: string
type: GenerateType
theme?: string
style?: string
}
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
}
const note = await prisma.note.findFirst({
where: { id: noteId, userId },
select: { id: true, title: true, notebookId: true },
})
if (!note) {
return NextResponse.json({ error: 'Note introuvable' }, { status: 404 })
}
const defaults = TYPE_DEFAULTS[type]
const agentName = type === 'slide-generator'
? `Slides — ${(note.title || 'Note').substring(0, 40)}`
: `Diagramme — ${(note.title || 'Note').substring(0, 40)}`
const agent = await prisma.agent.create({
data: {
name: agentName,
type,
role: defaults.role,
tools: JSON.stringify(defaults.tools),
maxSteps: defaults.maxSteps,
frequency: 'one-shot',
isEnabled: true,
sourceNoteIds: JSON.stringify([noteId]),
targetNotebookId: note.notebookId ?? undefined,
slideTheme: theme ?? 'vibrant_tech',
slideStyle: style ?? 'soft',
userId,
},
})
// ── Fire and forget — do NOT await so the HTTP response returns immediately ──
// In Node.js / Docker self-hosted, the process keeps running after response.
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(agent.id, userId))
.catch(err => console.error('[run-for-note] Background agent error:', err))
return NextResponse.json({ success: true, agentId: agent.id, status: 'running' })
}
// ─── GET : poll current agent status ──────────────────────────────────────
export async function GET(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
}
const userId = session.user.id
const agentId = req.nextUrl.searchParams.get('agentId')
if (!agentId) {
return NextResponse.json({ error: 'agentId manquant' }, { status: 400 })
}
// Verify ownership
const agent = await prisma.agent.findFirst({
where: { id: agentId, userId },
select: { id: true },
})
if (!agent) {
return NextResponse.json({ error: 'Agent introuvable' }, { status: 404 })
}
// Return latest action for this agent
const action = await prisma.agentAction.findFirst({
where: { agentId },
orderBy: { createdAt: 'desc' },
select: { id: true, status: true, result: true, log: true },
})
if (!action) {
// Action not created yet (race condition in first poll) — still running
return NextResponse.json({ status: 'running' })
}
// If success, find canvasId from the related canvas (result stores canvas id)
let canvasId: string | null = null
let noteId: string | null = null
if (action.status === 'success' && action.result) {
// result field: the executor stores canvas.id or note.id
const canvas = await prisma.canvas.findFirst({
where: { id: action.result },
select: { id: true },
})
if (canvas) {
canvasId = canvas.id
} else {
noteId = action.result
}
}
return NextResponse.json({
status: action.status, // 'running' | 'success' | 'failure'
actionId: action.id,
canvasId,
noteId,
error: action.status === 'failure' ? (action.log || 'Erreur inconnue') : undefined,
})
}

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
export async function GET(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const canvasId = req.nextUrl.searchParams.get('id')
if (!canvasId) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
const canvas = await prisma.canvas.findUnique({
where: { id: canvasId, userId: session.user.id },
})
if (!canvas) return NextResponse.json({ error: 'Not found' }, { status: 404 })
let parsed: any
try { parsed = JSON.parse(canvas.data) } catch {
return NextResponse.json({ error: 'Invalid data' }, { status: 500 })
}
if (parsed.type !== 'pptx' || !parsed.base64) {
return NextResponse.json({ error: 'Not a PPTX canvas' }, { status: 400 })
}
const byteChars = atob(parsed.base64)
const bytes = new Uint8Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) bytes[i] = byteChars.charCodeAt(i)
const filename = parsed.filename || `${canvas.name.replace(/[^a-zA-Z0-9]/g, '_')}.pptx`
// Auto-delete after serving
await prisma.canvas.delete({ where: { id: canvasId } })
return new NextResponse(bytes, {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': String(bytes.length),
},
})
} catch (error) {
console.error('[Canvas Download]', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
try {
const canvasId = req.nextUrl.searchParams.get('id')
console.log('[Slides API] Request for id:', canvasId)
if (!canvasId) {
console.log('[Slides API] ERROR: Missing id')
return new Response(JSON.stringify({ error: 'Missing id' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const canvas = await prisma.canvas.findUnique({
where: { id: canvasId },
})
if (!canvas) {
console.log('[Slides API] ERROR: Canvas not found for id:', canvasId)
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
})
}
console.log('[Slides API] Canvas found:', canvas.name, '| data length:', canvas.data?.length)
console.log('[Slides API] Raw data start:', canvas.data?.substring(0, 200))
let parsed: any
try {
parsed = JSON.parse(canvas.data)
} catch (parseErr) {
console.log('[Slides API] ERROR: JSON parse failed:', parseErr)
return new Response(JSON.stringify({ error: 'Invalid data' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
console.log('[Slides API] Parsed type:', parsed.type, '| has html:', !!parsed.html, '| html length:', parsed.html?.length)
console.log('[Slides API] HTML start:', parsed.html?.substring(0, 150))
if (parsed.type !== 'slides' || !parsed.html) {
console.log('[Slides API] ERROR: Not a slides canvas. type:', parsed.type, 'html exists:', !!parsed.html)
return new Response(JSON.stringify({ error: 'Not a slides canvas' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
console.log('[Slides API] SUCCESS: Returning HTML, length:', parsed.html.length)
return new Response(parsed.html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache',
},
})
} catch (error) {
console.error('[Slides API] FATAL:', error)
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}

View File

@@ -5,7 +5,7 @@
* Compact card matching the reference design — with a "Next Run / Status" footer.
*/
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
@@ -83,21 +83,51 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
const dateLocale = language === 'fr' ? fr : enUS
const isNew = Date.now() - new Date(agent.createdAt).getTime() < 5 * 60 * 1000
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Cleanup polling on unmount
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
const handleRun = async () => {
setIsRunning(true)
const toastId = toast.loading(
t('agents.toasts.running') || `Agent "${agent.name}" en cours…`,
{ description: t('agents.toasts.runningDesc') || 'La génération peut prendre quelques minutes.', duration: Infinity }
)
try {
const { runAgent } = await import('@/app/actions/agent-actions')
const result = await runAgent(agent.id)
if (result.success) {
toast.success(t('agents.toasts.runSuccess', { name: agent.name }))
} else {
toast.error(t('agents.toasts.runError', { error: result.error || t('agents.toasts.runFailed') }))
if (!result.success) {
toast.error(t('agents.toasts.runError', { error: result.error || t('agents.toasts.runFailed') }), { id: toastId })
setIsRunning(false)
return
}
// Poll status every 3 s until terminal state
if (pollRef.current) clearInterval(pollRef.current)
pollRef.current = setInterval(async () => {
try {
const res = await fetch(`/api/agents/run-for-note?agentId=${agent.id}`)
const data = await res.json()
if (data.status === 'success') {
clearInterval(pollRef.current!)
pollRef.current = null
setIsRunning(false)
toast.success(t('agents.toasts.runSuccess', { name: agent.name }), { id: toastId, duration: 6000 })
onRefresh()
} else if (data.status === 'failure') {
clearInterval(pollRef.current!)
pollRef.current = null
setIsRunning(false)
toast.error(t('agents.toasts.runError', { error: data.error || t('agents.toasts.runFailed') }), { id: toastId })
onRefresh()
}
} catch { /* network error — keep polling */ }
}, 3000)
} catch {
toast.error(t('agents.toasts.runGenericError'))
} finally {
toast.error(t('agents.toasts.runGenericError'), { id: toastId })
setIsRunning(false)
onRefresh()
}
}

View File

@@ -6,15 +6,15 @@
* Novice-friendly: hides system prompt and tools behind "Advanced mode".
*/
import { useState, useMemo, useRef } from 'react'
import { X, Plus, Trash2, Globe, FileSearch, FilePlus, FileText, ExternalLink, Brain, ChevronDown, ChevronUp, HelpCircle, Mail, ImageIcon } from 'lucide-react'
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
import { X, Plus, Trash2, Globe, FileSearch, FilePlus, FileText, ExternalLink, Brain, ChevronDown, ChevronUp, HelpCircle, Mail, ImageIcon, Presentation, Pencil, Check } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
// --- Types ---
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom'
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator'
/** Small "?" tooltip shown next to form labels */
function FieldHelp({ tooltip }: { tooltip: string }) {
@@ -41,6 +41,7 @@ interface AgentFormProps {
role: string
sourceUrls?: string | null
sourceNotebookId?: string | null
sourceNoteIds?: string | null
targetNotebookId?: string | null
frequency: string
tools?: string | null
@@ -50,6 +51,8 @@ interface AgentFormProps {
scheduledTime?: string | null
scheduledDay?: number | null
timezone?: string | null
slideTheme?: string | null
slideStyle?: string | null
} | null
notebooks: { id: string; name: string; icon?: string | null }[]
onSave: (data: FormData) => Promise<void>
@@ -62,6 +65,8 @@ const TOOL_PRESETS: Record<string, string[]> = {
researcher: ['web_search', 'web_scrape', 'note_search', 'note_create', 'memory_search'],
monitor: ['note_search', 'note_read', 'note_create', 'memory_search'],
custom: ['memory_search'],
'slide-generator': ['generate_pptx'],
'excalidraw-generator': ['generate_excalidraw'],
}
// --- Shared class strings ---
@@ -89,6 +94,13 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
return ['']
})
const [sourceNotebookId, setSourceNotebookId] = useState(agent?.sourceNotebookId || '')
const [sourceNoteIds, setSourceNoteIds] = useState<string[]>(() => {
if (agent?.sourceNoteIds) {
try { return JSON.parse(agent.sourceNoteIds) } catch { return [] }
}
return []
})
const [noteOptions, setNoteOptions] = useState<{ id: string; title: string }[]>([])
const [targetNotebookId, setTargetNotebookId] = useState(agent?.targetNotebookId || '')
const [frequency, setFrequency] = useState(agent?.frequency || 'manual')
const [scheduledTime, setScheduledTime] = useState(agent?.scheduledTime || '08:00')
@@ -110,7 +122,36 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
const [maxSteps, setMaxSteps] = useState(agent?.maxSteps || 10)
const [notifyEmail, setNotifyEmail] = useState(agent?.notifyEmail || false)
const [includeImages, setIncludeImages] = useState(agent?.includeImages || false)
const [slideTheme, setSlideTheme] = useState(agent?.slideTheme || '')
const [slideStyle, setSlideStyle] = useState<'soft' | 'sharp' | 'rounded' | 'pill'>(
(agent?.slideStyle as 'soft' | 'sharp' | 'rounded' | 'pill') || 'soft'
)
const [excalidrawStyle, setExcalidrawStyle] = useState<'default' | 'austere' | 'sketch-plus'>(() => {
if (agent?.slideStyle === 'austere') return 'austere'
if (agent?.slideStyle === 'sketch-plus') return 'sketch-plus'
return 'default'
})
const [excalidrawType, setExcalidrawType] = useState<'auto' | 'architecture-cloud' | 'flowchart' | 'mindmap' | 'org-chart' | 'timeline' | 'process-map'>(() => {
const value = (agent?.slideTheme || '').trim()
if (value === 'auto' || value === 'architecture-cloud' || value === 'mindmap' || value === 'org-chart' || value === 'timeline' || value === 'process-map') return value
return 'flowchart'
})
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
if (!sourceNotebookId || (type !== 'slide-generator' && type !== 'excalidraw-generator' && type !== 'monitor')) {
setNoteOptions([])
return
}
fetch(`/api/notes?notebookId=${sourceNotebookId}&limit=50`)
.then(r => r.json())
.then(data => {
const notes = Array.isArray(data.data) ? data.data : Array.isArray(data) ? data : []
setNoteOptions(notes.map((n: any) => ({ id: n.id, title: n.title || 'Sans titre' })))
})
.catch(() => setNoteOptions([]))
}, [sourceNotebookId, type])
const [showAdvanced, setShowAdvanced] = useState(() => {
// Auto-open advanced if editing an agent with custom tools or custom prompt
if (agent?.tools) {
@@ -133,6 +174,9 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
{ id: 'note_create', icon: FilePlus, labelKey: 'agents.tools.noteCreate', external: false },
{ id: 'url_fetch', icon: ExternalLink, labelKey: 'agents.tools.urlFetch', external: false },
{ id: 'memory_search', icon: Brain, labelKey: 'agents.tools.memorySearch', external: false },
{ id: 'generate_pptx', icon: Presentation, labelKey: 'agents.tools.generatePptx', external: false },
{ id: 'generate_slides', icon: Presentation, labelKey: 'agents.tools.generateSlides', external: false },
{ id: 'generate_excalidraw', icon: Pencil, labelKey: 'agents.tools.generateExcalidraw', external: false },
], [])
// Track previous type to detect user-initiated type changes
@@ -172,10 +216,14 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
formData.set('frequency', frequency)
formData.set('targetNotebookId', targetNotebookId)
if (type === 'monitor') {
if (type === 'monitor' || type === 'slide-generator' || type === 'excalidraw-generator') {
formData.set('sourceNotebookId', sourceNotebookId)
}
if (sourceNoteIds.length > 0) {
formData.set('sourceNoteIds', JSON.stringify(sourceNoteIds))
}
const validUrls = urls.filter(u => u.trim())
if (validUrls.length > 0) {
formData.set('sourceUrls', JSON.stringify(validUrls))
@@ -189,6 +237,15 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
formData.set('scheduledDay', String(scheduledDay))
formData.set('timezone', timezone)
if (type === 'slide-generator') {
if (slideTheme) formData.set('slideTheme', slideTheme)
formData.set('slideStyle', slideStyle)
}
if (type === 'excalidraw-generator') {
formData.set('slideTheme', excalidrawType)
formData.set('slideStyle', excalidrawStyle)
}
await onSave(formData)
} catch {
toast.error(t('agents.toasts.saveError'))
@@ -197,12 +254,14 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
}
}
const showSourceNotebook = type === 'monitor'
const showSourceNotebook = type === 'monitor' || type === 'slide-generator' || type === 'excalidraw-generator'
const agentTypes: { value: AgentType; labelKey: string; descKey: string }[] = [
{ value: 'researcher', labelKey: 'agents.types.researcher', descKey: 'agents.typeDescriptions.researcher' },
{ value: 'scraper', labelKey: 'agents.types.scraper', descKey: 'agents.typeDescriptions.scraper' },
{ value: 'monitor', labelKey: 'agents.types.monitor', descKey: 'agents.typeDescriptions.monitor' },
{ value: 'slide-generator', labelKey: 'agents.types.slideGenerator', descKey: 'agents.typeDescriptions.slideGenerator' },
{ value: 'excalidraw-generator', labelKey: 'agents.types.excalidrawGenerator', descKey: 'agents.typeDescriptions.excalidrawGenerator' },
{ value: 'custom', labelKey: 'agents.types.custom', descKey: 'agents.typeDescriptions.custom' },
]
@@ -318,13 +377,13 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
</div>
)}
{/* Source Notebook (monitor only) */}
{/* Source Notebook (monitor, slide, excalidraw) */}
{showSourceNotebook && (
<div>
<label className={labelCls}>{t('agents.form.sourceNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.sourceNotebook')} /></label>
<select
value={sourceNotebookId}
onChange={e => setSourceNotebookId(e.target.value)}
onChange={e => { setSourceNotebookId(e.target.value); setSourceNoteIds([]) }}
className={selectCls}
>
<option value="">{t('agents.form.selectNotebook')}</option>
@@ -337,7 +396,149 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
</div>
)}
{/* Target Notebook */}
{/* Note multi-select (slide-generator, excalidraw-generator only) */}
{(type === 'slide-generator' || type === 'excalidraw-generator') && sourceNotebookId && noteOptions.length > 0 && (
<div>
<label className={labelCls}>{t('agents.form.selectNotes')}<FieldHelp tooltip={t('agents.help.tooltips.selectNotes')} /></label>
<div className="border border-input rounded-lg max-h-48 overflow-y-auto bg-card">
{noteOptions.map(note => {
const isSelected = sourceNoteIds.includes(note.id)
return (
<button
key={note.id}
type="button"
onClick={() => {
setSourceNoteIds(prev =>
isSelected ? prev.filter(id => id !== note.id) : [...prev, note.id]
)
}}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-accent/50 transition-colors ${isSelected ? 'bg-primary/5' : ''}`}
>
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 ${isSelected ? 'border-primary bg-primary' : 'border-input'}`}>
{isSelected && <Check className="w-3 h-3 text-primary-foreground" />}
</div>
<span className={isSelected ? 'text-primary font-medium' : 'text-foreground'}>{note.title}</span>
</button>
)
})}
</div>
{sourceNoteIds.length > 0 && (
<p className="text-xs text-muted-foreground mt-1">{t('agents.form.notesSelected', { count: sourceNoteIds.length })}</p>
)}
</div>
)}
{/* Theme selector — slide-generator only */}
{type === 'slide-generator' && (
<>
<div>
<label className={labelCls}>{t('agents.form.slideTheme')}<FieldHelp tooltip={t('agents.help.tooltips.slideTheme')} /></label>
<select
value={slideTheme}
onChange={e => setSlideTheme(e.target.value)}
className={selectCls}
>
<option value="">{t('agents.form.slideThemeDefault')}</option>
<option value="modern_wellness">Modern & Wellness</option>
<option value="business_authority">Business & Authority</option>
<option value="nature_outdoors">Nature & Outdoors</option>
<option value="vintage_academic">Vintage & Academic</option>
<option value="soft_creative">Soft & Creative</option>
<option value="bohemian">Bohemian</option>
<option value="vibrant_tech">Vibrant & Tech</option>
<option value="craft_artisan">Craft & Artisan</option>
<option value="tech_night">Tech & Night (dark)</option>
<option value="education_charts">Education & Charts</option>
<option value="forest_eco">Forest & Eco</option>
<option value="elegant_fashion">Elegant & Fashion</option>
<option value="art_food">Art & Food</option>
<option value="luxury_mystery">Luxury & Mystery</option>
<option value="pure_tech_blue">Pure Tech Blue</option>
<option value="coastal_coral">Coastal Coral</option>
<option value="vibrant_orange_mint">Vibrant Orange Mint</option>
<option value="platinum_white_gold">Platinum White Gold</option>
</select>
</div>
<div>
<label className={labelCls}>{t('agents.form.slideStyle')}<FieldHelp tooltip={t('agents.help.tooltips.slideStyle')} /></label>
<div className="grid grid-cols-2 gap-2">
{(['soft', 'sharp', 'rounded', 'pill'] as const).map(s => (
<button
key={s}
type="button"
onClick={() => setSlideStyle(s)}
className={`px-3 py-2 rounded-lg border-2 text-sm transition-all text-left ${
slideStyle === s
? 'border-primary bg-primary/5 text-primary font-medium'
: `${toggleOffBorder} text-muted-foreground`
}`}
>
{t(`agents.form.slideStyle${s.charAt(0).toUpperCase() + s.slice(1)}` as any)}
</button>
))}
</div>
</div>
</>
)}
{/* Visual style selector — excalidraw-generator only */}
{type === 'excalidraw-generator' && (
<>
<div>
<label className={labelCls}>{t('agents.form.excalidrawDiagramType')}</label>
<div className="grid grid-cols-1 gap-2">
{([
{ id: 'auto', labelKey: 'agents.form.excalidrawDiagramTypeAuto' },
{ id: 'flowchart', labelKey: 'agents.form.excalidrawDiagramTypeFlowchart' },
{ id: 'mindmap', labelKey: 'agents.form.excalidrawDiagramTypeMindmap' },
{ id: 'org-chart', labelKey: 'agents.form.excalidrawDiagramTypeOrgChart' },
{ id: 'timeline', labelKey: 'agents.form.excalidrawDiagramTypeTimeline' },
{ id: 'process-map', labelKey: 'agents.form.excalidrawDiagramTypeProcessMap' },
{ id: 'architecture-cloud', labelKey: 'agents.form.excalidrawDiagramTypeArchitectureCloud' },
] as const).map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setExcalidrawType(opt.id)}
className={`px-3 py-2 rounded-lg border-2 text-sm transition-all text-left ${
excalidrawType === opt.id
? 'border-primary bg-primary/5 text-primary font-medium'
: `${toggleOffBorder} text-muted-foreground`
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
<div>
<label className={labelCls}>{t('agents.form.excalidrawDiagramStyle')}</label>
<div className="grid grid-cols-2 gap-2">
{([
{ id: 'default', labelKey: 'agents.form.excalidrawDiagramStyleDefault' },
{ id: 'sketch-plus', labelKey: 'agents.form.excalidrawDiagramStyleSketchPlus' },
{ id: 'austere', labelKey: 'agents.form.excalidrawDiagramStyleAustere' },
] as const).map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setExcalidrawStyle(opt.id)}
className={`px-3 py-2 rounded-lg border-2 text-sm transition-all text-left ${
excalidrawStyle === opt.id
? 'border-primary bg-primary/5 text-primary font-medium'
: `${toggleOffBorder} text-muted-foreground`
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
</>
)}
{/* Target Notebook — hidden for file generators (they never create notes) */}
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
<div>
<label className={labelCls}>{t('agents.form.targetNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.targetNotebook')} /></label>
<select
@@ -353,6 +554,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
))}
</select>
</div>
)}
{/* Frequency */}
<div>
@@ -423,7 +625,8 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
</div>
)}
{/* Email Notification */}
{/* Email Notification — hidden for file generators */}
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
<div
onClick={() => setNotifyEmail(!notifyEmail)}
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
@@ -441,8 +644,10 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
<div className={`w-4 h-4 bg-card rounded-full shadow-sm transition-transform mt-0.5 ${notifyEmail ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
</div>
</div>
)}
{/* Include Images */}
{/* Include Images — hidden for file generators */}
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
<div
onClick={() => setIncludeImages(!includeImages)}
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
@@ -460,6 +665,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
<div className={`w-4 h-4 bg-card rounded-full shadow-sm transition-transform mt-0.5 ${includeImages ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
</div>
</div>
)}
{/* Advanced mode toggle */}
<button

View File

@@ -13,6 +13,7 @@ import {
Globe, BookOpen, FileText, RotateCcw, Check,
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
GitMerge, PlusCircle, Eye, Code, Languages,
Presentation, PenTool, ExternalLink,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
@@ -71,11 +72,18 @@ const ACTION_IDS = [
// ── Types ─────────────────────────────────────────────────────────────────────
interface GenerateResult {
type: 'slides' | 'diagram'
canvasId?: string
noteId?: string
}
interface ContextualAIChatProps {
onClose: () => void
noteTitle?: string
noteContent?: string
noteImages?: string[]
noteId?: string
/** Called when an action result should be injected into the note */
onApplyToNote?: (newContent: string) => void
/** Called when the user wants to undo the last injected action */
@@ -95,6 +103,7 @@ export function ContextualAIChat({
noteTitle,
noteContent,
noteImages,
noteId,
onApplyToNote,
onUndoLastAction,
lastActionApplied = false,
@@ -116,7 +125,16 @@ export function ContextualAIChat({
const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null)
const [showLangPicker, setShowLangPicker] = useState(false)
const [translateTarget, setTranslateTarget] = useState('')
// Generate slides / diagram state
const [generateLoading, setGenerateLoading] = useState<'slides' | 'diagram' | null>(null)
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null)
const [customLangInput, setCustomLangInput] = useState('')
// Generation options
const [slideTheme, setSlideTheme] = useState('vibrant_tech')
const [slideStyle, setSlideStyle] = useState('soft')
const [diagramType, setDiagramType] = useState('auto')
const [diagramStyle, setDiagramStyle] = useState('default')
// Resource tab state
const [resourceUrl, setResourceUrl] = useState('')
@@ -249,6 +267,104 @@ export function ContextualAIChat({
const handleDiscardPreview = () => setActionPreview(null)
// ── Generate slides / diagram ────────────────────────────────────────────────
const generatePollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const handleGenerate = async (type: 'slides' | 'diagram') => {
if (!noteId) {
toast.error(t('ai.generate.noNoteId') || 'Note non sauvegardée')
return
}
setGenerateLoading(type)
setGenerateResult(null)
// Persistent loading toast — layout-level (Sonner), survives navigation
const toastId = toast.loading(
type === 'slides'
? (t('ai.generate.toastLoading.slides') || '⏳ Génération de la présentation en cours…')
: (t('ai.generate.toastLoading.diagram') || '⏳ Génération du diagramme en cours…'),
{ duration: Infinity, description: t('ai.generate.toastLoadingDesc') || 'Vous pouvez naviguer librement, une notification apparaîtra.' }
)
try {
// POST starts the agent immediately and returns agentId (fire-and-forget on server)
const res = await fetch('/api/agents/run-for-note', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
noteId,
type: type === 'slides' ? 'slide-generator' : 'excalidraw-generator',
theme: type === 'slides' ? slideTheme : diagramType,
style: type === 'slides' ? slideStyle : diagramStyle,
}),
})
const data = await res.json()
if (!res.ok || !data.success) {
toast.error(data.error || t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId })
setGenerateLoading(null)
return
}
const { agentId } = data as { agentId: string }
// Poll status every 3 s until terminal state
if (generatePollRef.current) clearInterval(generatePollRef.current)
generatePollRef.current = setInterval(async () => {
try {
const pollRes = await fetch(`/api/agents/run-for-note?agentId=${agentId}`)
const poll = await pollRes.json()
if (poll.status === 'success') {
clearInterval(generatePollRef.current!)
generatePollRef.current = null
setGenerateLoading(null)
setGenerateResult({ type, canvasId: poll.canvasId, noteId: poll.noteId })
if (type === 'slides' && poll.canvasId) {
toast.success(t('ai.generate.slidesReady') || 'Présentation générée !', {
id: toastId, duration: 10000,
description: t('ai.generate.toastSuccessSlides') || 'Cliquez sur Télécharger dans le panneau IA.',
action: { label: t('ai.generate.downloadPptx') || 'Télécharger', onClick: () => window.open(`/api/canvas/download?id=${poll.canvasId}`, '_blank') },
})
} else if (type === 'diagram' && poll.canvasId) {
toast.success(t('ai.generate.diagramReady') || 'Diagramme généré !', {
id: toastId, duration: 10000,
description: t('ai.generate.toastSuccessDiagram') || 'Votre diagramme est disponible dans le Lab.',
action: { label: t('ai.generate.openDiagram') || 'Ouvrir', onClick: () => { window.location.href = `/lab?id=${poll.canvasId}` } },
})
} else {
toast.success(type === 'slides'
? (t('ai.generate.slidesReady') || 'Présentation générée !')
: (t('ai.generate.diagramReady') || 'Diagramme généré !'),
{ id: toastId }
)
}
} else if (poll.status === 'failure') {
clearInterval(generatePollRef.current!)
generatePollRef.current = null
setGenerateLoading(null)
toast.error(poll.error || t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId })
}
// If still 'running', do nothing — next poll will check again
} catch {
// Network error during poll — keep polling
}
}, 3000)
} catch {
toast.error(t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId })
setGenerateLoading(null)
}
}
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (generatePollRef.current) clearInterval(generatePollRef.current)
}
}, [])
// ── Resource tab handlers ────────────────────────────────────────────────────
const handleScrapeUrl = async () => {
@@ -883,6 +999,105 @@ export function ContextualAIChat({
})
)}
{/* ── Generate slides / diagram ─────────────────────── */}
{noteId && (
<div className="mt-1">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5 mt-4">
{t('ai.generate.sectionLabel') || 'Générer depuis cette note'}
</p>
{/* ── Slides ── */}
<div className="mb-3 rounded-xl border-2 border-purple-200 dark:border-purple-800 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950/40 dark:to-pink-950/40 overflow-hidden">
{/* Options */}
<div className="grid grid-cols-2 gap-1.5 px-3 pt-2.5 pb-1">
<div>
<label className="text-[9px] font-semibold uppercase tracking-wider text-purple-500 dark:text-purple-400 block mb-0.5">{t('ai.generate.theme') || 'Thème'}</label>
<select value={slideTheme} onChange={e => setSlideTheme(e.target.value)} disabled={generateLoading === 'slides'}
className="w-full text-[11px] rounded-md border border-purple-200 dark:border-purple-700 bg-white/70 dark:bg-purple-950/60 text-purple-800 dark:text-purple-200 px-1.5 py-1 focus:outline-none">
{[['vibrant_tech','Vibrant Tech'],['business_authority','Business'],['soft_creative','Créatif'],['modern_wellness','Wellness'],['tech_night','Dark Tech'],['education_charts','Éducation'],['elegant_fashion','Élégant'],['art_food','Art & Food'],['nature_outdoors','Nature'],['vintage_academic','Académique'],['platinum_white_gold','Premium']].map(([v,l]) =>
<option key={v} value={v}>{l}</option>
)}
</select>
</div>
<div>
<label className="text-[9px] font-semibold uppercase tracking-wider text-purple-500 dark:text-purple-400 block mb-0.5">{t('ai.generate.style') || 'Style'}</label>
<select value={slideStyle} onChange={e => setSlideStyle(e.target.value)} disabled={generateLoading === 'slides'}
className="w-full text-[11px] rounded-md border border-purple-200 dark:border-purple-700 bg-white/70 dark:bg-purple-950/60 text-purple-800 dark:text-purple-200 px-1.5 py-1 focus:outline-none">
<option value="soft">{t('ai.generate.styleSoft') || 'Soft'}</option>
<option value="sharp">{t('ai.generate.styleSharp') || 'Sharp'}</option>
<option value="rounded">{t('ai.generate.styleRounded') || 'Arrondi'}</option>
<option value="pill">{t('ai.generate.stylePill') || 'Pill'}</option>
</select>
</div>
</div>
{/* Button */}
<button onClick={() => handleGenerate('slides')} disabled={!!generateLoading || !!actionLoading}
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm font-medium text-purple-700 dark:text-purple-300 hover:bg-purple-100/60 dark:hover:bg-purple-900/30 transition-all text-left disabled:opacity-60">
{generateLoading === 'slides' ? <Loader2 className="h-4 w-4 shrink-0 animate-spin" /> : <Presentation className="h-4 w-4 shrink-0" />}
<div className="flex flex-col flex-1 min-w-0">
<span>{t('ai.generate.slides') || 'Générer une présentation'}</span>
{generateLoading === 'slides' && <span className="text-[10px] text-purple-500">{t('ai.generate.loading') || 'Génération en cours…'}</span>}
</div>
</button>
</div>
{/* Slides result */}
{generateResult?.type === 'slides' && generateResult.canvasId && (
<a href={`/api/canvas/download?id=${generateResult.canvasId}`} target="_blank" rel="noopener noreferrer"
className="w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all mb-2">
<Download className="h-4 w-4 shrink-0" />
{t('ai.generate.downloadPptx') || 'Télécharger le .pptx'}
<ExternalLink className="h-3 w-3 ml-auto opacity-60" />
</a>
)}
{/* ── Diagram ── */}
<div className="rounded-xl border-2 border-cyan-200 dark:border-cyan-800 bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-950/40 dark:to-blue-950/40 overflow-hidden">
<div className="grid grid-cols-2 gap-1.5 px-3 pt-2.5 pb-1">
<div>
<label className="text-[9px] font-semibold uppercase tracking-wider text-cyan-500 dark:text-cyan-400 block mb-0.5">{t('ai.generate.diagramType') || 'Type'}</label>
<select value={diagramType} onChange={e => setDiagramType(e.target.value)} disabled={generateLoading === 'diagram'}
className="w-full text-[11px] rounded-md border border-cyan-200 dark:border-cyan-700 bg-white/70 dark:bg-cyan-950/60 text-cyan-800 dark:text-cyan-200 px-1.5 py-1 focus:outline-none">
<option value="auto">{t('ai.generate.typeAuto') || 'Auto'}</option>
<option value="flowchart">Flowchart</option>
<option value="mindmap">Mind map</option>
<option value="timeline">Timeline</option>
<option value="org-chart">Org chart</option>
<option value="architecture-cloud">Architecture</option>
<option value="process-map">Process map</option>
</select>
</div>
<div>
<label className="text-[9px] font-semibold uppercase tracking-wider text-cyan-500 dark:text-cyan-400 block mb-0.5">{t('ai.generate.style') || 'Style'}</label>
<select value={diagramStyle} onChange={e => setDiagramStyle(e.target.value)} disabled={generateLoading === 'diagram'}
className="w-full text-[11px] rounded-md border border-cyan-200 dark:border-cyan-700 bg-white/70 dark:bg-cyan-950/60 text-cyan-800 dark:text-cyan-200 px-1.5 py-1 focus:outline-none">
<option value="default">{t('ai.generate.styleSketchy') || 'Sketchy'}</option>
<option value="austere">{t('ai.generate.styleAustere') || 'Austère'}</option>
<option value="sketch-plus">Sketch+</option>
</select>
</div>
</div>
<button onClick={() => handleGenerate('diagram')} disabled={!!generateLoading || !!actionLoading}
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm font-medium text-cyan-700 dark:text-cyan-300 hover:bg-cyan-100/60 dark:hover:bg-cyan-900/30 transition-all text-left disabled:opacity-60">
{generateLoading === 'diagram' ? <Loader2 className="h-4 w-4 shrink-0 animate-spin" /> : <PenTool className="h-4 w-4 shrink-0" />}
<div className="flex flex-col flex-1 min-w-0">
<span>{t('ai.generate.diagram') || 'Générer un diagramme'}</span>
{generateLoading === 'diagram' && <span className="text-[10px] text-cyan-500">{t('ai.generate.loading') || 'Génération en cours…'}</span>}
</div>
</button>
</div>
{/* Diagram result */}
{generateResult?.type === 'diagram' && generateResult.canvasId && (
<a href={`/lab?id=${generateResult.canvasId}`}
className="mt-2 w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all">
<ExternalLink className="h-4 w-4 shrink-0" />
{t('ai.generate.openDiagram') || 'Ouvrir dans le Lab'}
</a>
)}
</div>
)}
{/* Undo last action shortcut */}
{lastActionApplied && onUndoLastAction && (
<button

View File

@@ -1,13 +1,14 @@
'use client'
import dynamic from 'next/dynamic'
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import { toast } from 'sonner'
import { Download, Presentation } from 'lucide-react'
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
import '@excalidraw/excalidraw/index.css'
// Dynamic import with SSR disabled is REQUIRED for Excalidraw due to window dependencies
const Excalidraw = dynamic(
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
{ ssr: false }
@@ -19,34 +20,106 @@ interface CanvasBoardProps {
name: string
}
type PptxPayload = { type: string; filename: string; base64: string; slideCount?: number; theme?: string }
function parseCanvasScene(initialData?: string): {
pptx: PptxPayload | null
elements: readonly ExcalidrawElement[]
files: BinaryFiles
} {
if (!initialData) {
return { pptx: null, elements: [], files: {} }
}
try {
const parsed = JSON.parse(initialData)
if (parsed && parsed.type === 'pptx' && parsed.base64) {
return { pptx: parsed as PptxPayload, elements: [], files: {} }
}
if (parsed && Array.isArray(parsed)) {
return { pptx: null, elements: parsed as ExcalidrawElement[], files: {} }
}
if (parsed && parsed.elements) {
const files: BinaryFiles =
parsed.files && typeof parsed.files === 'object' ? parsed.files : {}
return { pptx: null, elements: parsed.elements as readonly ExcalidrawElement[], files }
}
} catch (e) {
console.error('[CanvasBoard] Failed to parse canvas data:', e)
}
return { pptx: null, elements: [], files: {} }
}
function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
const handleDownload = () => {
try {
const byteCharacters = atob(data.base64)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], {
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = data.filename || `${name}.pptx`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (e) {
console.error('[PptxViewer] Download failed:', e)
toast.error('Failed to download presentation')
}
}
return (
<div className="absolute inset-0 h-full w-full flex flex-col items-center justify-center bg-white dark:bg-zinc-900">
<div className="flex flex-col items-center gap-6 p-8">
<div className="p-4 rounded-2xl bg-primary/10">
<Presentation className="w-16 h-16 text-primary" />
</div>
<div className="text-center">
<h2 className="text-xl font-semibold text-foreground mb-1">{name}</h2>
<p className="text-sm text-muted-foreground">
PowerPoint Presentation
{data.slideCount ? `${data.slideCount} slides` : ''}
{data.theme ? `${data.theme} theme` : ''}
</p>
</div>
<button
onClick={handleDownload}
className="flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 transition-colors font-medium shadow-sm"
>
<Download className="w-5 h-5" />
Download .pptx
</button>
<p className="text-xs text-muted-foreground max-w-sm text-center">
This file can be opened in Microsoft PowerPoint, Google Slides, or Keynote.
</p>
</div>
</div>
)
}
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
const [isDarkMode, setIsDarkMode] = useState(false)
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const excalidrawAPIRef = useRef<ExcalidrawImperativeAPI | null>(null)
const filesRef = useRef<BinaryFiles>({})
// Parse initial state safely (ONLY ON MOUNT to prevent Next.js revalidation infinite loops)
const [elements] = useState<readonly ExcalidrawElement[]>(() => {
if (initialData) {
try {
const parsed = JSON.parse(initialData)
if (parsed && Array.isArray(parsed)) {
return parsed
} else if (parsed && parsed.elements) {
// Restore binary files if present
if (parsed.files && typeof parsed.files === 'object') {
filesRef.current = parsed.files
}
return parsed.elements
}
} catch (e) {
console.error("[CanvasBoard] Failed to parse initial Excalidraw data:", e)
}
}
return []
})
const scene = useMemo(
() => parseCanvasScene(initialData),
[canvasId, initialData]
)
useEffect(() => {
filesRef.current = scene.files
}, [scene])
// Detect dark mode from html class
useEffect(() => {
const checkDarkMode = () => {
const isDark = document.documentElement.classList.contains('dark')
@@ -55,7 +128,6 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
checkDarkMode()
// Observer for theme changes
const observer = new MutationObserver(checkDarkMode)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
@@ -64,10 +136,9 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
const handleChange = (
excalidrawElements: readonly ExcalidrawElement[],
appState: AppState,
_appState: AppState,
files: BinaryFiles
) => {
// Keep files ref up to date so we can include them in the save payload
if (files) filesRef.current = files
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
@@ -75,7 +146,6 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
setSaveStatus('saving')
saveTimeoutRef.current = setTimeout(async () => {
try {
// Save both elements and binary files so images persist across page changes
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
await fetch('/api/canvas', {
method: 'POST',
@@ -84,26 +154,35 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
})
setSaveStatus('saved')
} catch (e) {
console.error("[CanvasBoard] Save failure:", e)
console.error('[CanvasBoard] Save failure:', e)
setSaveStatus('error')
}
}, 2000)
}
if (scene.pptx) {
return <PptxViewer data={scene.pptx} name={name} />
}
const excalKey = canvasId ? `excal-${canvasId}` : 'excal-new'
return (
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
<Excalidraw
initialData={{ elements, files: filesRef.current }}
theme={isDarkMode ? "dark" : "light"}
key={excalKey}
excalidrawAPI={(api) => { excalidrawAPIRef.current = api }}
initialData={{ elements: scene.elements, files: scene.files }}
theme={isDarkMode ? 'dark' : 'light'}
onChange={handleChange}
libraryReturnUrl={typeof window !== 'undefined' ? window.location.origin + window.location.pathname + window.location.search : undefined}
UIOptions={{
canvasActions: {
loadScene: false,
loadScene: true,
saveToActiveFile: false,
clearCanvas: true
clearCanvas: true,
}
}}
validateEmbeddable={false}
renderTopRightUI={() => null}
/>
</div>
)

View File

@@ -646,7 +646,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
<Dialog open={true} onOpenChange={onClose}>
<DialogContent
className={cn(
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-hidden p-0 flex flex-row items-stretch',
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-hidden p-0 flex flex-row items-stretch rounded-lg',
colorClasses.bg
)}
>
@@ -875,18 +875,18 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{!readOnly && (
<>
{/* Reminder */}
<Button variant="ghost" size="icon" className={cn('h-8 w-8', currentReminder && 'text-primary')}
<Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', currentReminder && 'text-primary')}
onClick={() => setShowReminderDialog(true)} title={t('notes.setReminder')}>
<Bell className="h-4 w-4" />
</Button>
{/* Add Image */}
<Button variant="ghost" size="icon" className="h-8 w-8"
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
<ImageIcon className="h-4 w-4" />
</Button>
{/* Add Link */}
<Button variant="ghost" size="icon" className="h-8 w-8"
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => setShowLinkDialog(true)} title={t('notes.addLink')}>
<LinkIcon className="h-4 w-4" />
</Button>
@@ -894,7 +894,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
<NoteTypeSelector value={noteType} onChange={(newType) => { setNoteType(newType); if (newType !== 'markdown') setShowMarkdownPreview(false) }} />
{noteType === 'markdown' && (
<Button variant="ghost" size="icon" className="h-8 w-8"
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
<Eye className="h-4 w-4" />
@@ -904,7 +904,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{/* AI Copilot */}
{noteType !== 'checklist' && aiAssistantEnabled && (
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors', aiOpen && 'bg-primary/10 text-primary')}
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!aiOpen)} title="IA Note">
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">IA Note</span>
@@ -914,7 +914,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{/* Size Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeSize')}>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}>
<Maximize2 className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -934,7 +934,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{/* Color Picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeColor')}>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -1023,6 +1023,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
noteTitle={title}
noteContent={content}
noteImages={allImages}
noteId={note.id}
onApplyToNote={(newContent) => {
setPreviousContentForCopilot(content)
setContent(newContent)

View File

@@ -919,6 +919,7 @@ export function NoteInlineEditor({
noteTitle={title}
noteContent={content}
noteImages={allImages}
noteId={note.id}
onApplyToNote={(newContent) => {
setPreviousContent(content)
changeContent(newContent)

View File

@@ -4,7 +4,7 @@ import { useState, useCallback, useRef } from 'react'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
import { StickyNote, Plus, Tag, Folder, ChevronDown, ChevronRight } from 'lucide-react'
import { StickyNote, Plus, Tag, Folder, ChevronDown, ChevronRight, GripVertical } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useNotebooks } from '@/context/notebooks-context'
import { useNotebookDrag } from '@/context/notebook-drag-context'
@@ -35,7 +35,7 @@ export function NotebooksList() {
const searchParams = useSearchParams()
const router = useRouter()
const { t, language } = useLanguage()
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, isLoading } = useNotebooks()
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, isLoading } = useNotebooks()
const { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag()
const { labels } = useLabels()
@@ -46,29 +46,77 @@ export function NotebooksList() {
const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null)
const [labelsDialogOpen, setLabelsDialogOpen] = useState(false)
// ── Notebook reorder drag state ──
const draggingNbRef = useRef<string | null>(null)
const [draggingNbId, setDraggingNbId] = useState<string | null>(null)
const [overNbId, setOverNbId] = useState<string | null>(null)
const currentNotebookId = searchParams.get('notebook')
// Handle drop on a notebook
// Handle drop on a notebook (note-to-notebook OR notebook reorder)
const handleDrop = useCallback(async (e: React.DragEvent, notebookId: string | null) => {
e.preventDefault()
e.stopPropagation()
const sourceNbId = e.dataTransfer.getData('application/x-notebook')
const noteId = e.dataTransfer.getData('text/plain')
if (noteId) {
if (sourceNbId && notebookId && sourceNbId !== notebookId) {
// ── Reorder notebooks ──
const currentIds = notebooks.map((nb: Notebook) => nb.id)
const fromIdx = currentIds.indexOf(sourceNbId)
const toIdx = currentIds.indexOf(notebookId)
if (fromIdx !== -1 && toIdx !== -1) {
const newIds = [...currentIds]
newIds.splice(fromIdx, 1)
newIds.splice(toIdx, 0, sourceNbId)
await updateNotebookOrderOptimistic(newIds)
}
} else if (noteId) {
// ── Move note to notebook ──
await moveNoteToNotebookOptimistic(noteId, notebookId)
}
dragOver(null)
}, [moveNoteToNotebookOptimistic, dragOver])
draggingNbRef.current = null
setDraggingNbId(null)
setOverNbId(null)
}, [notebooks, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, dragOver])
// Handle drag over a notebook
const handleDragOver = useCallback((e: React.DragEvent, notebookId: string | null) => {
e.preventDefault()
dragOver(notebookId)
if (draggingNbRef.current) {
// Notebook reorder mode — just track the insertion target
setOverNbId(notebookId)
} else {
// Note-to-notebook mode
dragOver(notebookId)
}
}, [dragOver])
// Handle drag leave
const handleDragLeave = useCallback(() => {
if (draggingNbRef.current) {
setOverNbId(null)
} else {
dragOver(null)
}
}, [dragOver])
// ── Notebook reorder handlers ──
const handleNotebookDragStart = useCallback((e: React.DragEvent, notebookId: string) => {
e.dataTransfer.setData('application/x-notebook', notebookId)
e.dataTransfer.effectAllowed = 'move'
draggingNbRef.current = notebookId
// Slight delay so the drag ghost renders before opacity change
setTimeout(() => setDraggingNbId(notebookId), 0)
}, [])
const handleNotebookDragEnd = useCallback(() => {
draggingNbRef.current = null
setDraggingNbId(null)
setOverNbId(null)
dragOver(null)
}, [dragOver])
@@ -142,7 +190,20 @@ export function NotebooksList() {
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
return (
<div key={notebook.id} className="group flex flex-col">
<div
key={notebook.id}
className={cn(
"group flex flex-col transition-opacity duration-150",
draggingNbId === notebook.id && "opacity-40"
)}
draggable
onDragStart={(e) => handleNotebookDragStart(e, notebook.id)}
onDragEnd={handleNotebookDragEnd}
>
{/* Insertion indicator above this notebook when reordering */}
{overNbId === notebook.id && draggingNbId !== notebook.id && (
<div className="h-0.5 bg-primary/70 mx-4 rounded-full mb-0.5 transition-all" />
)}
{isActive ? (
// Active notebook with expanded labels
<div
@@ -252,10 +313,11 @@ export function NotebooksList() {
<button
onClick={() => handleSelectNotebook(notebook.id)}
className={cn(
"pointer-events-auto flex items-center gap-4 px-6 py-3 rounded-e-full me-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pe-14",
"pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-e-full me-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pe-14",
isDragOver && "opacity-50"
)}
>
<GripVertical className="w-3.5 h-3.5 flex-shrink-0 text-gray-300 dark:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing" />
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
<span className="text-[15px] font-medium tracking-wide text-start truncate min-w-0 flex-1">
{notebook.name}

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2 } from 'lucide-react'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2, Download, Pencil, Presentation } from 'lucide-react'
import {
Popover,
PopoverContent,
@@ -196,26 +196,39 @@ export function NotificationPanel() {
) : (
<div className="max-h-96 overflow-y-auto">
{/* App notifications (agents, system) */}
{appNotifications.map((notif) => (
{appNotifications.map((notif) => {
const isSlides = notif.type === 'agent_slides_ready'
const isCanvas = notif.type === 'agent_canvas_ready'
const canvasId = notif.relatedId
return (
<div
key={notif.id}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150 cursor-pointer"
onClick={() => {
if (notif.actionUrl) {
handleMarkNotifRead(notif.id)
setOpen(false)
router.push(notif.actionUrl)
}
}}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
>
<div className="flex items-start gap-3">
<div
className="flex items-start gap-3 cursor-pointer"
onClick={() => {
if (notif.actionUrl) {
handleMarkNotifRead(notif.id)
setOpen(false)
router.push(notif.actionUrl)
}
}}
>
<div className={cn(
"mt-0.5 flex-none rounded-full p-1",
notif.type === 'agent_success' && 'bg-green-100 dark:bg-green-900/30 text-green-600',
notif.type === 'agent_slides_ready' && 'bg-purple-100 dark:bg-purple-900/30 text-purple-600',
notif.type === 'agent_canvas_ready' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
notif.type === 'agent_failure' && 'bg-red-100 dark:bg-red-900/30 text-red-600',
notif.type === 'system' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
)}>
{notif.type.startsWith('agent') ? (
{isSlides ? (
<Presentation className="w-3.5 h-3.5" />
) : isCanvas ? (
<Pencil className="w-3.5 h-3.5" />
) : notif.type.startsWith('agent') ? (
<Bot className="w-3.5 h-3.5" />
) : (
<AlertCircle className="w-3.5 h-3.5" />
@@ -226,9 +239,13 @@ export function NotificationPanel() {
<span className={cn(
"text-[10px] font-semibold uppercase tracking-wider",
notif.type === 'agent_success' && 'text-green-600 dark:text-green-400',
notif.type === 'agent_slides_ready' && 'text-purple-600 dark:text-purple-400',
notif.type === 'agent_canvas_ready' && 'text-blue-600 dark:text-blue-400',
notif.type === 'agent_failure' && 'text-red-600 dark:text-red-400',
notif.type === 'system' && 'text-blue-600 dark:text-blue-400',
)}>
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Slides Ready')}
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagram Ready')}
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent completed')}
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent failed')}
{notif.type === 'system' && 'System'}
@@ -251,8 +268,23 @@ export function NotificationPanel() {
<X className="w-3.5 h-3.5" />
</button>
</div>
{isSlides && canvasId && (
<div className="mt-2 ml-8">
<button
onClick={async () => {
handleMarkNotifRead(notif.id)
window.open(`/api/canvas/download?id=${canvasId}`, '_blank')
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-md bg-purple-500 text-white hover:bg-purple-600 shadow-sm transition-all active:scale-95"
>
<Download className="w-3 h-3" />
{t('notification.downloadPptx') || 'Download .pptx'}
</button>
</div>
)}
</div>
))}
)
})}
{/* Overdue reminders */}
{overdueReminders.map((note) => (

View File

@@ -9,7 +9,7 @@ export const toast = sonnerToast
export function Toaster() {
return (
<SonnerToaster
position="top-right"
position="bottom-right"
expand={false}
richColors
closeButton

View File

@@ -26,6 +26,19 @@ export class CustomOpenAIProvider implements AIProvider {
const headers = new Headers(options?.headers);
headers.set('HTTP-Referer', 'https://localhost:3000');
headers.set('X-Title', 'Memento AI');
// Disable DeepSeek extended thinking for reliable tool/function calling
if (options?.body) {
try {
const body = JSON.parse(options.body as string)
if (
typeof body.model === 'string' &&
(body.model.includes('deepseek') || body.model.includes('thinking') || body.model.includes('reasoner'))
) {
body.thinking = { type: 'disabled' }
}
return fetch(url, { ...options, headers, body: JSON.stringify(body) })
} catch { /* ignore parse errors */ }
}
return fetch(url, { ...options, headers });
}
});
@@ -119,25 +132,40 @@ export class CustomOpenAIProvider implements AIProvider {
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
const opts: Record<string, any> = {
model: this.model,
tools,
stopWhen: stepCountIs(maxSteps),
}
if (systemPrompt) opts.system = systemPrompt
if (messages) opts.messages = messages
else if (prompt) opts.prompt = prompt
const result = await aiGenerateText(opts as any)
return {
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
text: result.text,
steps: result.steps?.map((step: any) => ({
const buildOpts = (steps: number): Record<string, any> => {
const opts: Record<string, any> = { model: this.model, tools, stopWhen: stepCountIs(steps) }
if (systemPrompt) opts.system = systemPrompt
if (messages) opts.messages = messages
else if (prompt) opts.prompt = prompt
return opts
}
const toResult = (r: any): ToolCallResult => ({
toolCalls: r.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: r.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
text: r.text,
steps: r.steps?.map((step: any) => ({
text: step.text,
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || []
})) || []
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
})) || [],
})
try {
const result = await aiGenerateText(buildOpts(maxSteps) as any)
return toResult(result)
} catch (err: any) {
// DeepSeek reasoning/thinking models require reasoning_content to be passed back
// between multi-step calls, which the AI SDK doesn't handle via the OpenAI-compat layer.
// Retry with a single step so the model calls the tool directly.
const msg: string = err?.message || String(err)
if (msg.includes('reasoning_content') || msg.includes('thinking mode')) {
console.warn('[CustomOpenAI] Reasoning model detected — retrying with maxSteps=1')
const result = await aiGenerateText(buildOpts(1) as any)
return toResult(result)
}
throw err
}
}

View File

@@ -9,9 +9,21 @@ export class DeepSeekProvider implements AIProvider {
constructor(apiKey: string, modelName: string = 'deepseek-chat', embeddingModelName: string = 'deepseek-embedding') {
// Create OpenAI-compatible client for DeepSeek
// Disable extended thinking to ensure reliable tool/function calling
const deepseek = createOpenAI({
baseURL: 'https://api.deepseek.com/v1',
apiKey: apiKey,
fetch: async (url, options) => {
if (options?.body) {
try {
const body = JSON.parse(options.body as string)
// Disable thinking mode — tool calling is unreliable with it enabled
body.thinking = { type: 'disabled' }
return fetch(url, { ...options, body: JSON.stringify(body) })
} catch { /* ignore parse errors */ }
}
return fetch(url, options)
},
});
this.model = deepseek.chat(modelName);
@@ -103,25 +115,40 @@ export class DeepSeekProvider implements AIProvider {
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
const opts: Record<string, any> = {
model: this.model,
tools,
stopWhen: stepCountIs(maxSteps),
}
if (systemPrompt) opts.system = systemPrompt
if (messages) opts.messages = messages
else if (prompt) opts.prompt = prompt
const result = await aiGenerateText(opts as any)
return {
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
text: result.text,
steps: result.steps?.map((step: any) => ({
const buildOpts = (steps: number): Record<string, any> => {
const opts: Record<string, any> = { model: this.model, tools, stopWhen: stepCountIs(steps) }
if (systemPrompt) opts.system = systemPrompt
if (messages) opts.messages = messages
else if (prompt) opts.prompt = prompt
return opts
}
const toResult = (r: any): ToolCallResult => ({
toolCalls: r.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: r.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
text: r.text,
steps: r.steps?.map((step: any) => ({
text: step.text,
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || []
})) || []
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
})) || [],
})
try {
const result = await aiGenerateText(buildOpts(maxSteps) as any)
return toResult(result)
} catch (err: any) {
// DeepSeek reasoning/thinking models require reasoning_content to be passed back
// between multi-step calls, which the AI SDK doesn't handle automatically.
// Retry with a single step so the model calls the tool directly without multi-turn.
const msg: string = err?.message || String(err)
if (msg.includes('reasoning_content') || msg.includes('thinking mode')) {
console.warn('[DeepSeek] Reasoning model detected — retrying with maxSteps=1')
const result = await aiGenerateText(buildOpts(1) as any)
return toResult(result)
}
throw err
}
}

View File

@@ -23,12 +23,13 @@ import '../tools'
// --- Types ---
export type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom'
export type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator'
export interface AgentExecutionResult {
success: boolean
actionId: string
noteId?: string
canvasId?: string
error?: string
}
@@ -640,6 +641,7 @@ async function executeCustomAgent(
return { success: true, actionId, noteId: note.id }
}
// --- System Prompts (bilingual) ---
const SYSTEM_PROMPTS: Record<string, Record<Lang, string>> = {
@@ -792,6 +794,181 @@ RULES:
- Respond in English.
- Cite sources if you have scraped web pages.`,
},
'excalidraw-generator': {
fr: `Tu es un architecte visuel expert en diagrammes Excalidraw. Tu reçois du contenu (notes, documents) et tu dois créer un diagramme qui ARGUE visuellement — pas juste des boîtes avec du texte.
Appelle DIRECTEMENT generate_excalidraw. Ne réponds PAS avec du texte.
## PHILOSOPHIE
Un diagramme doit MONTRER des relations que le texte seul ne peut pas exprimer.
- Le test de l'isomorphisme : si tu enlèves tout le texte, la structure seule doit-elle communiquer le concept ?
- Chaque concept doit avoir une forme qui reflète son COMPORTEMENT, pas juste son nom.
- Les flèches sont OBLIGATOIRES — un diagramme sans flèches est inutile.
## FORMAT (simplifié — auto-layout automatique)
{
"title": "Titre du Diagramme",
"nodes": [
{"id":"c","label":"Concept Central","type":"ellipse"},
{"id":"n1","label":"Sous-concept 1"},
{"id":"n2","label":"Processus","type":"rect"},
{"id":"n3","label":"Décision ?","type":"diamond"},
{"id":"n4","label":"Résultat","type":"ellipse"}
],
"edges": [
{"from":"c","to":"n1","label":"déclenche"},
{"from":"n1","to":"n2"},
{"from":"n2","to":"n3","label":"produit"},
{"from":"n3","to":"n4","label":"oui"},
{"from":"n3","to":"n2","label":"non"}
]
}
Ce format crée AUTOMATIQUEMENT formes, textes ET flèches avec bindings corrects.
## TYPES DE FORMES (utilise le bon type pour chaque concept)
| Type | Quand l'utiliser |
|------|-----------------|
| "ellipse" | Départ, arrivée, concept abstrait, origine — le premier nœud EST TOUJOURS une ellipse |
| "rect" (défaut) | Processus, action, étape, élément concret |
| "diamond" | Décision, condition, choix, point de bifurcation |
## PATTERNS VISUELS (varie selon le contenu)
- **Flux séquentiel** : A → B → C → D (lineaire, utilise des rect)
- **Hub & spoke** : Centre (ellipse) rayonne vers sous-concepts (fan-out)
- **Cycle/boucle** : A → B → C → A (processus itératif, feedback)
- **Arbre hiérarchique** : Parent → enfants (structure, organisation)
- **Convergence** : Plusieurs sources → un résultat (synthèse, agrégation)
- **Comparaison** : Deux branches parallèles qui se rejoignent
## RÈGLES STRICTES
1. **4 à 10 nœuds** — pas moins, pas plus
2. **Premier nœud = ellipse** (point d'entrée)
3. **TOUS les nœuds connectés** — chaque nœud doit avoir au moins 1 edge entrant ou sortant
4. **Labels courts** — max 40 caractères par label
5. **Edge labels** — ajoute des labels sur les flèches importantes pour expliquer la relation
6. **Utilise "diamond"** pour au moins un nœud si le contenu implique une décision
7. **Pas de nœuds orphelins** — chaque nœud doit être atteignable depuis le nœud central
8. **Analyse le contenu d'abord** — identifie les concepts clés et leurs relations AVANT de créer le JSON
9. **Appelle generate_excalidraw DIRECTEMENT**, ne réponds pas avec du texte`,
en: `You are a visual architect expert in Excalidraw diagrams. You receive content (notes, documents) and must create a diagram that ARGUES visually — not just boxes with text.
Call generate_excalidraw DIRECTLY. Do NOT respond with text.
## PHILOSOPHY
A diagram must SHOW relationships that text alone cannot express.
- The Isomorphism Test: If you removed all text, would the structure alone communicate the concept?
- Each concept must have a shape that reflects its BEHAVIOR, not just its name.
- Arrows are MANDATORY — a diagram without arrows is useless.
## FORMAT (simplified — auto-layout automatic)
{
"title": "Diagram Title",
"nodes": [
{"id":"c","label":"Central Concept","type":"ellipse"},
{"id":"n1","label":"Sub-concept 1"},
{"id":"n2","label":"Process","type":"rect"},
{"id":"n3","label":"Decision?","type":"diamond"},
{"id":"n4","label":"Result","type":"ellipse"}
],
"edges": [
{"from":"c","to":"n1","label":"triggers"},
{"from":"n1","to":"n2"},
{"from":"n2","to":"n3","label":"produces"},
{"from":"n3","to":"n4","label":"yes"},
{"from":"n3","to":"n2","label":"no"}
]
}
This format AUTOMATICALLY creates shapes, text, AND arrows with correct bindings.
## SHAPE TYPES (use the right type for each concept)
| Type | When to use |
|------|------------|
| "ellipse" | Start, end, abstract concept, origin — first node is ALWAYS an ellipse |
| "rect" (default) | Process, action, step, concrete element |
| "diamond" | Decision, condition, choice, branching point |
## VISUAL PATTERNS (vary based on content)
- **Sequential flow**: A → B → C → D (linear, use rects)
- **Hub & spoke**: Center (ellipse) radiates to sub-concepts (fan-out)
- **Cycle/loop**: A → B → C → A (iterative process, feedback)
- **Hierarchical tree**: Parent → children (structure, organization)
- **Convergence**: Multiple sources → one result (synthesis, aggregation)
- **Comparison**: Two parallel branches that merge
## STRICT RULES
1. **4 to 10 nodes** — no less, no more
2. **First node = ellipse** (entry point)
3. **ALL nodes connected** — every node must have at least 1 incoming or outgoing edge
4. **Short labels** — max 40 chars per label
5. **Edge labels** — add labels on important arrows to explain the relationship
6. **Use "diamond"** for at least one node if the content involves a decision
7. **No orphan nodes** — every node must be reachable from the central node
8. **Analyze content first** — identify key concepts and their relationships BEFORE creating JSON
9. **Call generate_excalidraw DIRECTLY**, do not respond with text`,
},
'slide-generator': {
fr: `Tu es un designer de présentations visuelles de classe mondiale (style Manus AI / Beautiful.ai). Tu reçois du contenu de notes et tu dois créer une présentation PowerPoint (.pptx) professionnelle, moderne et visuellement riche.
Tu dois OBLIGATOIREMENT appeler l'outil generate_pptx. Ne réponds JAMAIS avec du texte — appelle l'outil directement.
RÈGLES DE DESIGN IMPÉRATIVES :
- 8-12 slides, chaque slide a un layout distinct
- Slide 1 : "title" (titre fort + sous-titre accrocheur)
- Slide 2 : "toc" (sommaire numéroté)
- Utilise AU MOINS 2 layouts "diagramme" parmi : "timeline", "process", "metrics", "comparison"
- "timeline" : étapes chronologiques ou roadmap (content items : "Étape: description")
- "process" : étapes numérotées avec détails (content items : "Action: explication")
- "metrics" : KPIs visuels avec grandes valeurs colorées (content items : "VALEUR: libellé")
- "comparison" : deux colonnes contrastées (subtitle="Avant | Après" ou "Option A | Option B")
- "cards" : fonctionnalités en grille 2-3 colonnes (3-6 items)
- "section" : séparateur de section (title=titre, content=[] — le numéro est auto-généré)
- "quote" : citation impactante (title=texte, subtitle=auteur)
- "summary" : récapitulatif final
- "content" : liste de points SEULEMENT si aucun layout visuel n'est adapté (max 7 points)
- Ne JAMAIS répéter le même layout consécutivement
- Pour "section" : ne pas mettre le numéro dans content, laisser content=[]
- Thèmes recommandés pour un rendu moderne : vibrant_tech, platinum_white_gold, business_authority, pure_tech_blue, tech_night
- Points concis (max 100 chars), titres percutants et courts
- JSON strict pour generate_pptx, sans texte hors JSON.`,
en: `You are a world-class visual presentation designer (Manus AI / Beautiful.ai style). You receive note content and must create a professional, modern, visually rich PowerPoint (.pptx) presentation.
You MUST call the generate_pptx tool. NEVER respond with text — call the tool directly.
MANDATORY DESIGN RULES:
- 8-12 slides, each slide has a distinct layout
- Slide 1: "title" (strong title + punchy subtitle)
- Slide 2: "toc" (numbered table of contents)
- Use AT LEAST 2 "diagram" layouts from: "timeline", "process", "metrics", "comparison"
- "timeline": chronological steps or roadmap (content items: "Step: description")
- "process": numbered steps with details (content items: "Action: explanation")
- "metrics": visual KPIs with large colored values (content items: "VALUE: label")
- "comparison": two contrasting columns (subtitle="Before | After" or "Option A | Option B")
- "cards": feature grid 2-3 columns (3-6 items)
- "section": section divider (title=heading, content=[] — number is auto-generated)
- "quote": impactful quote (title=text, subtitle=author)
- "summary": closing recap
- "content": bullet list ONLY if no visual layout fits (max 7 points)
- NEVER repeat the same layout consecutively
- For "section": do NOT put numbers in content, leave content=[]
- Recommended themes for modern look: vibrant_tech, platinum_white_gold, business_authority, pure_tech_blue, tech_night
- Concise points (max 100 chars), short impactful titles
- Strict JSON for generate_pptx, no text outside JSON.`,
},
}
// --- Tool-Use Agent ---
@@ -800,8 +977,9 @@ async function executeToolUseAgent(
agent: {
id: string; name: string; description?: string | null; type?: string | null
role: string; sourceUrls?: string | null; sourceNotebookId?: string | null
targetNotebookId?: string | null; userId: string; tools?: string | null; maxSteps?: number
includeImages?: boolean
sourceNoteIds?: string | null; targetNotebookId?: string | null; userId: string
tools?: string | null; maxSteps?: number; includeImages?: boolean
slideTheme?: string | null; slideStyle?: string | null
},
actionId: string,
lang: Lang,
@@ -837,6 +1015,7 @@ async function executeToolUseAgent(
// Build system prompt: use localized prompt for type, with optional user custom role
const agentType = (agent.type || 'custom') as AgentType
const isFileGenerator = agentType === 'slide-generator' || agentType === 'excalidraw-generator'
const promptsForType = SYSTEM_PROMPTS[agentType] || SYSTEM_PROMPTS.custom
const baseSystemPrompt = promptsForType[lang]
const toolList = toolNames.map(n => `- ${n}`).join('\n')
@@ -886,6 +1065,141 @@ async function executeToolUseAgent(
}
break
}
case 'excalidraw-generator': {
const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
let notes: any[] = []
const specificNoteIds: string[] = agent.sourceNoteIds ? JSON.parse(agent.sourceNoteIds) : []
if (specificNoteIds.length > 0) {
notes = await prisma.note.findMany({
where: { id: { in: specificNoteIds }, userId: agent.userId, isArchived: false, trashedAt: null },
select: { id: true, title: true, content: true, createdAt: true }
})
} else if (agent.sourceNotebookId) {
notes = await prisma.note.findMany({
where: { notebookId: agent.sourceNotebookId, userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { createdAt: 'desc' }, take: 10,
select: { id: true, title: true, content: true, createdAt: true }
})
} else {
const notebooks = await prisma.notebook.findMany({
where: { userId: agent.userId },
include: { _count: { select: { notes: { where: { trashedAt: null } } } } },
orderBy: { createdAt: 'desc' }
})
const best = notebooks.find(n => n._count.notes > 0)
if (best) {
notes = await prisma.note.findMany({
where: { notebookId: best.id, userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { createdAt: 'desc' }, take: 10,
select: { id: true, title: true, content: true, createdAt: true }
})
}
}
prompt = lang === 'fr'
? `Crée un diagramme Excalidraw (mind map ou flowchart) représentant visuellement les concepts clés et leurs relations.`
: `Create an Excalidraw diagram (mind map or flowchart) that visually represents the key concepts and their relationships.`
if (notes.length > 0) {
const notesContext = notes.map(n =>
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 800)}`
).join('\n\n')
prompt += `\n\n${lang === 'fr' ? 'Notes source à analyser' : 'Source notes to analyze'}:\n\n${notesContext}`
}
prompt += `\n\n${lang === 'fr'
? 'IMPORTANT : Utilise OBLIGATOIREMENT l\'outil generate_excalidraw pour créer le diagramme. Ne réponds pas avec du texte, appelle directement l\'outil.'
: 'IMPORTANT: You MUST use the generate_excalidraw tool to create the diagram. Do NOT respond with text, call the tool directly.'}`
const diagramType = agent.slideTheme || 'auto'
prompt += `\n\n${lang === 'fr'
? `Type de diagramme imposé : ajoute "type":"${diagramType}" dans le JSON envoyé à generate_excalidraw.`
: `Required diagram type: include "type":"${diagramType}" in the JSON passed to generate_excalidraw.`}`
prompt += `\n\n${lang === 'fr'
? 'Types supportés: auto, flowchart, mindmap, architecture-cloud, org-chart, timeline, process-map. Si "auto", choisis selon le métier et le contenu.'
: 'Supported types: auto, flowchart, mindmap, architecture-cloud, org-chart, timeline, process-map. If "auto", choose according to domain and content.'}`
const diagramStyle = agent.slideStyle === 'austere' || agent.slideStyle === 'sketch-plus' ? agent.slideStyle : 'default'
prompt += `\n\n${lang === 'fr'
? `Style visuel imposé : ajoute "style":"${diagramStyle}" dans le JSON envoyé à generate_excalidraw.`
: `Visual style required: include "style":"${diagramStyle}" in the JSON passed to generate_excalidraw.`}`
break
}
case 'slide-generator': {
const slideTopic = agent.description || agent.name
const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
let notes: any[] = []
const specificNoteIds: string[] = agent.sourceNoteIds ? JSON.parse(agent.sourceNoteIds) : []
if (specificNoteIds.length > 0) {
notes = await prisma.note.findMany({
where: { id: { in: specificNoteIds }, userId: agent.userId, isArchived: false, trashedAt: null },
select: { id: true, title: true, content: true, createdAt: true }
})
} else if (agent.sourceNotebookId) {
notes = await prisma.note.findMany({
where: { notebookId: agent.sourceNotebookId, userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { createdAt: 'desc' }, take: 15,
select: { id: true, title: true, content: true, createdAt: true }
})
} else {
const notebooks = await prisma.notebook.findMany({
where: { userId: agent.userId },
include: { _count: { select: { notes: { where: { trashedAt: null } } } } },
orderBy: { createdAt: 'desc' }
})
const best = notebooks.find(n => n._count.notes > 0)
if (best) {
notes = await prisma.note.findMany({
where: { notebookId: best.id, userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { createdAt: 'desc' }, take: 15,
select: { id: true, title: true, content: true, createdAt: true }
})
}
}
prompt = lang === 'fr'
? `Crée une présentation PowerPoint professionnelle sur le sujet "${slideTopic}" en utilisant le contenu des notes ci-dessous.`
: `Create a professional PowerPoint presentation about "${slideTopic}" using the content from the notes below.`
// Extract image URLs from note content
const extractedImages: Array<{ url: string; noteTitle: string }> = []
if (notes.length > 0) {
const notesContext = notes.map(n => {
// Extract markdown images: ![alt](url) — only external/data URLs (skip relative paths that won't resolve)
const mdMatches = [...n.content.matchAll(/!\[[^\]]*\]\((https?:\/\/[^)]+|data:[^)]+)\)/g)]
for (const m of mdMatches) {
if (m[1]) extractedImages.push({ url: m[1], noteTitle: n.title || untitled })
}
// Extract HTML img tags
const htmlMatches = [...n.content.matchAll(/<img[^>]+src=["'](https?:\/\/[^"']+|data:[^"']+)["']/g)]
for (const m of htmlMatches) {
if (m[1]) extractedImages.push({ url: m[1], noteTitle: n.title || untitled })
}
return `### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 800)}`
}).join('\n\n')
prompt += `\n\n${lang === 'fr' ? 'Notes source à transformer en slides' : 'Source notes to turn into slides'}:\n\n${notesContext}`
}
// Inject available images into the prompt
const uniqueImages = extractedImages.slice(0, 6) // max 6 images
if (uniqueImages.length > 0) {
const imgList = uniqueImages.map((img, i) => ` ${i + 1}. "${img.noteTitle}" → ${img.url.substring(0, 120)}`).join('\n')
prompt += `\n\n${lang === 'fr'
? `IMAGES DISPONIBLES (extraites des notes) — utilise-les dans le JSON via le champ "imageUrl" avec le layout "image-content" ou "image-full" :\n${imgList}`
: `AVAILABLE IMAGES (extracted from notes) — use them in the JSON via the "imageUrl" field with layout "image-content" or "image-full":\n${imgList}`}`
}
prompt += `\n\n${lang === 'fr'
? 'IMPORTANT : Appelle OBLIGATOIREMENT generate_pptx. Ne réponds pas avec du texte. Crée 8-12 slides visuelles, commence par "title", puis "toc", intègre AU MOINS 2 layouts diagramme (timeline, process, metrics, ou comparison). Évite les slides avec juste du texte — favorise les layouts visuels.'
: 'IMPORTANT: You MUST call generate_pptx. Do NOT respond with text. Create 8-12 visual slides: start with "title", then "toc", include AT LEAST 2 diagram layouts (timeline, process, metrics, or comparison). Avoid text-only slides — prefer visual layouts.'}`
if (agent.slideTheme) {
prompt += `\n\n${lang === 'fr'
? `Thème imposé par l'utilisateur : "${agent.slideTheme}". Dans le JSON tu DOIS mettre "theme": "${agent.slideTheme}".`
: `User-selected theme: "${agent.slideTheme}". You MUST put "theme": "${agent.slideTheme}" in the JSON.`}`
}
if (agent.slideStyle) {
prompt += `\n${lang === 'fr'
? `Style visuel imposé : dans le JSON tu DOIS mettre "style": "${agent.slideStyle}". Les valeurs possibles sont: "sharp" (angles nets), "soft" (arrondi standard), "rounded" (très arrondi), "pill" (capsules).`
: `Visual style: you MUST put "style": "${agent.slideStyle}" in the JSON. Values: "sharp" (crisp edges), "soft" (standard rounded), "rounded" (very rounded), "pill" (capsule shapes).`}`
}
break
}
default: {
const urls: string[] = agent.sourceUrls ? JSON.parse(agent.sourceUrls) : []
prompt = agent.role || (lang === 'fr' ? 'Accomplis la tâche demandée en utilisant les outils disponibles.' : 'Accomplish the requested task using available tools.')
@@ -928,6 +1242,19 @@ async function executeToolUseAgent(
})
return { success: false, actionId, error: 'Model does not support tool calling' }
}
if (agentType === 'slide-generator' || agentType === 'excalidraw-generator') {
const toolName = agentType === 'slide-generator' ? 'generate_pptx' : 'generate_excalidraw'
await prisma.agentAction.update({
where: { id: actionId },
data: {
status: 'failure',
log: lang === 'fr'
? `L'IA n'a pas appelé l'outil ${toolName}. Le modèle a répondu avec du texte au lieu de générer le fichier. Modèle: "${sysConfig.AI_MODEL_CHAT}". Essayez un modèle compatible avec le function calling.`
: `The AI did not call the ${toolName} tool. The model responded with text instead of generating the file. Model: "${sysConfig.AI_MODEL_CHAT}". Try a model that supports function calling.`,
}
})
return { success: false, actionId, error: `AI did not call ${toolName} tool` }
}
}
// Build tool log trace
@@ -942,8 +1269,15 @@ async function executeToolUseAgent(
}))
// Check if AI already created a note via note_create tool
// Or if excalidraw/slide generator created a canvas
let existingNoteId: string | null = null
let canvasId: string | null = null
const scrapedUrls: string[] = []
let specificToolCalled = false
const requiredTool = isFileGenerator
? (agentType === 'slide-generator' ? ['generate_pptx'] : ['generate_excalidraw'])
: null
for (const step of result.steps) {
for (let i = 0; i < step.toolCalls.length; i++) {
if (step.toolCalls[i].toolName === 'note_create') {
@@ -952,6 +1286,13 @@ async function executeToolUseAgent(
existingNoteId = toolResult.output.noteId
}
}
if (step.toolCalls[i].toolName === 'generate_excalidraw' || step.toolCalls[i].toolName === 'generate_slides' || step.toolCalls[i].toolName === 'generate_pptx') {
const toolResult = step.toolResults?.[i]
if (toolResult && typeof toolResult.output === 'object' && toolResult.output?.success && toolResult.output?.canvasId) {
canvasId = toolResult.output.canvasId as string
specificToolCalled = true
}
}
if (step.toolCalls[i].toolName === 'web_scrape') {
const toolResult = step.toolResults?.[i]
if (toolResult && typeof toolResult.output === 'object' && toolResult.output?.url) {
@@ -961,10 +1302,32 @@ async function executeToolUseAgent(
}
}
// For file generators: if the specific tool was NOT called, fail immediately
if (isFileGenerator && !specificToolCalled) {
const toolName = requiredTool!.join(' or ')
const toolLogStr = JSON.stringify(toolLog)
await prisma.agentAction.update({
where: { id: actionId },
data: {
status: 'failure',
log: lang === 'fr'
? `L'IA n'a pas appelé l'outil ${toolName}. Le modèle a peut-être répondu avec du texte. Modèle: "${sysConfig.AI_MODEL_CHAT}". Essayez un modèle compatible avec le function calling.`
: `The AI did not call the ${toolName} tool. The model may have responded with text. Model: "${sysConfig.AI_MODEL_CHAT}". Try a model that supports function calling.`,
toolLog: toolLogStr,
}
})
return { success: false, actionId, error: `AI did not call ${toolName} tool` }
}
const totalToolCalls = result.steps.reduce((acc, s) => acc + s.toolCalls.length, 0)
let noteId: string
if (existingNoteId) {
let noteId: string | undefined
if (isFileGenerator) {
// File generators NEVER create notes — only canvases
noteId = undefined
} else if (canvasId) {
noteId = undefined
} else if (existingNoteId) {
if (agent.targetNotebookId) {
await prisma.note.update({
where: { id: existingNoteId },
@@ -1010,17 +1373,23 @@ async function executeToolUseAgent(
console.log(`[AgentExecutor] includeImages enabled but no scraped URLs found in tool results`)
}
const resultId = canvasId || noteId
const resultType = canvasId ? 'Canvas' : 'Note'
await prisma.agentAction.update({
where: { id: actionId },
data: {
status: 'success',
result: noteId,
log: `Tool-use: ${totalToolCalls} calls, ${Math.round(duration / 1000)}s. Note: ${noteId}${imageCount > 0 ? `, ${imageCount} images` : ''}`,
result: resultId,
log: `Tool-use: ${totalToolCalls} calls, ${Math.round(duration / 1000)}s. ${resultType}: ${resultId}${imageCount > 0 ? `, ${imageCount} images` : ''}`,
toolLog: JSON.stringify(toolLog),
}
})
return { success: true, actionId, noteId }
if (canvasId) {
return { success: true, actionId, canvasId }
}
return { success: true, actionId, noteId: resultId }
}
// --- Agent Email Notification ---
@@ -1097,6 +1466,10 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
case 'custom':
result = await executeCustomAgent(agent, action.id, lang)
break
case 'slide-generator':
case 'excalidraw-generator':
result = await executeToolUseAgent(agent, action.id, lang, promptOverride)
break
default:
result = await executeScraperAgent(agent, action.id, lang)
}
@@ -1117,7 +1490,7 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
data: { lastRun: new Date(), ...nextRunUpdate }
})
if (result.success && agent.notifyEmail) {
if (result.success && agent.notifyEmail && !result.canvasId) {
const note = result.noteId
? await prisma.note.findUnique({ where: { id: result.noteId }, select: { content: true } })
: null
@@ -1128,15 +1501,26 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
// Create in-app notification for agent result
if (result.success) {
const isCanvas = !!result.canvasId
const resultId = result.canvasId || result.noteId
const isSlides = isCanvas && (agent.type === 'slide-generator')
let message: string
if (isSlides) {
message = lang === 'fr' ? `Présentation PowerPoint prête — cliquez pour télécharger le fichier .pptx.` : `PowerPoint presentation ready — click to download the .pptx file.`
} else if (isCanvas) {
message = lang === 'fr' ? `Diagramme Excalidraw créé — cliquez pour ouvrir dans le Lab.` : `Excalidraw diagram created — click to open in Lab.`
} else if (result.noteId) {
message = lang === 'fr' ? `L'agent a terminé avec succès — note créée.` : `Agent completed successfully — note created.`
} else {
message = lang === 'fr' ? `L'agent a terminé avec succès.` : `Agent completed successfully.`
}
await createNotification({
userId,
type: 'agent_success',
type: isSlides ? 'agent_slides_ready' : isCanvas ? 'agent_canvas_ready' : 'agent_success',
title: agent.name,
message: result.noteId
? (lang === 'fr' ? `L'agent a terminé avec succès — note créée.` : `Agent completed successfully — note created.`)
: (lang === 'fr' ? `L'agent a terminé avec succès.` : `Agent completed successfully.`),
actionUrl: result.noteId ? `/?openNote=${result.noteId}` : '/agents',
relatedId: result.noteId || agentId,
message,
actionUrl: isCanvas ? `/lab?id=${result.canvasId}` : result.noteId ? `/?openNote=${result.noteId}` : '/agents',
relatedId: resultId || agentId,
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,9 @@ import './note-crud.tool'
import './web-scrape.tool'
import './url-fetch.tool'
import './memory.tool'
import './excalidraw.tool'
import './pptx.tool'
import './slides.tool'
// Re-export registry
export { toolRegistry, type ToolContext, type RegisteredTool } from './registry'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,736 @@
'use server'
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
interface SlideSpec {
title: string
subtitle?: string
content: string[]
layout?: 'title' | 'content' | 'section' | 'two-column' | 'cards' | 'stats' | 'quote' | 'toc' | 'summary' | 'image'
imageUrl?: string
notes?: string
}
interface PresentationSpec {
title: string
slides: SlideSpec[]
theme?: string
style?: string
author?: string
}
interface Palette {
primary: string
secondary: string
accent: string
light: string
bg: string
isDark: boolean
}
const PALETTES: Record<string, Palette> = {
modern_wellness: { primary: '#006d77', secondary: '#83c5be', accent: '#e29578', light: '#ffddd2', bg: '#edf6f9', isDark: false },
business_authority: { primary: '#2b2d42', secondary: '#8d99ae', accent: '#ef233c', light: '#edf2f4', bg: '#edf2f4', isDark: false },
nature_outdoors: { primary: '#606c38', secondary: '#283618', accent: '#dda15e', light: '#fefae0', bg: '#fefae0', isDark: false },
vintage_academic: { primary: '#780000', secondary: '#669bbc', accent: '#c1121f', light: '#fdf0d5', bg: '#fdf0d5', isDark: false },
soft_creative: { primary: '#7c6c8a', secondary: '#a89bbd', accent: '#d4a5c9', light: '#e8dff0', bg: '#f3eef8', isDark: false },
bohemian: { primary: '#8a7e5e', secondary: '#a89e72', accent: '#c4a06a', light: '#e9dcc0', bg: '#f5eed8', isDark: false },
vibrant_tech: { primary: '#023047', secondary: '#219ebc', accent: '#ffb703', light: '#8ecae6', bg: '#f8fbff', isDark: false },
craft_artisan: { primary: '#5e3e28', secondary: '#8a6548', accent: '#a68a64', light: '#d4c4a8', bg: '#ede0d4', isDark: false },
tech_night: { primary: '#e0e0e0', secondary: '#ffc300', accent: '#ffd60a', light: '#003566', bg: '#001d3d', isDark: true },
education_charts: { primary: '#264653', secondary: '#2a9d8f', accent: '#e76f51', light: '#e9c46a', bg: '#f4f1eb', isDark: false },
forest_eco: { primary: '#344e41', secondary: '#588157', accent: '#a3b18a', light: '#dad7cd', bg: '#eae8e3', isDark: false },
elegant_fashion: { primary: '#4a5759', secondary: '#8f9fa2', accent: '#b0c4b1', light: '#c9ada7', bg: '#f2e9e4', isDark: false },
art_food: { primary: '#335c67', secondary: '#5e8a6f', accent: '#e09f3e', light: '#f3d97a', bg: '#fff8e1', isDark: false },
luxury_mystery: { primary: '#22223b', secondary: '#4a4e69', accent: '#9a8c98', light: '#c9ada7', bg: '#f2e9e4', isDark: false },
pure_tech_blue: { primary: '#03045e', secondary: '#0077b6', accent: '#00b4d8', light: '#90e0ef', bg: '#caf0f8', isDark: false },
coastal_coral: { primary: '#0081a7', secondary: '#00afb9', accent: '#f07167', light: '#fed9b7', bg: '#fdfcdc', isDark: false },
vibrant_orange_mint: { primary: '#1a1a2e', secondary: '#2ec4b6', accent: '#ff9f1c', light: '#cbf3f0', bg: '#ffffff', isDark: false },
platinum_white_gold: { primary: '#0a0a0a', secondary: '#0070F3', accent: '#D4AF37', light: '#f5f5f5', bg: '#ffffff', isDark: false },
}
const PALETTE_ALIASES: Record<string, string> = {
modern: 'vibrant_tech', corporate: 'business_authority', minimal: 'elegant_fashion',
dark: 'tech_night', midnight: 'luxury_mystery', forest: 'forest_eco', coral: 'coastal_coral',
ocean: 'pure_tech_blue', charcoal: 'platinum_white_gold', teal: 'education_charts',
berry: 'art_food', cherry: 'vintage_academic', clair: 'pure_tech_blue', light: 'modern_wellness',
warm: 'bohemian', premium: 'platinum_white_gold', clean: 'vibrant_tech',
}
const THEME_NAMES: Record<string, string> = {
modern_wellness: 'Modern & Wellness', business_authority: 'Business & Authority',
nature_outdoors: 'Nature & Outdoors', vintage_academic: 'Vintage & Academic',
soft_creative: 'Soft & Creative', bohemian: 'Bohemian',
vibrant_tech: 'Vibrant & Tech', craft_artisan: 'Craft & Artisan',
tech_night: 'Tech & Night', education_charts: 'Education & Charts',
forest_eco: 'Forest & Eco', elegant_fashion: 'Elegant & Fashion',
art_food: 'Art & Food', luxury_mystery: 'Luxury & Mystery',
pure_tech_blue: 'Pure Tech Blue', coastal_coral: 'Coastal Coral',
vibrant_orange_mint: 'Vibrant Orange Mint', platinum_white_gold: 'Platinum White Gold',
}
function resolvePalette(spec: PresentationSpec): { palette: Palette; key: string } {
const name = (spec.theme || '').toLowerCase().replace(/[\s-]/g, '_')
const key = PALETTE_ALIASES[name] || (PALETTES[name] ? name : 'vibrant_tech')
return { palette: PALETTES[key]!, key }
}
function resolveRadius(style?: string): string {
switch ((style || '').toLowerCase()) {
case 'sharp': return '2px'
case 'rounded': return '16px'
case 'pill': return '24px'
default: return '10px'
}
}
function esc(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function safeHtml(str: string): string {
return str
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
.replace(/\son\w+\s*=\s*["'][^"']*["']/gi, '')
.replace(/\son\w+\s*=\s*[^\s>]*/gi, '')
.replace(/javascript\s*:/gi, '')
}
function buildThemeCSS(p: Palette, radius: string): string {
const text = p.isDark ? '#f0f0f0' : '#1a1a1a'
const muted = p.isDark ? '#999' : '#555'
const heading = p.isDark ? '#ffffff' : p.primary
const bgText = p.isDark ? '#e0e0e0' : '#ffffff'
const shadowAlpha = p.isDark ? '0.35' : '0.08'
const shadowAlphaSm = p.isDark ? '0.25' : '0.05'
return `:root {
--p-primary: ${p.primary};
--p-secondary: ${p.secondary};
--p-accent: ${p.accent};
--p-light: ${p.light};
--p-bg: ${p.bg};
--p-text: ${text};
--p-muted: ${muted};
--p-heading: ${heading};
--p-on-primary: ${bgText};
--p-radius: ${radius};
--p-shadow: 0 8px 32px rgba(0,0,0,${shadowAlpha});
--p-shadow-sm: 0 2px 12px rgba(0,0,0,${shadowAlphaSm});
--p-gradient: linear-gradient(135deg, ${p.primary} 0%, ${p.secondary} 100%);
--p-border: ${p.isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
--p-border-accent: ${p.isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)'};
--r-background-color: ${p.bg};
--r-main-font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--r-heading-font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--r-main-font-size: 26px;
--r-heading-font-weight: 700;
--r-heading-color: ${heading};
--r-heading-line-height: 1.15;
--r-heading-letter-spacing: -0.03em;
--r-heading-text-transform: none;
--r-heading-text-shadow: none;
--r-heading1-size: 3.2em;
--r-heading2-size: 2em;
--r-heading3-size: 1.4em;
--r-heading4-size: 1em;
--r-main-color: ${text};
--r-block-margin: 16px;
--r-link-color: ${p.accent};
--r-link-color-hover: ${p.secondary};
--r-selection-background-color: ${p.accent};
--r-selection-color: ${bgText};
}`
}
function buildLayoutCSS(): string {
return `
.reveal-viewport { background: var(--p-bg); }
.reveal {
font-family: var(--r-main-font);
font-weight: 400;
letter-spacing: -0.01em;
color: var(--p-text);
}
.reveal h1, .reveal h2, .reveal h3, .reveal h4 {
font-family: var(--r-heading-font);
font-weight: 700;
text-transform: none;
letter-spacing: -0.03em;
line-height: 1.15;
}
.reveal h1 { font-size: var(--r-heading1-size); }
.reveal h2 { font-size: var(--r-heading2-size); margin-bottom: 0.1em; }
.reveal h3 { font-size: var(--r-heading3-size); }
.reveal section { padding: 40px 60px; text-align: left; }
.reveal a { color: var(--p-accent); text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
.reveal a:hover { border-bottom-color: var(--p-accent); }
.reveal .accent-bar {
width: 48px; height: 3px; background: var(--p-accent);
border-radius: 2px; margin-bottom: 1.4rem; flex-shrink: 0;
}
.reveal .accent-bar--center {
margin-left: auto; margin-right: auto;
}
.reveal .accent-bar--wide {
width: 80px; height: 4px;
}
/* ======= DECORATIVE FRAME ======= */
.reveal .frame-top,
.reveal .frame-bottom,
.reveal .frame-left,
.reveal .frame-right {
position: fixed; z-index: 10; background: var(--p-accent); pointer-events: none;
}
.reveal .frame-top { top: 0; left: 0; right: 0; height: 4px; }
.reveal .frame-bottom { bottom: 0; left: 0; right: 0; height: 4px; }
.reveal .frame-left { top: 0; bottom: 0; left: 0; width: 4px; }
.reveal .frame-right { top: 0; bottom: 0; right: 0; width: 4px; }
/* ======= TITLE ======= */
.reveal .s-title {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100%; text-align: center; padding: 0 80px;
}
.reveal .s-title::before {
content: ''; position: absolute; inset: 0; z-index: -1;
background: var(--p-gradient); opacity: 0.06;
}
.reveal .s-title h1 {
color: var(--p-heading); margin: 0; line-height: 1.1;
}
.reveal .s-title .subtitle {
color: var(--p-muted); font-size: 16pt; margin-top: 1.2rem;
font-weight: 300; letter-spacing: 0.02em;
}
/* ======= SECTION DIVIDER ======= */
.reveal .s-section {
display: flex; flex-direction: column; justify-content: center; align-items: center;
height: 100%; text-align: center;
}
.reveal .s-section::before {
content: ''; position: absolute; inset: 0; z-index: -1;
background: var(--p-light);
}
.reveal .s-section .section-num {
color: var(--p-accent); font-size: 120pt; font-weight: 800;
opacity: 0.12; line-height: 1; margin-bottom: -0.3em;
}
.reveal .s-section h2 {
color: var(--p-heading);
}
.reveal .s-section .subtitle {
color: var(--p-muted); font-size: 14pt; margin-top: 0.5rem;
}
/* ======= TOC ======= */
.reveal .s-toc h2 { color: var(--p-heading); }
.reveal .s-toc .toc-list { display: flex; flex-direction: column; gap: 2px; }
.reveal .s-toc .toc-item {
display: flex; align-items: center; gap: 16px;
padding: 10px 16px; border-radius: var(--p-radius);
transition: background 0.2s;
}
.reveal .s-toc .toc-item:nth-child(odd) { background: var(--p-border); }
.reveal .s-toc .toc-num {
color: var(--p-accent); font-size: 24pt; font-weight: 800;
min-width: 50px; text-align: right; line-height: 1;
}
.reveal .s-toc .toc-label {
color: var(--p-text); font-size: 14pt; padding-left: 12px;
border-left: 3px solid var(--p-secondary);
}
/* ======= CONTENT ======= */
.reveal .s-content h2 { color: var(--p-heading); }
.reveal .s-content ul { list-style: none; padding: 0; margin: 0; }
.reveal .s-content li {
color: var(--p-text); font-size: 14pt; padding: 8px 0;
display: flex; align-items: flex-start; gap: 14px; line-height: 1.5;
}
.reveal .s-content li::before {
content: ''; display: block; width: 8px; height: 8px; min-width: 8px;
background: var(--p-accent); border-radius: 50%; margin-top: 0.5em;
}
/* ======= TWO COLUMN ======= */
.reveal .s-twocol h2 { color: var(--p-heading); }
.reveal .s-twocol .cols {
display: grid; grid-template-columns: 1fr 1fr; gap: 32px;
}
.reveal .s-twocol .col {
background: var(--p-border); border-radius: var(--p-radius);
padding: 20px 24px;
}
.reveal .s-twocol .col--accent {
border-left: 3px solid var(--p-accent);
}
.reveal .s-twocol .col p {
color: var(--p-text); font-size: 13pt; margin: 8px 0; line-height: 1.55;
}
/* ======= CARDS ======= */
.reveal .s-cards h2 { color: var(--p-heading); }
.reveal .s-cards .card-grid { display: grid; gap: 14px; }
.reveal .s-cards .card-grid.g2 { grid-template-columns: repeat(2, 1fr); }
.reveal .s-cards .card-grid.g3 { grid-template-columns: repeat(3, 1fr); }
.reveal .s-cards .card {
border-radius: var(--p-radius); padding: 22px 24px;
display: flex; flex-direction: column; gap: 6px;
border: 1px solid var(--p-border-accent);
background: var(--p-border);
transition: transform 0.2s, box-shadow 0.2s;
}
.reveal .s-cards .card:nth-child(odd) {
background: var(--p-primary); border-color: transparent;
}
.reveal .s-cards .card:nth-child(odd) .card-num { color: rgba(255,255,255,0.25); }
.reveal .s-cards .card:nth-child(odd) .card-text { color: #ffffff; }
.reveal .s-cards .card:nth-child(even) .card-num { color: var(--p-accent); opacity: 0.5; }
.reveal .s-cards .card:nth-child(even) .card-text { color: var(--p-text); }
.reveal .s-cards .card-num {
font-size: 18pt; font-weight: 800; line-height: 1;
}
.reveal .s-cards .card-text {
font-size: 12pt; line-height: 1.5;
}
/* ======= STATS ======= */
.reveal .s-stats h2 { color: var(--p-heading); }
.reveal .s-stats .stat-grid {
display: grid; gap: 28px; margin-top: 1.2rem;
}
.reveal .s-stats .stat {
text-align: center; padding-top: 16px;
border-top: 4px solid var(--p-accent);
}
.reveal .s-stats .stat-value {
color: var(--p-heading); font-size: 44pt; font-weight: 800; line-height: 1;
}
.reveal .s-stats .stat-label {
color: var(--p-muted); font-size: 12pt; margin-top: 8px;
text-transform: uppercase; letter-spacing: 0.06em;
}
/* ======= QUOTE ======= */
.reveal .s-quote {
display: flex; flex-direction: column; justify-content: center;
height: 100%; text-align: left; padding: 0 80px;
}
.reveal .s-quote::before {
content: ''; position: absolute; inset: 0; z-index: -1;
background: var(--p-gradient); opacity: 0.08;
}
.reveal .s-quote .q-mark {
color: var(--p-accent); font-size: 100pt; font-weight: 700;
line-height: 0.4; font-family: 'Playfair Display', Georgia, serif;
opacity: 0.5;
}
.reveal .s-quote blockquote {
color: var(--p-heading); font-size: 22pt; font-style: italic;
line-height: 1.5; margin: 16px 0 24px;
font-family: 'Playfair Display', Georgia, serif;
border: none; box-shadow: none; padding: 0; background: none;
width: 100%; text-align: left;
}
.reveal .s-quote cite {
color: var(--p-accent); font-size: 12pt; font-style: normal;
font-family: var(--r-main-font);
}
/* ======= SUMMARY ======= */
.reveal .s-summary h2 { color: var(--p-heading); }
.reveal .s-summary::before {
content: ''; position: absolute; inset: 0; z-index: -1;
background: var(--p-light);
}
.reveal .s-summary .summary-list { display: flex; flex-direction: column; gap: 4px; }
.reveal .s-summary .summary-item {
display: flex; align-items: center; gap: 14px;
padding: 10px 16px; border-radius: var(--p-radius);
background: var(--p-border);
}
.reveal .s-summary .summary-dot {
width: 10px; height: 10px; min-width: 10px;
background: var(--p-accent); border-radius: 50%;
}
.reveal .s-summary .summary-text {
color: var(--p-text); font-size: 14pt;
}
/* ======= IMAGE ======= */
.reveal .s-image h2 { color: var(--p-heading); }
.reveal .s-image img {
max-width: 85%; max-height: 55vh; border-radius: var(--p-radius);
box-shadow: var(--p-shadow); display: block; margin: 1rem auto 0;
}
.reveal .s-image .caption {
color: var(--p-muted); font-size: 11pt; text-align: center; margin-top: 12px;
}
/* ======= UI ======= */
.reveal .controls button { color: var(--p-accent); }
.reveal .progress span { background: var(--p-accent); }
.reveal .slide-number { color: var(--p-muted); font-size: 10pt; opacity: 0.7; }
/* ======= PRINT ======= */
@page { size: 1219px 686px; margin: 0; }
@media print {
.reveal section { padding: 20px; }
.reveal .frame-top, .reveal .frame-bottom,
.reveal .frame-left, .reveal .frame-right { display: none; }
}`
}
function renderSlide(slide: SlideSpec, index: number): string {
const layout = slide.layout || (index === 0 ? 'title' : 'content')
let html = ''
switch (layout) {
case 'title':
html = `<section class="s-title">
<div class="accent-bar accent-bar--wide accent-bar--center"></div>
<h1>${safeHtml(slide.title)}</h1>
${slide.subtitle ? `<p class="subtitle">${safeHtml(slide.subtitle)}</p>` : ''}
<div class="accent-bar accent-bar--wide accent-bar--center" style="margin-top:1.5rem;"></div>
</section>`
break
case 'toc':
html = `<section class="s-toc">
<h2>${safeHtml(slide.title || 'Sommaire')}</h2>
<div class="accent-bar"></div>
<div class="toc-list">
${slide.content.map((item, i) => `<div class="toc-item">
<span class="toc-num">${String(i + 1).padStart(2, '0')}</span>
<span class="toc-label">${safeHtml(item)}</span>
</div>`).join('\n ')}
</div>
</section>`
break
case 'section':
html = `<section class="s-section">
<span class="section-num">${safeHtml(slide.content[0] || String(index).padStart(2, '0'))}</span>
<h2>${safeHtml(slide.title)}</h2>
${slide.subtitle ? `<p class="subtitle">${safeHtml(slide.subtitle)}</p>` : ''}
<div class="accent-bar accent-bar--center" style="margin-top:1rem;"></div>
</section>`
break
case 'content':
html = `<section class="s-content">
<h2>${safeHtml(slide.title)}</h2>
<div class="accent-bar"></div>
<ul>
${slide.content.map(item => `<li><span>${safeHtml(item)}</span></li>`).join('\n ')}
</ul>
</section>`
break
case 'two-column': {
const mid = Math.ceil(slide.content.length / 2)
const left = slide.content.slice(0, mid)
const right = slide.content.slice(mid)
html = `<section class="s-twocol">
<h2>${safeHtml(slide.title)}</h2>
<div class="accent-bar"></div>
<div class="cols">
<div class="col">
${left.map(item => `<p>${safeHtml(item)}</p>`).join('\n ')}
</div>
<div class="col col--accent">
${right.map(item => `<p>${safeHtml(item)}</p>`).join('\n ')}
</div>
</div>
</section>`
break
}
case 'cards': {
const items = slide.content.slice(0, 6)
const gClass = items.length <= 3 ? `g${items.length}` : 'g2'
html = `<section class="s-cards">
<h2>${safeHtml(slide.title)}</h2>
<div class="accent-bar"></div>
<div class="card-grid ${gClass}">
${items.map((item, i) => `<div class="card">
<span class="card-num">${String(i + 1).padStart(2, '0')}</span>
<p class="card-text">${safeHtml(item)}</p>
</div>`).join('\n ')}
</div>
</section>`
break
}
case 'stats':
html = `<section class="s-stats">
<h2>${safeHtml(slide.title)}</h2>
<div class="accent-bar"></div>
<div class="stat-grid" style="grid-template-columns:repeat(${slide.content.slice(0, 4).length}, 1fr);">
${slide.content.slice(0, 4).map(item => {
const parts = item.split(/[-\u2013\u2014:]/)
const stat = parts[0]?.trim() || item
const label = parts.slice(1).join(':').trim()
return `<div class="stat">
<div class="stat-value">${safeHtml(stat)}</div>
${label ? `<div class="stat-label">${safeHtml(label)}</div>` : ''}
</div>`
}).join('\n ')}
</div>
</section>`
break
case 'quote':
html = `<section class="s-quote">
<div class="q-mark">\u201C</div>
<blockquote>${safeHtml(slide.title)}</blockquote>
${slide.subtitle ? `<cite>\u2014 ${safeHtml(slide.subtitle)}</cite>` : ''}
</section>`
break
case 'summary':
html = `<section class="s-summary">
<h2>${safeHtml(slide.title || 'En r\u00e9sum\u00e9')}</h2>
<div class="accent-bar"></div>
<div class="summary-list">
${slide.content.slice(0, 5).map(item => `<div class="summary-item">
<div class="summary-dot"></div>
<span class="summary-text">${safeHtml(item)}</span>
</div>`).join('\n ')}
</div>
</section>`
break
case 'image':
html = `<section class="s-image">
<h2>${safeHtml(slide.title)}</h2>
<div class="accent-bar"></div>
${slide.imageUrl ? `<img src="${esc(slide.imageUrl)}" alt="${esc(slide.title)}">` : ''}
${slide.content[0] ? `<p class="caption">${safeHtml(slide.content[0])}</p>` : ''}
</section>`
break
default:
html = `<section class="s-content">
<h2>${safeHtml(slide.title)}</h2>
<ul>
${slide.content.map(item => `<li><span>${safeHtml(item)}</span></li>`).join('\n ')}
</ul>
</section>`
}
if (slide.notes) {
html = html.replace('</section>', `<aside class="notes">${esc(slide.notes)}</aside>\n</section>`)
}
return html
}
function buildRevealHtml(spec: PresentationSpec): string {
const { palette, key } = resolvePalette(spec)
const baseTheme = palette.isDark ? 'moon' : 'white'
const radius = resolveRadius(spec.style)
const slidesHtml = spec.slides.map((s, i) => renderSlide(s, i)).join('\n')
return `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${esc(spec.title)}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/theme/${baseTheme}.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Playfair+Display:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
<style>
${buildThemeCSS(palette, radius)}
${buildLayoutCSS()}
</style>
</head>
<body>
<div class="reveal">
<div class="frame-top"></div>
<div class="frame-bottom"></div>
<div class="frame-left"></div>
<div class="frame-right"></div>
<div class="slides">
${slidesHtml}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.js"></script>
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/plugin/notes/notes.js"></script>
<script>
Reveal.initialize({
hash: true,
slideNumber: 'c/t',
showSlideNumber: 'all',
transition: 'slide',
transitionSpeed: 'default',
backgroundTransition: 'fade',
center: true,
margin: 0.06,
width: 1280,
height: 720,
plugins: [ RevealNotes ],
keyboard: true,
overview: true,
touch: true,
loop: false,
controls: true,
controlsLayout: 'bottom-right',
controlsBackArrows: 'visible',
progress: true,
});
</script>
</body>
</html>`
}
function parseSlidesFromText(text: string): PresentationSpec {
const lines = text.split('\n').filter(l => l.trim().length > 0)
const title = lines[0]?.replace(/^#+\s*/, '').trim() || 'Presentation'
const slides: SlideSpec[] = []
let current: SlideSpec | null = null
for (const line of lines) {
const t = line.trim()
if (t.match(/^#{1,2}\s+/) || t.match(/^slide\s+\d+/i)) {
if (current) slides.push(current)
current = { title: t.replace(/^#{1,2}\s+/, '').replace(/^slide\s+\d+\s*[:-]?\s*/i, ''), content: [] }
} else if (current && (t.match(/^[-*]\s+/) || t.match(/^\d+\.\s+/))) {
current.content.push(t.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, ''))
}
}
if (current) slides.push(current)
if (slides.length === 0) slides.push({ title, content: lines.slice(1, 8).map(l => l.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '')) })
return { title, slides }
}
toolRegistry.register({
name: 'generate_slides',
description: 'Generate a beautiful HTML presentation with Reveal.js and save it for viewing.',
isInternal: true,
buildTool: (ctx) =>
tool({
description: `Generate a beautiful HTML presentation using Reveal.js and save it.
Provide a JSON specification:
{
"title": "Presentation Title",
"theme": "vibrant_tech",
"slides": [
{ "title": "Title", "subtitle": "Subtitle", "content": [], "layout": "title" },
{ "title": "Sommaire", "content": ["Section 1", "Section 2"], "layout": "toc" },
{ "title": "Key Points", "content": ["Point 1", "Point 2"], "layout": "content" },
{ "title": "Features", "content": ["Feature A: desc", "Feature B: desc"], "layout": "cards" },
{ "title": "Metrics", "content": ["99% - Uptime", "50K - Users"], "layout": "stats" },
{ "title": "Introduction", "content": ["01"], "subtitle": "Topic", "layout": "section" },
{ "title": "A great quote.", "subtitle": "- Author", "layout": "quote" },
{ "title": "Summary", "content": ["Point 1", "Point 2"], "layout": "summary" }
]
}
THEMES: modern_wellness, business_authority, nature_outdoors, vintage_academic, soft_creative, bohemian, vibrant_tech, craft_artisan, tech_night, education_charts, forest_eco, elegant_fashion, art_food, luxury_mystery, pure_tech_blue, coastal_coral, vibrant_orange_mint, platinum_white_gold
LAYOUTS: title, toc, content, section, two-column, cards, stats, quote, summary, image
RULES:
- First slide MUST be "title"
- Second slide should be "toc"
- Use "section" for dividers (content[0]=section number like "01")
- Use "cards" for feature lists (3-6 items)
- Use "stats" for numbers (format: "NUMBER - LABEL")
- Use "quote" for quotes (title=quote, subtitle=attribution)
- Use "summary" for closing summary
- Use "two-column" for comparisons
- 5-12 slides, vary layouts, no repeats consecutively`,
inputSchema: z.object({
title: z.string().describe('Title for the presentation'),
slides: z.string().describe('JSON presentation specification'),
}),
execute: async ({ title, slides }) => {
try {
console.log('[Slides Tool] INPUT title:', title)
console.log('[Slides Tool] INPUT slides (first 500 chars):', slides?.substring(0, 500))
let spec: PresentationSpec
try {
const parsed = JSON.parse(slides)
console.log('[Slides Tool] JSON parsed OK. slides count:', parsed.slides?.length, 'theme:', parsed.theme, 'title:', parsed.title)
if (parsed.slides && Array.isArray(parsed.slides) && parsed.slides.length > 0) {
spec = {
title: parsed.title || title || 'Presentation',
theme: parsed.theme || 'vibrant_tech',
style: parsed.style,
slides: parsed.slides.map((s: any) => ({
title: String(s.title || '').substring(0, 200),
subtitle: s.subtitle ? String(s.subtitle).substring(0, 300) : undefined,
content: Array.isArray(s.content) ? s.content.map((c: any) => String(c).substring(0, 500)).slice(0, 12) : [],
layout: ['title', 'content', 'section', 'two-column', 'cards', 'stats', 'quote', 'toc', 'summary', 'image'].includes(s.layout) ? s.layout : undefined,
imageUrl: s.imageUrl ? String(s.imageUrl).substring(0, 500) : undefined,
notes: s.notes ? String(s.notes).substring(0, 1000) : undefined,
})),
}
} else {
console.log('[Slides Tool] No slides array in JSON, falling back to text parse')
spec = parseSlidesFromText(slides)
}
} catch (parseErr) {
console.log('[Slides Tool] JSON parse failed, falling back to text parse:', parseErr)
spec = parseSlidesFromText(slides)
}
console.log('[Slides Tool] Spec:', JSON.stringify({ title: spec.title, theme: spec.theme, style: spec.style, slideCount: spec.slides.length, layouts: spec.slides.map(s => s.layout) }))
if (spec.slides.length === 0) {
console.log('[Slides Tool] ERROR: No slides provided')
return { success: false, error: 'No slides provided' }
}
const html = buildRevealHtml(spec)
console.log('[Slides Tool] HTML generated. Length:', html.length, '| Start:', html.substring(0, 120))
const canvas = await prisma.canvas.create({
data: {
name: title || spec.title || 'Presentation',
data: JSON.stringify({
type: 'slides',
title: spec.title,
theme: spec.theme,
slideCount: spec.slides.length,
html,
}),
userId: ctx.userId,
},
})
console.log('[Slides Tool] Canvas created:', canvas.id, canvas.name)
return {
success: true, canvasId: canvas.id, canvasName: canvas.name,
slideCount: spec.slides.length, theme: spec.theme,
message: `Presentation created with ${spec.slides.length} slides. Open in browser to view.`,
}
} catch (e: any) {
console.error('[Slides Tool] FATAL:', e)
return { success: false, error: `Failed: ${e.message}` }
}
},
}),
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,10 @@ const nextConfig: NextConfig = {
// Enable standalone output for Docker
output: 'standalone',
// These server-side packages use Node.js internals (buffers, native modules, etc.)
// and must not be bundled by Turbopack — they are required directly by Node.js at runtime.
serverExternalPackages: ['pptxgenjs', 'dagre', 'elkjs'],
// Serve dynamically uploaded files via API route (public/ is read-only in production)
async rewrites() {
return [

View File

@@ -52,9 +52,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"date-fns": "^4.1.0",
"diff": "^9.0.0",
"dotenv": "^17.2.3",
"elkjs": "^0.11.1",
"isomorphic-dompurify": "^3.12.0",
"jsdom": "^29.0.2",
"katex": "^0.16.27",
@@ -64,6 +66,7 @@
"nodemailer": "^8.0.4",
"novel": "^1.0.2",
"postcss": "^8.5.6",
"pptxgenjs": "^4.0.1",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -86,6 +89,7 @@
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/bcryptjs": "^2.4.6",
"@types/dagre": "^0.7.54",
"@types/diff": "^7.0.2",
"@types/node": "^20",
"@types/nodemailer": "^7.0.4",
@@ -7113,6 +7117,13 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/dagre": {
"version": "0.7.54",
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz",
"integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -7956,6 +7967,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cose-base": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
@@ -8589,6 +8606,16 @@
"node": ">=12"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/dagre-d3-es": {
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
@@ -8810,6 +8837,12 @@
"integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==",
"license": "ISC"
},
"node_modules/elkjs": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
"integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==",
"license": "EPL-2.0"
},
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
@@ -9113,6 +9146,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/hachure-fill": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
@@ -9367,6 +9409,12 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/https": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
"license": "ISC"
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -9408,6 +9456,27 @@
"pica": "^7.1.0"
}
},
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"license": "MIT",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immutable": {
"version": "4.3.8",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz",
@@ -9539,6 +9608,12 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -9733,6 +9808,24 @@
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/katex": {
"version": "0.16.45",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz",
@@ -9794,6 +9887,15 @@
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
"license": "MIT"
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -10070,6 +10172,12 @@
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
@@ -12431,6 +12539,33 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/pptxgenjs": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz",
"integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==",
"license": "MIT",
"dependencies": {
"@types/node": "^22.8.1",
"https": "^1.0.0",
"image-size": "^1.2.1",
"jszip": "^3.10.1"
}
},
"node_modules/pptxgenjs/node_modules/@types/node": {
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/pptxgenjs/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/preact": {
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
@@ -12485,6 +12620,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -12726,6 +12867,15 @@
"integrity": "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==",
"license": "Apache-2.0"
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/radix-ui": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
@@ -13048,6 +13198,21 @@
}
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -13290,6 +13455,12 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -13370,6 +13541,12 @@
"node": ">=10"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
@@ -13501,6 +13678,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -14150,7 +14336,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {

View File

@@ -3,8 +3,8 @@
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "prisma generate && next build",
"dev": "NODE_OPTIONS='--max-old-space-size=4096' next dev --turbopack",
"build": "prisma generate && next build --webpack",
"start": "next start",
"db:generate": "prisma generate",
"db:migrate": "node scripts/safe-migrate.js",
@@ -69,9 +69,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"date-fns": "^4.1.0",
"diff": "^9.0.0",
"dotenv": "^17.2.3",
"elkjs": "^0.11.1",
"isomorphic-dompurify": "^3.12.0",
"jsdom": "^29.0.2",
"katex": "^0.16.27",
@@ -81,6 +83,7 @@
"nodemailer": "^8.0.4",
"novel": "^1.0.2",
"postcss": "^8.5.6",
"pptxgenjs": "^4.0.1",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -103,6 +106,7 @@
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/bcryptjs": "^2.4.6",
"@types/dagre": "^0.7.54",
"@types/diff": "^7.0.2",
"@types/node": "^20",
"@types/nodemailer": "^7.0.4",

View File

@@ -0,0 +1,15 @@
-- AlterTable: Agent — notes sources explicites + options slides (aligné schema.prisma)
DO $$ BEGIN
ALTER TABLE "Agent" ADD COLUMN "sourceNoteIds" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "Agent" ADD COLUMN "slideTheme" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "Agent" ADD COLUMN "slideStyle" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;

View File

@@ -311,6 +311,9 @@ model Agent {
isEnabled Boolean @default(true)
targetNotebookId String?
sourceNotebookId String?
sourceNoteIds String?
slideTheme String?
slideStyle String?
tools String? @default("[]")
maxSteps Int @default(10)
notifyEmail Boolean @default(false)