Compare commits
20 Commits
0ebf10344d
...
ancien-ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d8252aec0 | ||
|
|
33ad874e5d | ||
|
|
3b72e4afbb | ||
|
|
ccc94a4b35 | ||
|
|
fb6823e25e | ||
|
|
7326cfc98f | ||
|
|
4f950740eb | ||
|
|
db200bbc9f | ||
|
|
08a49a04ce | ||
|
|
ab914f0587 | ||
|
|
b55f558a62 | ||
|
|
19d0b2759a | ||
|
|
34a977b5c4 | ||
|
|
75b08ef53b | ||
|
|
98e246e257 | ||
|
|
ff0b56f805 | ||
|
|
d1e08f64c8 | ||
|
|
e7f28abccc | ||
|
|
129d5541e6 | ||
|
|
21fb56de3f |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
140
memento-note/app/api/agents/run-for-note/route.ts
Normal file
140
memento-note/app/api/agents/run-for-note/route.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
47
memento-note/app/api/canvas/download/route.ts
Normal file
47
memento-note/app/api/canvas/download/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
70
memento-note/app/api/canvas/slides/route.ts
Normal file
70
memento-note/app/api/canvas/slides/route.ts
Normal 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' },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -919,6 +919,7 @@ export function NoteInlineEditor({
|
||||
noteTitle={title}
|
||||
noteContent={content}
|
||||
noteImages={allImages}
|
||||
noteId={note.id}
|
||||
onApplyToNote={(newContent) => {
|
||||
setPreviousContent(content)
|
||||
changeContent(newContent)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -9,7 +9,7 @@ export const toast = sonnerToast
|
||||
export function Toaster() {
|
||||
return (
|
||||
<SonnerToaster
|
||||
position="top-right"
|
||||
position="bottom-right"
|
||||
expand={false}
|
||||
richColors
|
||||
closeButton
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:  — 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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
1199
memento-note/lib/ai/tools/excalidraw.tool.ts
Normal file
1199
memento-note/lib/ai/tools/excalidraw.tool.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
|
||||
1170
memento-note/lib/ai/tools/pptx.tool.ts
Normal file
1170
memento-note/lib/ai/tools/pptx.tool.ts
Normal file
File diff suppressed because it is too large
Load Diff
736
memento-note/lib/ai/tools/slides.tool.ts
Normal file
736
memento-note/lib/ai/tools/slides.tool.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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
@@ -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 [
|
||||
|
||||
187
memento-note/package-lock.json
generated
187
memento-note/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 $$;
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user