diff --git a/memento-note/app/api/canvas/download/route.ts b/memento-note/app/api/canvas/download/route.ts new file mode 100644 index 0000000..346518d --- /dev/null +++ b/memento-note/app/api/canvas/download/route.ts @@ -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 }) + } +} diff --git a/memento-note/app/api/canvas/slides/route.ts b/memento-note/app/api/canvas/slides/route.ts new file mode 100644 index 0000000..c47035e --- /dev/null +++ b/memento-note/app/api/canvas/slides/route.ts @@ -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' }, + }) + } +} diff --git a/memento-note/components/agents/agent-form.tsx b/memento-note/components/agents/agent-form.tsx index bf34d3f..364814a 100644 --- a/memento-note/components/agents/agent-form.tsx +++ b/memento-note/components/agents/agent-form.tsx @@ -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 @@ -62,6 +65,8 @@ const TOOL_PRESETS: Record = { 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(() => { + 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 )} - {/* Source Notebook (monitor only) */} + {/* Source Notebook (monitor, slide, excalidraw) */} {showSourceNotebook && (
setSlideTheme(e.target.value)} + className={selectCls} + > + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ {(['soft', 'sharp', 'rounded', 'pill'] as const).map(s => ( + + ))} +
+
+ + )} + + {/* Visual style selector — excalidraw-generator only */} + {type === 'excalidraw-generator' && ( + <> +
+ +
+ {([ + { 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) => ( + + ))} +
+
+
+ +
+ {([ + { id: 'default', labelKey: 'agents.form.excalidrawDiagramStyleDefault' }, + { id: 'sketch-plus', labelKey: 'agents.form.excalidrawDiagramStyleSketchPlus' }, + { id: 'austere', labelKey: 'agents.form.excalidrawDiagramStyleAustere' }, + ] as const).map((opt) => ( + + ))} +
+
+ + )} + + {/* Target Notebook — hidden for file generators (they never create notes) */} + {type !== 'slide-generator' && type !== 'excalidraw-generator' && (