18 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
22 changed files with 982 additions and 131 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

@@ -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

@@ -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

@@ -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

@@ -4,7 +4,16 @@ import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
import dagre from 'dagre'
// import type is erased at build time — Turbopack won't try to resolve the module
import type dagreType from 'dagre'
let _dagre: typeof dagreType | null = null
async function getDagre(): Promise<typeof dagreType> {
if (!_dagre) {
const mod = await import('dagre')
_dagre = (mod.default ?? mod) as typeof dagreType
}
return _dagre
}
interface SimplifiedNode {
id: string
@@ -504,12 +513,13 @@ function getNodeRenderSpec(node: SimplifiedNode, isCenter: boolean): NodeRenderS
return { text: wrapped, fontSize, width, height }
}
function computeNodeLayoutForRankdir(
async function computeNodeLayoutForRankdir(
nodes: SimplifiedNode[],
edges: SimplifiedEdge[],
rankdir: 'LR' | 'TB',
renderSpecs: Map<string, NodeRenderSpec>,
): Map<string, NodeLayoutBox> {
): Promise<Map<string, NodeLayoutBox>> {
const dagre = await getDagre()
const graph = new dagre.graphlib.Graph()
graph.setGraph({
rankdir,
@@ -738,11 +748,11 @@ async function computeNodeLayout(
const firstRankdir: 'LR' | 'TB' = (diagramType === 'org-chart') ? 'TB' : 'LR'
const secondRankdir: 'LR' | 'TB' = firstRankdir === 'LR' ? 'TB' : 'LR'
const lrLayout = computeNodeLayoutForRankdir(nodes, edges, firstRankdir, renderSpecs)
const lrLayout = await computeNodeLayoutForRankdir(nodes, edges, firstRankdir, renderSpecs)
const lrQuality = computeLayoutQuality(nodes, edges, lrLayout)
const lrBounds = getLayoutBounds(lrLayout)
const tbLayout = computeNodeLayoutForRankdir(nodes, edges, secondRankdir, renderSpecs)
const tbLayout = await computeNodeLayoutForRankdir(nodes, edges, secondRankdir, renderSpecs)
const tbQuality = computeLayoutQuality(nodes, edges, tbLayout)
const tbBounds = getLayoutBounds(tbLayout)

View File

@@ -1,6 +1,16 @@
'use server'
import PptxGenJS from 'pptxgenjs'
// import type is erased at build time — Turbopack won't try to resolve the module
import type PptxGenJSModule from 'pptxgenjs'
// Lazy singleton — actual module loaded at runtime only
let _PptxGenJS: (new () => PptxGenJSModule) | null = null
async function getPptxGenClass(): Promise<new () => PptxGenJSModule> {
if (!_PptxGenJS) {
const mod = await import('pptxgenjs')
_PptxGenJS = (mod.default ?? mod) as unknown as new () => PptxGenJSModule
}
return _PptxGenJS
}
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
@@ -105,7 +115,7 @@ function addBadge(s: any, num: number, accent: string) {
}
/** Modern split-layout cover: white left panel with big title, colored right panel */
function addCoverSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg) {
function addCoverSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg) {
const s = pres.addSlide()
s.background = { color: t.bg }
@@ -167,7 +177,7 @@ function addCoverSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: Style
return s
}
function addTocSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
function addTocSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
const s = pres.addSlide()
s.background = { color: t.bg }
const textColor = textOnBg(t.bg)
@@ -216,7 +226,7 @@ function addTocSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCf
}
/** Section divider: full-bleed colored left panel + large decorative number on right */
function addSectionSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
function addSectionSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
const s = pres.addSlide()
s.background = { color: t.bg }
@@ -256,7 +266,7 @@ function addSectionSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: Sty
return s
}
function addContentSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
function addContentSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
const s = pres.addSlide()
s.background = { color: t.bg }
const textColor = textOnBg(t.bg)
@@ -273,7 +283,7 @@ function addContentSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: Sty
const bullets = slide.content.map((line, i) => ({
text: line,
options: { fontSize: 15, fontFace: 'Arial', color: textColor, bullet: { type: 'bullet', indent: 10 }, breakLine: true, paraSpaceAfter: 10, paraSpaceBefore: i === 0 ? 0 : 2 },
options: { fontSize: 15, fontFace: 'Arial', color: textColor, bullet: { type: 'bullet' as const, indent: 10 }, breakLine: true, paraSpaceAfter: 10, paraSpaceBefore: i === 0 ? 0 : 2 },
}))
s.addText(bullets, {
@@ -285,7 +295,7 @@ function addContentSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: Sty
return s
}
function addTwoColumnSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
function addTwoColumnSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
const s = pres.addSlide()
s.background = { color: t.bg }
const textColor = textOnBg(t.bg)
@@ -324,7 +334,7 @@ function addTwoColumnSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: S
return s
}
function addCardsSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
function addCardsSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
const s = pres.addSlide()
s.background = { color: t.bg }
@@ -403,7 +413,7 @@ function addCardsSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: Style
return s
}
function addStatsSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
function addStatsSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
const s = pres.addSlide()
s.background = { color: t.bg }
@@ -449,7 +459,7 @@ function addStatsSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: Style
return s
}
function addQuoteSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
function addQuoteSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
const s = pres.addSlide()
s.background = { color: t.primary }
const textColor = textOnBg(t.primary)
@@ -479,7 +489,7 @@ function addQuoteSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: Style
return s
}
function addSummarySlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
function addSummarySlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
const s = pres.addSlide()
s.background = { color: t.light }
const textColor = textOnBg(t.light)
@@ -525,7 +535,7 @@ function resolveImageProps(imageUrl: string): Record<string, any> {
}
/** Image on the right (40%), bullets on the left (55%) */
function addImageContentSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
function addImageContentSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
const s = pres.addSlide()
s.background = { color: t.bg }
const textColor = textOnBg(t.bg)
@@ -589,7 +599,7 @@ function addImageContentSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style
}
/** Full-bleed image slide with title + subtitle overlay */
function addImageFullSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
function addImageFullSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
const s = pres.addSlide()
s.background = { color: t.primary }
@@ -600,7 +610,7 @@ function addImageFullSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: S
}
// Dark overlay at bottom for text legibility
s.addShape(SHAPE_RECT, { x: 0, y: 3.3, w: 10, h: 2.33, fill: { color: '000000' }, transparency: 40 })
s.addShape(SHAPE_RECT, { x: 0, y: 3.3, w: 10, h: 2.33, fill: { color: '000000', transparency: 40 } })
s.addShape(SHAPE_RECT, { x: 0, y: 3.3, w: 0.12, h: 2.33, fill: { color: t.accent } })
s.addText(slide.title, {
@@ -619,7 +629,7 @@ function addImageFullSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: S
}
/** Horizontal timeline — content items: "Step title: description" */
function addTimelineSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
function addTimelineSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
const s = pres.addSlide()
s.background = { color: t.bg }
const textColor = textOnBg(t.bg)
@@ -726,7 +736,7 @@ function addTimelineSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: St
}
/** Vertical numbered steps with connecting line — content items: "Step title: description" */
function addProcessSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
function addProcessSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
const s = pres.addSlide()
s.background = { color: t.bg }
const textColor = textOnBg(t.bg)
@@ -796,7 +806,7 @@ function addProcessSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: Sty
}
/** Two-panel comparison — subtitle: "Left Label | Right Label", content split 50/50 */
function addComparisonSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
function addComparisonSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
const s = pres.addSlide()
s.background = { color: t.bg }
@@ -864,7 +874,7 @@ function addComparisonSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style:
}
/** Visual KPI blocks — content items: "VALUE: label" or "VALUE - label" */
function addMetricsSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
function addMetricsSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
const s = pres.addSlide()
s.background = { color: t.bg }
@@ -922,7 +932,7 @@ function addMetricsSlide(pres: PptxGenJS, slide: SlideSpec, t: Theme, style: Sty
return s
}
function addClosingSlide(pres: PptxGenJS, spec: PresentationSpec, t: Theme, style: StyleCfg) {
function addClosingSlide(pres: PptxGenJSModule, spec: PresentationSpec, t: Theme, style: StyleCfg) {
const s = pres.addSlide()
s.background = { color: t.bg }
@@ -960,10 +970,11 @@ function addClosingSlide(pres: PptxGenJS, spec: PresentationSpec, t: Theme, styl
return s
}
function buildPresentation(spec: PresentationSpec): PptxGenJS {
async function buildPresentation(spec: PresentationSpec): Promise<PptxGenJSModule> {
const { theme } = resolveTheme(spec)
const style = STYLES[spec.style || 'soft'] || STYLES.soft!
const PptxGenJS = await getPptxGenClass()
const pres = new PptxGenJS()
pres.title = spec.title
pres.author = 'Momento'
@@ -1111,7 +1122,27 @@ RULES:
if (spec.slides.length === 0) return { success: false, error: 'No slides provided' }
const pptx = buildPresentation(spec)
// Pre-fetch all image URLs server-side and convert to base64 data URIs.
// pptxgenjs on Node.js cannot reliably fetch authenticated or relative URLs,
// so we do it here where we have full server context.
for (const slide of spec.slides) {
if (!slide.imageUrl || slide.imageUrl.startsWith('data:')) continue
try {
const res = await fetch(slide.imageUrl, { signal: AbortSignal.timeout(8000) })
if (res.ok) {
const buf = await res.arrayBuffer()
const mime = res.headers.get('content-type') || 'image/png'
const b64 = Buffer.from(buf).toString('base64')
slide.imageUrl = `data:${mime};base64,${b64}`
} else {
slide.imageUrl = undefined // failed: show placeholder text
}
} catch {
slide.imageUrl = undefined
}
}
const pptx = await buildPresentation(spec)
const base64 = await pptx.write({ outputType: 'base64' }) as string
const canvas = await prisma.canvas.create({

View File

@@ -436,6 +436,36 @@
"translate": "Translate",
"explain": "Explain"
},
"generate": {
"sectionLabel": "Generate from this note",
"slides": "Generate a presentation",
"diagram": "Generate a diagram",
"loading": "Generating…",
"slidesReady": "Presentation generated!",
"diagramReady": "Diagram generated!",
"downloadPptx": "Download .pptx",
"openDiagram": "Open in Lab",
"error": "Error during generation",
"noNoteId": "Save the note first",
"theme": "Theme",
"style": "Style",
"diagramType": "Type",
"typeAuto": "Auto",
"styleSoft": "Soft",
"styleSharp": "Sharp",
"styleRounded": "Rounded",
"stylePill": "Pill",
"styleSketchy": "Sketchy",
"styleAustere": "Austere",
"styleSketchPlus": "Sketch+",
"toastLoading": {
"slides": "⏳ Generating presentation…",
"diagram": "⏳ Generating diagram…"
},
"toastLoadingDesc": "You can navigate freely, a notification will appear.",
"toastSuccessSlides": "Click Download in the AI panel.",
"toastSuccessDiagram": "Your diagram is available in the Lab."
},
"openAssistant": "Open AI Assistant",
"poweredByMomento": "Powered by Momento AI",
"welcomeMsg": "Hello! I'm your AI assistant. How can I help you with your notes today? I can help refine tone, expand messaging, or summarize content.",
@@ -1560,6 +1590,8 @@
"updated": "Agent updated",
"deleted": "\"{name}\" deleted",
"deleteError": "Error deleting",
"running": "Generation in progress…",
"runningDesc": "Generation may take a few minutes. You can navigate freely.",
"runSuccess": "\"{name}\" executed successfully",
"runError": "Error: {error}",
"runFailed": "Execution failed",

View File

@@ -436,6 +436,36 @@
"translate": "Traduire",
"explain": "Expliquer"
},
"generate": {
"sectionLabel": "Générer depuis cette note",
"slides": "Générer une présentation",
"diagram": "Générer un diagramme",
"loading": "Génération en cours…",
"slidesReady": "Présentation générée !",
"diagramReady": "Diagramme généré !",
"downloadPptx": "Télécharger le .pptx",
"openDiagram": "Ouvrir dans le Lab",
"error": "Erreur lors de la génération",
"noNoteId": "Enregistrez d'abord la note",
"theme": "Thème",
"style": "Style",
"diagramType": "Type",
"typeAuto": "Auto",
"styleSoft": "Soft",
"styleSharp": "Sharp",
"styleRounded": "Arrondi",
"stylePill": "Pill",
"styleSketchy": "Sketchy",
"styleAustere": "Austère",
"styleSketchPlus": "Sketch+",
"toastLoading": {
"slides": "⏳ Génération de la présentation en cours…",
"diagram": "⏳ Génération du diagramme en cours…"
},
"toastLoadingDesc": "Vous pouvez naviguer librement, une notification apparaîtra.",
"toastSuccessSlides": "Cliquez sur Télécharger dans le panneau IA.",
"toastSuccessDiagram": "Votre diagramme est disponible dans le Lab."
},
"openAssistant": "Ouvrir IA Note",
"poweredByMomento": "Propulsé par Momento AI",
"welcomeMsg": "Bonjour ! Je suis votre assistant IA. Comment puis-je vous aider avec vos notes ? Je peux affiner le ton, développer un message ou résumer le contenu.",
@@ -1638,6 +1668,8 @@
"updated": "Agent mis à jour",
"deleted": "\"{name}\" supprimé",
"deleteError": "Erreur lors de la suppression",
"running": "Génération en cours…",
"runningDesc": "La génération peut prendre quelques minutes. Vous pouvez naviguer.",
"runSuccess": "\"{name}\" exécuté avec succès",
"runError": "Erreur : {error}",
"runFailed": "Exécution échouée",

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)