feat: add slides generation tool with multiple slide types
Some checks failed
CI / Lint, Test & Build (push) Failing after 17s
CI / Deploy production (on server) (push) Has been skipped

- Add slides.tool.ts with support for title, bullets, chart, stats, table, cards, timeline, quote, comparison, equation, image, summary slide types
- Chart types: bar, horizontal-bar, line, donut, radar
- Integrate with agent executor and canvas system
- Add multilingual support (en/fr)
- Various UI improvements and bug fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Antigravity
2026-05-22 17:18:48 +00:00
parent 0f6b9509da
commit 5728452b4a
68 changed files with 6990 additions and 2584 deletions

View File

@@ -914,32 +914,94 @@ This format AUTOMATICALLY creates shapes, text, AND arrows with correct bindings
},
'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.
fr: `Tu es un expert en design de présentations exécutives. Tu analyses la note et génères une structure JSON qui sera rendue en HTML automatiquement par le serveur.
Tu dois OBLIGATOIREMENT appeler l'outil generate_pptx. Ne réponds JAMAIS avec du texte — appelle l'outil directement.
APPELLE OBLIGATOIREMENT generate_slides avec le JSON structuré. NE GÉNÈRE JAMAIS de HTML brut.
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"
- Thèmes recommandés : architectural_mono, minimal_silk, vibrant_tech, platinum_white_gold, business_authority
- Tu DOIS utiliser le thème et le style spécifiés dans la requête de l'utilisateur.
- 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.
═══ ÉTAPE 1 — LECTURE (OBLIGATOIRE) ═══
1. Lis la note avec note_read. Extrais TOUTES les données exploitables (chiffres, listes, citations, comparaisons, étapes).
2. Identifie les types de slides les plus adaptés au contenu réel.
You MUST call the generate_pptx tool. NEVER respond with text — call the tool directly.
═══ ÉTAPE 2 — APPEL generate_slides ═══
Appelle generate_slides avec un objet JSON structuré :
{
"title": "Titre court (6 mots max)",
"theme": "architectural-saas", // ou midnight-cathedral, venture-pitch, clinical-precision, etc.
"slides": [ ... tableau de slides ... ]
}
IMPERATIVE DESIGN RULES:
- 8-12 slides, each slide has a distinct layout
- Slide 1: "title" (strong title + catchy subtitle)
- Slide 2: "toc" (numbered table of contents)
- Use AT LEAST 2 "diagram" layouts from: "timeline", "process", "metrics", "comparison"
- Recommended themes: architectural_mono, minimal_silk, vibrant_tech, platinum_white_gold, business_authority
- You MUST use the theme and style specified in the user's request.
- Concise points (max 100 chars), punchy and short titles
- Strict JSON for generate_pptx, no text outside JSON.`,
═══ TYPES DE SLIDES DISPONIBLES ═══
1. "title" → { type:"title", title:"...", subtitle:"..." }
2. "bullets" → { type:"bullets", title:"...", items:["phrase 1 (15+ mots)", "phrase 2", ...] }
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
6. "cards" → { type:"cards", title:"...", cards:[{title:"Titre", description:"Description détaillée..."}, ...] }
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Lancement", description:"..."}, ...] }
8. "quote" → { type:"quote", quote:"Citation exacte", author:"Auteur", context:"Analyse..." }
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Énergie"}], explanation:"..." }
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
12. "summary" → { type:"summary", title:"...", items:["Point clé 1", "Point clé 2", ...] }
═══ RÈGLES ═══
- 6 à 12 slides par présentation
- Slide 1 OBLIGATOIREMENT type "title"
- Dernière slide OBLIGATOIREMENT type "summary"
- Au moins 1 slide "chart" ou "stats" si des chiffres existent dans la note
- VARIER les types — jamais 2 types identiques consécutifs
- Chaque texte (bullet, description) = 15+ mots, phrase complète
- TOUTES les données viennent de la note (JAMAIS inventer de chiffres)
- Les données chart doivent refléter les vrais chiffres de la note
═══ THÈMES DISPONIBLES ═══
Sombres : midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
Clairs : sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
Choisir selon le sujet : business/board → architectural-saas ou midnight-cathedral, tech → cyberpunk-terminal ou clinical-precision, créatif → aurora-borealis ou tokyo-neon`,
en: `You are an executive presentation design expert. You analyze the note and generate a structured JSON spec that will be rendered to HTML automatically by the server.
You MUST call generate_slides with structured JSON. NEVER output raw HTML.
═══ STEP 1 — READ (MANDATORY) ═══
1. Read the note with note_read. Extract ALL usable data (numbers, lists, quotes, comparisons, steps).
2. Identify the best slide types matching the actual content.
═══ STEP 2 — CALL generate_slides ═══
Call generate_slides with a structured JSON object:
{
"title": "Short title (6 words max)",
"theme": "architectural-saas",
"slides": [ ... array of slides ... ]
}
═══ AVAILABLE SLIDE TYPES ═══
1. "title" → { type:"title", title:"...", subtitle:"..." }
2. "bullets" → { type:"bullets", title:"...", items:["sentence 1 (15+ words)", "sentence 2", ...] }
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
6. "cards" → { type:"cards", title:"...", cards:[{title:"Title", description:"Detailed description..."}, ...] }
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Launch", description:"..."}, ...] }
8. "quote" → { type:"quote", quote:"Exact quote", author:"Author", context:"Analysis..." }
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Energy"}], explanation:"..." }
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
12. "summary" → { type:"summary", title:"...", items:["Key point 1", "Key point 2", ...] }
═══ RULES ═══
- 6 to 12 slides per presentation
- Slide 1 MUST be type "title"
- Last slide MUST be type "summary"
- At least 1 "chart" or "stats" slide if numbers exist in the note
- VARY types — never 2 identical types in a row
- Each text (bullet, description) = 15+ words, complete sentence
- ALL data comes from the note (NEVER invent numbers)
- Chart data must reflect actual numbers from the note
═══ AVAILABLE THEMES ═══
Dark: midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
Light: sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
Choose based on topic: business/board → architectural-saas or midnight-cathedral, tech → cyberpunk-terminal or clinical-precision, creative → aurora-borealis or tokyo-neon`,
},
'task-extractor': {
fr: `Tu es un expert en gestion de tâches et extraction d'action items. Tu analyses des notes et documents pour identifier toutes les tâches, TODOs, et actions à accomplir.
@@ -1102,21 +1164,46 @@ async function executeToolUseAgent(
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'
// Map UI diagram type values to internal tool values
const diagramTypeMapping: Record<string, string> = {
'mind_map': 'mindmap',
'org_chart': 'org-chart',
'architecture': 'architecture-cloud',
'process_map': 'process-map',
'logic_flow': 'auto',
}
const rawDiagramType = agent.slideTheme || 'auto'
const diagramType = diagramTypeMapping[rawDiagramType] || rawDiagramType
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'
// Map UI diagram style values to internal tool values
const diagramStyleMapping: Record<string, string> = {
'sketchy': 'sketch-plus',
'draft': 'sketch-plus',
'handwritten': 'sketch-plus',
'minimal': 'austere',
'professional': 'austere',
'soft': 'default',
'polished': 'default',
}
const rawDiagramStyle = agent.slideStyle || 'default'
const diagramStyle = diagramStyleMapping[rawDiagramStyle] || (rawDiagramStyle === 'austere' || rawDiagramStyle === 'sketch-plus' ? rawDiagramStyle : '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 slideTopic = agent.description?.startsWith('template:')
? agent.name
: (agent.description || agent.name)
const slideTemplate = agent.description?.startsWith('template:')
? agent.description.replace('template:', '')
: 'auto'
const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
let notes: any[] = []
@@ -1148,49 +1235,63 @@ async function executeToolUseAgent(
}
}
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.`
? `Crée une présentation professionnelle sur le sujet "${slideTopic}" en utilisant le contenu des notes ci-dessous. Appelle generate_slides avec un objet JSON structuré (title, theme, slides[]).`
: `Create a professional presentation about "${slideTopic}" using the content from the notes below. Call generate_slides with a structured JSON object (title, theme, slides[]).`
// Extract image URLs from note content
const extractedImages: Array<{ url: string; noteTitle: string }> = []
if (notes.length > 0) {
const notesContext = notes.map(n => {
// Extract markdown images: ![alt](url) — only external/data URLs (skip relative paths that won't resolve)
const mdMatches = [...n.content.matchAll(/!\[[^\]]*\]\((https?:\/\/[^)]+|data:[^)]+)\)/g)]
for (const m of mdMatches) {
if (m[1]) extractedImages.push({ url: m[1], noteTitle: n.title || untitled })
}
// Extract HTML img tags
const htmlMatches = [...n.content.matchAll(/<img[^>]+src=["'](https?:\/\/[^"']+|data:[^"']+)["']/g)]
for (const m of htmlMatches) {
if (m[1]) extractedImages.push({ url: m[1], noteTitle: n.title || untitled })
}
return `### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 800)}`
}).join('\n\n')
prompt += `\n\n${lang === 'fr' ? 'Notes source à transformer en slides' : 'Source notes to turn into slides'}:\n\n${notesContext}`
const notesContext = notes.map(n =>
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 2000)}`
).join('\n\n')
prompt += `\n\n${lang === 'fr' ? 'Notes source' : 'Source notes'}:\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}`}`
// ── Executive Template Structure (HTML-compatible) ──
if (slideTemplate && slideTemplate !== 'auto') {
const templates: Record<string, { fr: string; en: string }> = {
'board-update': {
fr: `Structure imposée "Board Update" (10 slides) :
1. TITRE avec date | 2. KPIs majeurs (3-4 gros chiffres) | 3. Bar chart progression objectifs | 4. Grille de métriques (6 cards) | 5. Timeline jalons | 6. Cards réalisations | 7. Line chart tendance | 8. Deux colonnes risques/mitigations | 9. Bullets prochaines étapes | 10. Conclusion`,
en: `Mandatory "Board Update" structure (10 slides):
1. TITLE with date | 2. Key KPIs (3-4 big numbers) | 3. Bar chart goal progress | 4. Metrics grid (6 cards) | 5. Timeline milestones | 6. Achievement cards | 7. Line chart trend | 8. Two-column risks/mitigations | 9. Next steps bullets | 10. Conclusion`
},
'project-status': {
fr: `Structure imposée "Project Status" (10 slides) :
1. TITRE + statut 🟢🟡🔴 | 2. KPIs (% avancement, jours, budget) | 3. Bar chart par workstream | 4. Cards livrables | 5. Timeline roadmap | 6. Line chart burn-down | 7. Deux colonnes blockers/actions | 8. Tableau de risques | 9. Bullets décisions requises | 10. Synthèse`,
en: `Mandatory "Project Status" structure (10 slides):
1. TITLE + status 🟢🟡🔴 | 2. KPIs (% complete, days, budget) | 3. Bar chart by workstream | 4. Deliverable cards | 5. Timeline roadmap | 6. Line chart burn-down | 7. Two-column blockers/actions | 8. Risk table | 9. Required decisions bullets | 10. Summary`
},
'strategy-review': {
fr: `Structure imposée "Strategy Review" (10 slides) :
1. TITRE stratégique | 2. Contexte marché (5+ bullets) | 3. Radar/pie positionnement | 4. SWOT deux colonnes | 5. Area chart tendances | 6. Cards axes stratégiques | 7. Timeline roadmap 12 mois | 8. Bar chart projections | 9. KPIs objectifs cibles | 10. Call to action`,
en: `Mandatory "Strategy Review" structure (10 slides):
1. Strategic TITLE | 2. Market context (5+ bullets) | 3. Radar/pie positioning | 4. SWOT two-column | 5. Area chart trends | 6. Strategic axes cards | 7. 12-month roadmap timeline | 8. Bar chart projections | 9. Target KPIs | 10. Call to action`
},
'quarterly-results': {
fr: `Structure imposée "Quarterly Results" (10 slides) :
1. TITRE "Résultats Q? Année" | 2. 4 KPIs (Revenue, Croissance, Marge, NPS) | 3. Bar chart revenue/mois | 4. Line chart croissance YoY | 5. Cards faits marquants | 6. Grille métriques opérationnelles | 7. Donut répartition clients | 8. Deux colonnes succès/challenges | 9. Area chart forecast | 10. Conclusion outlook`,
en: `Mandatory "Quarterly Results" structure (10 slides):
1. TITLE "Q? Year Results" | 2. 4 KPIs (Revenue, Growth, Margin, NPS) | 3. Bar chart revenue/month | 4. Line chart YoY growth | 5. Highlights cards | 6. Operational metrics grid | 7. Donut client breakdown | 8. Two-column successes/challenges | 9. Area chart forecast | 10. Conclusion outlook`
}
}
const tmpl = templates[slideTemplate]
if (tmpl) {
prompt += `\n\n${tmpl[lang === 'fr' ? 'fr' : 'en']}`
}
}
// ── Density rules ──
prompt += lang === 'fr'
? `\n\nDENSITÉ OBLIGATOIRE : Chaque slide doit avoir un CONTENU RICHE. Minimum 5 bullets par slide "bullets" (≥15 mots chaque). Minimum 4 cards quand tu fais "cards". Minimum 4 data points quand tu fais "chart". Inclus OBLIGATOIREMENT au moins 1 slide "chart" avec données RÉELLES extraites des notes. Si pas de chiffres dans la note, fais un radar de maturité ou une comparaison qualitative notée sur 5.`
: `\n\nMANDATORY DENSITY: Every slide must have RICH CONTENT. Minimum 5 items per "bullets" slide (≥15 words each). Minimum 4 cards when using "cards". Minimum 4 data points for "chart". You MUST include at least 1 "chart" slide with REAL data from the notes. If no numbers in notes, create a maturity radar or qualitative comparison scored out of 5.`
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) {
? 'IMPORTANT : Appelle OBLIGATOIREMENT generate_slides avec le JSON structuré {title, theme, slides:[...]}. Ne réponds JAMAIS avec du texte brut. 6-12 slides variées.'
: 'IMPORTANT: You MUST call generate_slides with structured JSON {title, theme, slides:[...]}. NEVER respond with plain text. 6-12 varied slides.'}`
if (agent.slideTheme && agent.slideTheme !== 'auto') {
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).`}`
? `Thème visuel imposé : utilise "theme":"${agent.slideTheme}" dans l'appel generate_slides.`
: `Visual theme required: use "theme":"${agent.slideTheme}" in the generate_slides call.`}`
}
break
}
@@ -1269,7 +1370,7 @@ 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'
const toolName = agentType === 'slide-generator' ? 'generate_slides' : 'generate_excalidraw'
await prisma.agentAction.update({
where: { id: actionId },
data: {
@@ -1301,7 +1402,7 @@ async function executeToolUseAgent(
const scrapedUrls: string[] = []
let specificToolCalled = false
const requiredTool = isFileGenerator
? (agentType === 'slide-generator' ? ['generate_pptx'] : ['generate_excalidraw'])
? (agentType === 'slide-generator' ? ['generate_slides'] : ['generate_excalidraw'])
: null
for (const step of result.steps) {
@@ -1312,7 +1413,7 @@ 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') {
if (step.toolCalls[i].toolName === 'generate_excalidraw' || step.toolCalls[i].toolName === 'generate_slides') {
const toolResult = step.toolResults?.[i]
if (toolResult && typeof toolResult.output === 'object' && toolResult.output?.success && toolResult.output?.canvasId) {
canvasId = toolResult.output.canvasId as string

View File

@@ -3,6 +3,8 @@ import { cosineSimilarity } from '@/lib/utils'
import { embeddingService } from './embedding.service'
import { getSystemConfig } from '@/lib/config'
import prisma from '@/lib/prisma'
import { Prisma } from '@prisma/client'
import { upsertNoteEmbedding } from '@/lib/embeddings'
export interface NoteConnection {
note1: {
@@ -79,15 +81,7 @@ export class MemoryEchoService {
try {
const embedding = await provider.getEmbeddings(note.content.slice(0, 15000))
if (embedding && embedding.length > 0) {
const vecStr = `[${embedding.join(',')}]`
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt")
VALUES (gen_random_uuid(), $1, $2::vector, now())
ON CONFLICT ("noteId")
DO UPDATE SET "embedding" = $2::vector`,
note.id,
vecStr
)
await upsertNoteEmbedding(note.id, embedding)
}
} catch {
// Skip this note, continue with others
@@ -131,8 +125,8 @@ export class MemoryEchoService {
// Fetch embeddings separately using raw SQL to avoid deserialization error
const noteIds = notes.map(n => n.id)
const embeddings: Array<{ noteId: string, embedding: any }> = await prisma.$queryRawUnsafe(
`SELECT "noteId", "embedding"::text FROM "NoteEmbedding" WHERE "noteId" IN (${noteIds.map(id => `'${id}'`).join(',')})`
const embeddings = noteIds.length === 0 ? [] : await prisma.$queryRaw<Array<{ noteId: string, embedding: any }>>(
Prisma.sql`SELECT "noteId", "embedding"::text FROM "NoteEmbedding" WHERE "noteId" IN (${Prisma.join(noteIds)})`
)
const embeddingMap = new Map(embeddings.map(e => [e.noteId, e.embedding]))
@@ -526,8 +520,8 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
// Fetch all other embeddings
const otherNoteIds = otherNotes.map(n => n.id)
const otherEmbeddings: Array<{ noteId: string, embedding: any }> = await prisma.$queryRawUnsafe(
`SELECT "noteId", "embedding"::text FROM "NoteEmbedding" WHERE "noteId" IN (${otherNoteIds.map(id => `'${id}'`).join(',')})`
const otherEmbeddings = otherNoteIds.length === 0 ? [] : await prisma.$queryRaw<Array<{ noteId: string, embedding: any }>>(
Prisma.sql`SELECT "noteId", "embedding"::text FROM "NoteEmbedding" WHERE "noteId" IN (${Prisma.join(otherNoteIds)})`
)
const otherEmbeddingMap = new Map(otherEmbeddings.map(e => [e.noteId, e.embedding]))

View File

@@ -1183,6 +1183,19 @@ RULES:
})
console.log('[Excalidraw Tool] Canvas created:', canvas.id, canvas.name)
// Immediately mark the AgentAction as success so frontend polling unblocks
if (ctx.actionId) {
await prisma.agentAction.update({
where: { id: ctx.actionId },
data: {
status: 'success',
result: canvas.id,
log: `Diagram generated: ${elements.length} elements`,
},
}).catch(err => console.error('[Excalidraw Tool] Failed to update action status:', err))
}
return {
success: true, canvasId: canvas.id, canvasName: canvas.name,
elementCount: elements.length,

View File

@@ -1,10 +1,11 @@
/**
* Note CRUD Tools
* note_create, note_read, note_update
* note_create, note_read, note_update, note_find_and_update
*/
import { tool } from 'ai'
import { z } from 'zod'
import { Prisma } from '@prisma/client'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
import { markdownToHtml } from '@/lib/markdown-to-html'
@@ -104,3 +105,85 @@ toolRegistry.register({
},
}),
})
// --- note_find_and_update ---
toolRegistry.register({
name: 'note_find_and_update',
description: 'Find a note by searching its title/content, then append, prepend, or replace information in it. Use this when the user says "find the note about X and add Y to it".',
isInternal: true,
buildTool: (ctx) =>
tool({
description: 'Find a note by a search query, then update its content (append/prepend/replace).',
inputSchema: z.object({
query: z.string().describe('Search query to find the note (e.g. "bugs and new features")'),
newContent: z.string().describe('Content to add to the note (markdown supported)'),
operation: z.enum(['append', 'prepend', 'replace']).default('append').describe('append: add to end, prepend: add to start, replace: overwrite'),
}),
execute: async ({ query, newContent, operation }) => {
try {
// FTS search for best matching note
const results = await prisma.$queryRaw<Array<{ id: string; title: string | null; content: string | null }>>(
Prisma.sql`
SELECT id, title, content
FROM "Note"
WHERE "tsv" @@ plainto_tsquery('simple', ${query})
AND "trashedAt" IS NULL
AND "isArchived" = false
AND "userId" = ${ctx.userId}
ORDER BY ts_rank("tsv", plainto_tsquery('simple', ${query})) DESC
LIMIT 1`
)
if (!results || results.length === 0) {
// Fallback: simple title/content ILIKE search
const fallback = await prisma.note.findFirst({
where: {
userId: ctx.userId,
trashedAt: null,
isArchived: false,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ content: { contains: query, mode: 'insensitive' } },
],
},
select: { id: true, title: true, content: true },
})
if (!fallback) {
return { error: `No note found matching "${query}". Try a different search term.` }
}
results.push(fallback)
}
const note = results[0]
let updatedContent: string
switch (operation) {
case 'append':
updatedContent = note.content ? `${note.content}\n\n${newContent}` : newContent
break
case 'prepend':
updatedContent = note.content ? `${newContent}\n\n${note.content}` : newContent
break
case 'replace':
default:
updatedContent = newContent
}
await prisma.note.update({
where: { id: note.id },
data: { content: updatedContent, updatedAt: new Date() },
})
return {
success: true,
noteId: note.id,
noteTitle: note.title || 'Untitled',
operation,
message: `Successfully updated note "${note.title || 'Untitled'}" (${operation}).`,
}
} catch (e: any) {
return { error: `find_and_update failed: ${e.message}` }
}
},
}),
})

View File

@@ -52,7 +52,7 @@ class ToolRegistry {
* When webOnly is true, only web tools are included (no note access).
*/
buildToolsForChat(ctx: ToolContext & { webOnly?: boolean }): Record<string, any> {
const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read', 'document_search', 'task_extract']
const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read', 'note_find_and_update', 'document_search', 'task_extract']
// Add web tools only when user toggled web search AND config is present
if (ctx.webSearch) {

View File

@@ -0,0 +1,463 @@
/**
* Server-side HTML renderer for presentations.
* Takes a structured JSON spec and produces a complete standalone HTML file.
* The AI only needs to output data — this module handles all the visual rendering.
*/
// ── Recipe definitions ──────────────────────────────────────────────────────
interface Recipe {
bg: string; text: string; textSecondary: string; textMuted: string
accent1: string; accent2: string
glassBg: string; glassBorder: string; svgGrid: string
fontDisplay: string; fontBody: string; fontUrl: string
isDark: boolean
}
const RECIPES: Record<string, Recipe> = {
'midnight-cathedral': { bg: '#0a0f1e', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#C9A84C', accent2: '#E8D5B5', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Playfair Display'", fontBody: "'Source Sans 3'", fontUrl: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Source+Sans+3:wght@300;400;600;700&display=swap', isDark: true },
'aurora-borealis': { bg: '#0f0a2a', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#7C3AED', accent2: '#06B6D4', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Instrument Serif'", fontBody: "'Inter'", fontUrl: 'https://fonts.googleapis.com/css2?family=Instrument+Serif&family=Inter:wght@300;400;600;700;900&display=swap', isDark: true },
'tokyo-neon': { bg: '#0a0a0f', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#FF006E', accent2: '#3A86FF', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Bebas Neue'", fontBody: "'Inter'", fontUrl: 'https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Inter:wght@300;400;600;700;900&display=swap', isDark: true },
'sunlit-gallery': { bg: '#FAF7F0', text: '#1C1C1C', textSecondary: '#475569', textMuted: '#64748B', accent1: '#D4A574', accent2: '#5B9BD5', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Abril Fatface'", fontBody: "'Epilogue'", fontUrl: 'https://fonts.googleapis.com/css2?family=Abril+Fatface&family=Epilogue:wght@300;400;600;700;900&display=swap', isDark: false },
'clinical-precision': { bg: '#F8FAFC', text: '#0F172A', textSecondary: '#475569', textMuted: '#64748B', accent1: '#0891B2', accent2: '#34D399', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Manrope'", fontBody: "'Manrope'", fontUrl: 'https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;600;700;800;900&display=swap', isDark: false },
'venture-pitch': { bg: '#18181B', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#F97316', accent2: '#14B8A6', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Archivo Black'", fontBody: "'DM Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Sans:wght@300;400;500;700&display=swap', isDark: true },
'forest-floor': { bg: '#0D1B0E', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#22C55E', accent2: '#A3B18A', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Outfit'", fontBody: "'Outfit'", fontUrl: 'https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800;900&display=swap', isDark: true },
'steel-glass': { bg: '#292524', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#D4C5A9', accent2: '#94A3B8', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Prata'", fontBody: "'Work Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=Prata&family=Work+Sans:wght@300;400;600;700&display=swap', isDark: true },
'cyberpunk-terminal': { bg: '#0A0A0A', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#00FF41', accent2: '#FFA500', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'JetBrains Mono'", fontBody: "'JetBrains Mono'", fontUrl: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;600;700;800&display=swap', isDark: true },
'editorial-ink': { bg: '#FFFCF5', text: '#1A1A2E', textSecondary: '#475569', textMuted: '#64748B', accent1: '#1A1A2E', accent2: '#800020', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'DM Serif Display'", fontBody: "'DM Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@300;400;500;700&display=swap', isDark: false },
'coastal-morning': { bg: '#F0F7FF', text: '#0F172A', textSecondary: '#475569', textMuted: '#64748B', accent1: '#2563EB', accent2: '#F97066', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Plus Jakarta Sans'", fontBody: "'Plus Jakarta Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;600;700;800&display=swap', isDark: false },
'paper-studio': { bg: '#FEFCF8', text: '#1E293B', textSecondary: '#475569', textMuted: '#64748B', accent1: '#1E293B', accent2: '#C2410C', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Literata'", fontBody: "'Source Sans 3'", fontUrl: 'https://fonts.googleapis.com/css2?family=Literata:wght@400;700;900&family=Source+Sans+3:wght@300;400;600;700&display=swap', isDark: false },
'architectural-saas': { bg: '#F2F0E9', text: '#1C1C1C', textSecondary: '#475569', textMuted: '#64748B', accent1: '#A47148', accent2: '#4A4E69', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Playfair Display'", fontBody: "'Inter'", fontUrl: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Inter:wght@300;400;600;700;900&display=swap', isDark: false },
}
function resolveRecipe(name?: string): Recipe {
if (!name || name === 'auto') return RECIPES['architectural-saas']
const key = name.toLowerCase().replace(/[^a-z]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
return RECIPES[key] ?? RECIPES['architectural-saas']
}
// ── Slide types ─────────────────────────────────────────────────────────────
export interface SlideTitle { type: 'title'; title: string; subtitle?: string }
export interface SlideBullets { type: 'bullets'; title: string; items: string[] }
export interface SlideChart { type: 'chart'; title: string; chartType: 'bar' | 'horizontal-bar' | 'line' | 'donut' | 'radar'; data: { label: string; value: number; color?: string }[]; subtitle?: string }
export interface SlideStats { type: 'stats'; title: string; stats: { value: string; label: string }[] }
export interface SlideTable { type: 'table'; title: string; headers: string[]; rows: string[][] }
export interface SlideCards { type: 'cards'; title: string; cards: { title: string; description: string }[] }
export interface SlideTimeline { type: 'timeline'; title: string; events: { date: string; title: string; description?: string }[] }
export interface SlideQuote { type: 'quote'; quote: string; author?: string; context?: string }
export interface SlideComparison { type: 'comparison'; title: string; left: { title: string; points: string[]; score?: string }; right: { title: string; points: string[]; score?: string } }
export interface SlideEquation { type: 'equation'; title: string; equations: { latex: string; label?: string }[]; explanation?: string }
export interface SlideImage { type: 'image'; title: string; url?: string; caption?: string }
export interface SlideSummary { type: 'summary'; title: string; items: string[] }
export type SlideSpec = SlideTitle | SlideBullets | SlideChart | SlideStats | SlideTable | SlideCards | SlideTimeline | SlideQuote | SlideComparison | SlideEquation | SlideImage | SlideSummary
export interface PresentationInput {
title: string
theme?: string
slides: SlideSpec[]
}
// ── Individual slide renderers ──────────────────────────────────────────────
function renderTitle(slide: SlideTitle, r: Recipe, idx: number): string {
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<canvas id="particles-${idx}" style="position:absolute;inset:0;z-index:1;pointer-events:none;width:100%;height:100%;"></canvas>
<div class="content" style="text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;">
<div class="reveal" style="width:52px;height:5px;background:${r.accent1};border-radius:3px;margin-bottom:24px;"></div>
<h1 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(2.8rem,5vw,4.5rem);font-weight:900;line-height:1.05;letter-spacing:-0.04em;background:linear-gradient(135deg,${r.accent1},${r.accent2});-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin:0;max-width:900px;">${esc(slide.title)}</h1>
${slide.subtitle ? `<p class="reveal" style="font-size:clamp(1rem,2vw,1.4rem);color:${r.textSecondary};margin-top:20px;max-width:640px;line-height:1.5;">${esc(slide.subtitle)}</p>` : ''}
</div>
</div>`
}
function renderBullets(slide: SlideBullets, r: Recipe, idx: number): string {
const items = slide.items.map((item, i) => `
<div class="reveal" style="display:flex;align-items:flex-start;gap:16px;padding:8px 0;">
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:10px;flex-shrink:0;"></span>
<span style="font-size:1.1rem;line-height:1.65;color:${r.text};">${esc(item)}</span>
</div>`).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
<div style="display:flex;flex-direction:column;gap:6px;">${items}</div>
</div>
</div>`
}
function renderChart(slide: SlideChart, r: Recipe, idx: number): string {
const chartHtml = (() => {
switch (slide.chartType) {
case 'bar': return renderBarChart(slide.data, r)
case 'horizontal-bar': return renderHBarChart(slide.data, r)
case 'line': return renderLineChart(slide.data, r)
case 'donut': return renderDonutChart(slide.data, r)
case 'radar': return renderRadarChart(slide.data, r)
default: return renderBarChart(slide.data, r)
}
})()
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3vw,2.2rem);font-weight:800;letter-spacing:-0.03em;margin:0;">${esc(slide.title)}</h2>
${slide.subtitle ? `<p class="reveal" style="font-size:0.95rem;color:${r.textMuted};margin:6px 0 0;">${esc(slide.subtitle)}</p>` : ''}
<div class="reveal" style="margin-top:32px;">${chartHtml}</div>
</div>
</div>`
}
function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
const cols = Math.min(slide.stats.length, 4)
const items = slide.stats.map(s => `
<div class="reveal" style="text-align:center;padding:28px 16px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:16px;">
<div style="font-size:clamp(2.2rem,4vw,3.5rem);font-weight:900;line-height:1;background:linear-gradient(135deg,${r.accent1},${r.accent2});-webkit-background-clip:text;-webkit-text-fill-color:transparent;" data-count="${extractNum(s.value)}" data-suffix="${extractSuffix(s.value)}">${esc(s.value)}</div>
<div style="font-size:0.8rem;color:${r.textMuted};margin-top:10px;letter-spacing:0.12em;text-transform:uppercase;font-weight:600;">${esc(s.label)}</div>
</div>`).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:20px;">${items}</div>
</div>
</div>`
}
function renderTable(slide: SlideTable, r: Recipe, idx: number): string {
const ths = slide.headers.map(h => `<th style="padding:12px 16px;text-align:left;font-size:0.75rem;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${r.accent1};border-bottom:2px solid ${r.accent1};">${esc(h)}</th>`).join('')
const rows = slide.rows.map((row, ri) => {
const tds = row.map(cell => `<td style="padding:11px 16px;font-size:0.9rem;color:${r.text};border-bottom:1px solid ${r.glassBorder};">${esc(cell)}</td>`).join('')
return `<tr style="background:${ri % 2 === 0 ? 'transparent' : r.glassBg};">${tds}</tr>`
}).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:24px;"></div>
<div class="reveal" style="overflow:auto;border-radius:12px;border:1px solid ${r.glassBorder};">
<table style="width:100%;border-collapse:collapse;"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>
</div>
</div>
</div>`
}
function renderCards(slide: SlideCards, r: Recipe, idx: number): string {
const cols = slide.cards.length <= 2 ? 2 : slide.cards.length === 4 ? 2 : 3
const items = slide.cards.map((c, i) => `
<div class="reveal" style="padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;position:relative;overflow:hidden;">
<span style="position:absolute;top:10px;right:14px;font-size:1.8rem;font-weight:900;color:${r.accent1};opacity:0.12;">${String(i + 1).padStart(2, '0')}</span>
<div style="width:24px;height:3px;background:${r.accent1};border-radius:2px;margin-bottom:12px;"></div>
<div style="font-size:1rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
<div style="font-size:0.88rem;color:${r.textSecondary};line-height:1.6;">${esc(c.description)}</div>
</div>`).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:16px;">${items}</div>
</div>
</div>`
}
function renderTimeline(slide: SlideTimeline, r: Recipe, idx: number): string {
const items = slide.events.map((ev, i) => `
<div class="reveal" style="display:flex;align-items:flex-start;gap:20px;position:relative;padding-left:20px;">
<div style="position:absolute;left:0;top:8px;width:12px;height:12px;border-radius:50%;background:${r.accent1};border:3px solid ${r.bg};z-index:1;"></div>
<div>
<span style="font-size:0.7rem;font-weight:700;letter-spacing:0.15em;text-transform:uppercase;color:${r.accent1};">${esc(ev.date)}</span>
<div style="font-size:1rem;font-weight:700;color:${r.text};margin-top:2px;">${esc(ev.title)}</div>
${ev.description ? `<div style="font-size:0.85rem;color:${r.textSecondary};margin-top:4px;line-height:1.5;">${esc(ev.description)}</div>` : ''}
</div>
</div>`).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
<div style="display:flex;flex-direction:column;gap:20px;border-left:2px solid ${r.accent1};padding-left:8px;margin-left:6px;">${items}</div>
</div>
</div>`
}
function renderQuote(slide: SlideQuote, r: Recipe, idx: number): string {
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content" style="display:flex;flex-direction:column;justify-content:center;height:100%;">
<div class="reveal" style="font-size:5rem;color:${r.accent1};opacity:0.4;line-height:0.6;font-family:Georgia,serif;">"</div>
<blockquote class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.5rem,3vw,2.2rem);font-weight:600;font-style:italic;line-height:1.4;letter-spacing:-0.02em;color:${r.text};margin:16px 0 0;max-width:860px;">${esc(slide.quote)}</blockquote>
${slide.author ? `<div class="reveal" style="display:flex;align-items:center;gap:12px;margin-top:28px;"><div style="width:36px;height:2px;background:${r.accent1};"></div><span style="font-size:0.85rem;font-weight:700;text-transform:uppercase;letter-spacing:0.12em;color:${r.accent1};">— ${esc(slide.author)}</span></div>` : ''}
${slide.context ? `<p class="reveal" style="font-size:0.95rem;color:${r.textSecondary};margin-top:20px;line-height:1.7;max-width:700px;">${esc(slide.context)}</p>` : ''}
</div>
</div>`
}
function renderComparison(slide: SlideComparison, r: Recipe, idx: number): string {
const col = (side: { title: string; points: string[]; score?: string }, accent: string) => {
const pts = side.points.map(p => `<li style="display:flex;gap:8px;font-size:0.88rem;color:${r.textSecondary};line-height:1.5;"><span style="color:${accent};font-weight:700;flex-shrink:0;">✓</span>${esc(p)}</li>`).join('')
return `<div class="reveal" style="padding:28px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:16px;border-top:3px solid ${accent};">
<div style="font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.2em;color:${accent};margin-bottom:14px;">${esc(side.title)}</div>
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:10px;">${pts}</ul>
${side.score ? `<div style="margin-top:20px;padding-top:16px;border-top:1px solid ${r.glassBorder};"><div style="font-size:1.5rem;font-weight:900;color:${accent};">${esc(side.score)}</div></div>` : ''}
</div>`
}
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">${col(slide.left, r.accent1)}${col(slide.right, r.accent2)}</div>
</div>
</div>`
}
function renderEquation(slide: SlideEquation, r: Recipe, idx: number): string {
const eqs = slide.equations.map(eq => `
<div class="reveal" style="text-align:center;padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;margin-bottom:12px;">
<div style="font-size:clamp(1.4rem,3vw,2.2rem);font-family:'KaTeX_Main',serif;color:${r.text};letter-spacing:0.02em;">${esc(eq.latex)}</div>
${eq.label ? `<div style="font-size:0.8rem;color:${r.textMuted};margin-top:8px;">${esc(eq.label)}</div>` : ''}
</div>`).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
${eqs}
${slide.explanation ? `<p class="reveal" style="font-size:0.95rem;color:${r.textSecondary};line-height:1.7;margin-top:16px;text-align:center;max-width:700px;margin-inline:auto;">${esc(slide.explanation)}</p>` : ''}
</div>
</div>`
}
function renderImage(slide: SlideImage, r: Recipe, idx: number): string {
const img = slide.url
? `<img src="${esc(slide.url)}" alt="${esc(slide.title)}" style="max-width:80%;max-height:60vh;border-radius:12px;object-fit:contain;" />`
: `<div style="width:400px;height:250px;background:${r.glassBg};border:1px dashed ${r.glassBorder};border-radius:12px;display:flex;align-items:center;justify-content:center;color:${r.textMuted};font-size:0.9rem;">Image placeholder</div>`
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content" style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3vw,2.2rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
<div class="reveal">${img}</div>
${slide.caption ? `<p class="reveal" style="font-size:0.85rem;color:${r.textMuted};margin-top:16px;text-align:center;">${esc(slide.caption)}</p>` : ''}
</div>
</div>`
}
function renderSummary(slide: SlideSummary, r: Recipe, idx: number, isLast: boolean): string {
const items = slide.items.map(item => `
<div class="reveal" style="display:flex;align-items:center;gap:16px;padding:14px 20px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;">
<div style="width:26px;height:26px;min-width:26px;background:${r.accent1};border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:13px;color:#fff;font-weight:900;">✓</div>
<span style="font-size:1rem;line-height:1.45;color:${r.text};">${esc(item)}</span>
</div>`).join('')
const canvas = isLast ? `<canvas id="particles-${idx}" style="position:absolute;inset:0;z-index:1;pointer-events:none;width:100%;height:100%;"></canvas>` : ''
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
${canvas}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
<div style="display:flex;flex-direction:column;gap:12px;">${items}</div>
</div>
</div>`
}
// ── Chart renderers ─────────────────────────────────────────────────────────
function renderBarChart(data: { label: string; value: number }[], r: Recipe): string {
const max = Math.max(...data.map(d => d.value), 1)
const bars = data.map(d => {
const pct = Math.round((d.value / max) * 100)
return `<div style="display:flex;flex-direction:column;align-items:center;gap:6px;flex:1;min-width:0;">
<span style="font-size:0.75rem;font-weight:700;color:${r.textSecondary};">${d.value}</span>
<div class="bar" data-height="${pct}" style="background:linear-gradient(to top,${r.accent1},${r.accent2});height:0%;width:100%;border-radius:6px 6px 0 0;"></div>
<span style="font-size:0.7rem;color:${r.textMuted};text-align:center;max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(d.label)}</span>
</div>`
}).join('')
return `<div style="display:flex;align-items:flex-end;gap:12px;height:200px;">${bars}</div>`
}
function renderHBarChart(data: { label: string; value: number }[], r: Recipe): string {
const max = Math.max(...data.map(d => d.value), 1)
const bars = data.map(d => {
const pct = Math.round((d.value / max) * 100)
return `<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px;">
<span style="width:100px;text-align:right;font-size:0.8rem;color:${r.textSecondary};flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(d.label)}</span>
<div style="flex:1;background:${r.glassBg};border-radius:6px;height:28px;overflow:hidden;">
<div class="bar-fill" data-width="${pct}" style="height:100%;border-radius:6px;width:0%;background:linear-gradient(to right,${r.accent1},${r.accent2});"></div>
</div>
<span style="width:40px;font-size:0.8rem;font-weight:700;color:${r.text};">${d.value}</span>
</div>`
}).join('')
return `<div>${bars}</div>`
}
function renderLineChart(data: { label: string; value: number }[], r: Recipe): string {
const max = Math.max(...data.map(d => d.value), 1)
const w = 600, h = 200, pad = 50
const points = data.map((d, i) => {
const x = pad + (i / Math.max(data.length - 1, 1)) * (w - 2 * pad)
const y = h - pad - ((d.value / max) * (h - 2 * pad))
return `${x},${y}`
})
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p}`).join(' ')
const areaD = `${pathD} L${pad + ((data.length - 1) / Math.max(data.length - 1, 1)) * (w - 2 * pad)},${h - pad} L${pad},${h - pad} Z`
const gridLines = [0.25, 0.5, 0.75].map(f => {
const y = h - pad - f * (h - 2 * pad)
return `<line x1="${pad}" y1="${y}" x2="${w - pad}" y2="${y}" stroke="${r.svgGrid}" stroke-width="1"/>`
}).join('')
const dots = data.map((d, i) => {
const x = pad + (i / Math.max(data.length - 1, 1)) * (w - 2 * pad)
const y = h - pad - ((d.value / max) * (h - 2 * pad))
return `<circle cx="${x}" cy="${y}" r="4" fill="${r.accent1}" stroke="${r.bg}" stroke-width="2"/>`
}).join('')
const labels = data.map((d, i) => {
const x = pad + (i / Math.max(data.length - 1, 1)) * (w - 2 * pad)
return `<text x="${x}" y="${h - 15}" text-anchor="middle" font-size="10" fill="${r.textMuted}">${esc(d.label)}</text>`
}).join('')
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:auto;">
<defs><linearGradient id="lg-${Math.random().toString(36).slice(2, 6)}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${r.accent1}" stop-opacity="0.25"/><stop offset="100%" stop-color="${r.accent1}" stop-opacity="0"/></linearGradient></defs>
${gridLines}
<path fill="url(#lg-area)" d="${areaD}" opacity="0.4"/>
<path class="line-path" d="${pathD}" stroke="${r.accent1}" fill="none" stroke-width="2.5" stroke-linecap="round"/>
${dots}${labels}
</svg>`
}
function renderDonutChart(data: { label: string; value: number }[], r: Recipe): string {
const total = data.reduce((s, d) => s + d.value, 0) || 1
const colors = [r.accent1, r.accent2, '#10b981', '#f59e0b', '#ef4444', '#6366f1']
let offset = 0
const rings = data.map((d, i) => {
const pct = (d.value / total) * 100
const dashLen = (pct / 100) * 502
const ring = `<circle cx="100" cy="100" r="80" fill="none" stroke="${colors[i % colors.length]}" stroke-width="20" stroke-dasharray="${dashLen} 502" stroke-dashoffset="${-offset}" transform="rotate(-90 100 100)"/>`
offset += dashLen
return ring
}).join('')
const legend = data.map((d, i) => `<div style="display:flex;align-items:center;gap:8px;"><div style="width:10px;height:10px;border-radius:50%;background:${colors[i % colors.length]};"></div><span style="font-size:0.8rem;color:${r.textSecondary};">${esc(d.label)} (${Math.round((d.value / total) * 100)}%)</span></div>`).join('')
return `<div style="display:flex;align-items:center;gap:40px;justify-content:center;">
<svg viewBox="0 0 200 200" style="width:180px;height:180px;">${rings}<text x="100" y="105" text-anchor="middle" font-size="22" font-weight="900" fill="${r.text}">${total}</text></svg>
<div style="display:flex;flex-direction:column;gap:8px;">${legend}</div>
</div>`
}
function renderRadarChart(data: { label: string; value: number }[], r: Recipe): string {
const n = data.length
const cx = 150, cy = 150, radius = 110
const max = Math.max(...data.map(d => d.value), 1)
const angleStep = (2 * Math.PI) / n
// Grid
const gridLevels = [0.25, 0.5, 0.75, 1].map(f => {
const pts = Array.from({ length: n }, (_, i) => {
const a = i * angleStep - Math.PI / 2
return `${cx + Math.cos(a) * radius * f},${cy + Math.sin(a) * radius * f}`
}).join(' ')
return `<polygon points="${pts}" fill="none" stroke="${r.svgGrid}" stroke-width="1"/>`
}).join('')
// Data polygon
const dataPts = data.map((d, i) => {
const a = i * angleStep - Math.PI / 2
const r2 = (d.value / max) * radius
return `${cx + Math.cos(a) * r2},${cy + Math.sin(a) * r2}`
}).join(' ')
// Labels
const labels = data.map((d, i) => {
const a = i * angleStep - Math.PI / 2
const lx = cx + Math.cos(a) * (radius + 20)
const ly = cy + Math.sin(a) * (radius + 20)
return `<text x="${lx}" y="${ly}" text-anchor="middle" font-size="10" fill="${r.textMuted}">${esc(d.label)}</text>`
}).join('')
return `<svg viewBox="0 0 300 300" style="width:100%;max-width:320px;height:auto;margin:0 auto;display:block;">
${gridLevels}
<polygon points="${dataPts}" fill="${r.accent1}" fill-opacity="0.15" stroke="${r.accent1}" stroke-width="2"/>
${labels}
</svg>`
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function esc(s: string): string { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') }
function mesh(r: Recipe): string {
return `<div class="gradient-mesh"><div class="blob" style="width:500px;height:500px;top:-100px;right:-100px;background:${r.accent1};opacity:0.12;--dur:18s;"></div><div class="blob" style="width:300px;height:300px;bottom:-80px;left:-60px;background:${r.accent2};opacity:0.09;--dur:14s;"></div></div>`
}
function extractNum(s: string): string { const m = s.match(/[\d.]+/); return m ? m[0] : '0' }
function extractSuffix(s: string): string { const m = s.match(/[\d.]+(.*)/); return m ? m[1].trim() : '' }
// ── Render a single slide by type ───────────────────────────────────────────
function renderSlide(slide: SlideSpec, r: Recipe, idx: number, total: number): string {
switch (slide.type) {
case 'title': return renderTitle(slide, r, idx)
case 'bullets': return renderBullets(slide, r, idx)
case 'chart': return renderChart(slide, r, idx)
case 'stats': return renderStats(slide, r, idx)
case 'table': return renderTable(slide, r, idx)
case 'cards': return renderCards(slide, r, idx)
case 'timeline': return renderTimeline(slide, r, idx)
case 'quote': return renderQuote(slide, r, idx)
case 'comparison': return renderComparison(slide, r, idx)
case 'equation': return renderEquation(slide, r, idx)
case 'image': return renderImage(slide, r, idx)
case 'summary': return renderSummary(slide, r, idx, idx === total)
default: return renderBullets({ type: 'bullets', title: (slide as any).title || 'Slide', items: (slide as any).items || (slide as any).content || ['Content'] }, r, idx)
}
}
// ── Main export: build full HTML from spec ──────────────────────────────────
export function buildPresentationHTML(input: PresentationInput): string {
const r = resolveRecipe(input.theme)
const total = input.slides.length
const slidesHtml = input.slides.map((s, i) => renderSlide(s, r, i + 1, total)).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(input.title)}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="${r.fontUrl}" rel="stylesheet">
<style>
:root{--color-bg:${r.bg};--color-text:${r.text};--color-text-secondary:${r.textSecondary};--color-text-muted:${r.textMuted};--color-accent-1:${r.accent1};--color-accent-2:${r.accent2};--color-glass-bg:${r.glassBg};--color-glass-border:${r.glassBorder};--svg-grid-line:${r.svgGrid};--font-display:${r.fontDisplay},serif;--font-body:${r.fontBody},system-ui,sans-serif;}
*,*::before,*::after{box-sizing:border-box;}
html,body{margin:0;padding:0;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
.deck{width:100vw;height:100vh;position:relative;}
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.96);transition:opacity 0.6s ease,transform 0.6s ease;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center;}
.slide.active{opacity:1;transform:scale(1);pointer-events:all;}
.slide>.content{position:relative;z-index:2;width:100%;max-width:1100px;padding:clamp(1.5rem,4vw,4rem);}
.gradient-mesh{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0;}
.blob{position:absolute;border-radius:50%;filter:blur(80px);animation:float-slow var(--dur,18s) ease-in-out infinite;}
@keyframes float-slow{0%{transform:translate(0,0) scale(1);}50%{transform:translate(-40px,40px) scale(0.92);}100%{transform:translate(0,0) scale(1);}}
.nav-controls{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:100;display:flex;align-items:center;gap:12px;background:rgba(0,0,0,0.45);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,0.08);border-radius:9999px;padding:8px 16px;}
.nav-dot{width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,0.2);border:none;cursor:pointer;transition:all 0.3s;padding:0;flex-shrink:0;}
.nav-dot.active{background:var(--color-accent-1);transform:scale(1.3);box-shadow:0 0 8px ${r.accent1}66;}
.nav-btn{background:none;border:none;color:rgba(255,255,255,0.5);font-size:20px;cursor:pointer;padding:0 4px;}
.nav-btn:hover{color:var(--color-accent-1);}
.slide-counter{font-size:0.75rem;color:rgba(255,255,255,0.4);font-variant-numeric:tabular-nums;min-width:44px;text-align:center;}
.bar{border-radius:6px 6px 0 0;transition:height 0.8s cubic-bezier(0.34,1.56,0.64,1);}
.bar-fill{transition:width 0.8s cubic-bezier(0.34,1.56,0.64,1);}
.line-path{stroke-dasharray:9999;stroke-dashoffset:9999;transition:stroke-dashoffset 1.2s ease;}
</style>
</head>
<body>
<div class="deck">
${slidesHtml}
</div>
<div class="nav-controls" id="nav-controls">
<button class="nav-btn" onclick="changeSlide(-1)">&#8249;</button>
<div id="nav-dots" style="display:flex;gap:8px;align-items:center;"></div>
<button class="nav-btn" onclick="changeSlide(1)">&#8250;</button>
<span class="slide-counter" id="slide-counter">1 / ${total}</span>
</div>
<script>
var current=1,slides=document.querySelectorAll('.slide'),total=slides.length;
(function(){var d=document.getElementById('nav-dots');for(var i=1;i<=total;i++){var b=document.createElement('button');b.className='nav-dot'+(i===1?' active':'');(function(n){b.addEventListener('click',function(){goToSlide(n)});})(i);d.appendChild(b);}updateNav();})();
function goToSlide(n){if(n<1||n>total||n===current)return;var p=document.querySelector('.slide.active'),nx=document.querySelector('.slide[data-slide="'+n+'"]');if(!nx)return;if(p)p.classList.remove('active');nx.classList.add('active');current=n;updateNav();animateSlide(nx);}
function changeSlide(dir){var n=current+dir;if(n<1)n=total;if(n>total)n=1;goToSlide(n);}
function updateNav(){document.querySelectorAll('.nav-dot').forEach(function(d,i){d.classList.toggle('active',(i+1)===current);});var c=document.getElementById('slide-counter');if(c)c.textContent=current+' / '+total;try{parent.postMessage({type:'slideChange',current:current,total:total},'*');}catch(e){}}
document.addEventListener('keydown',function(e){if(e.key==='ArrowRight'||e.key===' ')changeSlide(1);if(e.key==='ArrowLeft')changeSlide(-1);});
var tx=0;document.addEventListener('touchstart',function(e){tx=e.touches[0].clientX;},{passive:true});document.addEventListener('touchend',function(e){var dx=tx-e.changedTouches[0].clientX;if(Math.abs(dx)>50)changeSlide(dx>0?1:-1);},{passive:true});
function animateSlide(s){s.querySelectorAll('.reveal').forEach(function(el,i){el.style.transition='none';el.style.opacity='0';el.style.transform='translateY(18px)';el.offsetHeight;el.style.transition='opacity 0.35s ease '+(i*0.07)+'s, transform 0.35s ease '+(i*0.07)+'s';el.style.opacity='1';el.style.transform='translateY(0)';});s.querySelectorAll('.bar[data-height]').forEach(function(b){b.style.height='0%';setTimeout(function(){b.style.height=b.dataset.height+'%';},100);});s.querySelectorAll('.bar-fill[data-width]').forEach(function(b){b.style.width='0%';setTimeout(function(){b.style.width=b.dataset.width+'%';},100);});s.querySelectorAll('.line-path').forEach(function(p){var l=p.getTotalLength?p.getTotalLength():2000;p.style.strokeDasharray=l;p.style.strokeDashoffset=l;setTimeout(function(){p.style.strokeDashoffset='0';},100);});s.querySelectorAll('[data-count]').forEach(function(el){var t=parseFloat(el.dataset.count),sf=el.dataset.suffix||'',st=30,inc=t/st,i=0,v=0;var iv=setInterval(function(){v+=inc;i++;el.textContent=(i>=st?t:Math.round(v))+sf;if(i>=st)clearInterval(iv);},30);});}
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);},300);}
// Particles
document.querySelectorAll('canvas[id^="particles-"]').forEach(function(c){c.width=window.innerWidth;c.height=window.innerHeight;var ctx=c.getContext('2d'),pts=[];for(var i=0;i<50;i++)pts.push({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3,r:Math.random()*2+0.5});function draw(){ctx.clearRect(0,0,c.width,c.height);pts.forEach(function(p){p.x+=p.vx;p.y+=p.vy;if(p.x<0)p.x=c.width;if(p.x>c.width)p.x=0;if(p.y<0)p.y=c.height;if(p.y>c.height)p.y=0;ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fillStyle='${r.accent1}80';ctx.fill();});requestAnimationFrame(draw);}draw();});
</script>
</body>
</html>`
}

View File

@@ -0,0 +1,74 @@
/**
* Palette definitions and resolution helpers.
* Shared between slides.tool.ts (server) and slides-renderer.tsx (client).
*/
import type { Palette, PresentationSpec } from '@/lib/types/presentation'
export type { Palette }
export const PALETTES: Record<string, Palette> = {
// ── Dark / Keynote ───────────────────────────────────────────────────────────
keynote: { primary: '#f1f5f9', secondary: '#7dd3fc', accent: '#6366f1', light: '#334155', bg: '#0f172a', isDark: true },
galaxy: { primary: '#e2e8f0', secondary: '#a78bfa', accent: '#f472b6', light: '#1e1b4b', bg: '#0d1117', isDark: true },
stage_dark: { primary: '#f9fafb', secondary: '#34d399', accent: '#fbbf24', light: '#1f2937', bg: '#111827', isDark: true },
tech_night: { primary: '#e0e0e0', secondary: '#ffc300', accent: '#ffd60a', light: '#003566', bg: '#001d3d', isDark: true },
luxury_mystery: { primary: '#f2e9e4', secondary: '#c9ada7', accent: '#9a8c98', light: '#4a4e69', bg: '#22223b', isDark: true },
vibrant_orange_mint: { primary: '#f1f1f1', secondary: '#2ec4b6', accent: '#ff9f1c', light: '#0d2137', bg: '#1a1a2e', isDark: true },
platinum_white_gold: { primary: '#0a0a0a', secondary: '#0070F3', accent: '#D4AF37', light: '#f5f5f5', bg: '#ffffff', isDark: false },
// ── Light / Pro ──────────────────────────────────────────────────────────────
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 },
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 },
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 },
architectural_mono: { primary: '#1C1C1C', secondary: '#D4A373', accent: '#A47148', light: '#F9F8F6', bg: '#F9F8F6', isDark: false },
minimal_silk: { primary: '#212529', secondary: '#6c757d', accent: '#dee2e6', light: '#f8f9fa', bg: '#ffffff', isDark: false },
}
export const PALETTE_ALIASES: Record<string, string> = {
modern: 'vibrant_tech', corporate: 'business_authority', minimal: 'elegant_fashion',
dark: 'keynote', midnight: 'galaxy', night: 'tech_night', 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', stage: 'stage_dark',
architectural: 'architectural_mono', silk: 'minimal_silk',
black: 'keynote', white: 'platinum_white_gold', nuit: 'galaxy', sombre: 'stage_dark',
}
export 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',
architectural_mono: 'Architectural Mono', minimal_silk: 'Minimal Silk',
}
export function resolvePalette(spec: Pick<PresentationSpec, 'theme'>): { palette: Palette; key: string } {
const name = (spec.theme || '').toLowerCase().replace(/[\s-]/g, '_')
const key = PALETTE_ALIASES[name] || (PALETTES[name] ? name : 'keynote')
return { palette: PALETTES[key]!, key }
}
export function resolveRadius(style?: string): string {
const s = (style || '').toLowerCase()
if (s === 'sharp' || s === 'professional') return '4px'
if (s === 'brutalist') return '0px'
if (s === 'creative' || s === 'rounded') return '18px'
if (s === 'pill') return '28px'
return '12px'
}

View File

@@ -4,764 +4,99 @@ import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
import { buildPresentationHTML } from './slides-html-builder'
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 },
architectural_mono: { primary: '#1C1C1C', secondary: '#D4A373', accent: '#ACB995', light: '#F9F8F6', bg: '#F9F8F6', isDark: false },
minimal_silk: { primary: '#212529', secondary: '#6c757d', accent: '#dee2e6', light: '#f8f9fa', 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',
architectural: 'architectural_mono', silk: 'minimal_silk',
}
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',
architectural_mono: 'Architectural Mono', minimal_silk: 'Minimal Silk',
}
function resolvePalette(spec: PresentationSpec): { palette: Palette; key: string } {
const name = (spec.theme || '').toLowerCase().replace(/[\s-]/g, '_')
const key = PALETTE_ALIASES[name] || (PALETTES[name] ? name : 'vibrant_tech')
return { palette: PALETTES[key]!, key }
}
function resolveRadius(style?: string): string {
switch ((style || '').toLowerCase()) {
case 'sharp': return '2px'
case 'rounded': return '16px'
case 'pill': return '24px'
default: return '10px'
}
}
function esc(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function safeHtml(str: string): string {
return str
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
.replace(/\son\w+\s*=\s*["'][^"']*["']/gi, '')
.replace(/\son\w+\s*=\s*[^\s>]*/gi, '')
.replace(/javascript\s*:/gi, '')
}
function buildThemeCSS(p: Palette, radius: string, key: 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};
}
${key === 'architectural_mono' ? `
.reveal-viewport {
background-color: #F9F8F6 !important;
background-image:
linear-gradient(rgba(28, 28, 28, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(28, 28, 28, 0.08) 1px, transparent 1px),
linear-gradient(rgba(28, 28, 28, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(28, 28, 28, 0.04) 1px, transparent 1px) !important;
background-size: 100px 100px, 100px 100px, 20px 20px, 20px 20px !important;
}
.reveal {
font-family: 'JetBrains Mono', monospace !important;
}
.reveal h1, .reveal h2, .reveal h3 {
font-family: 'JetBrains Mono', monospace !important;
text-transform: uppercase !important;
letter-spacing: -0.02em !important;
font-weight: 700 !important;
}
.reveal h1 { border-left: 12px solid #D4A373; padding-left: 40px; }
.reveal section { text-align: left; padding: 60px; }
.reveal p, .reveal li { font-weight: 300; font-family: 'JetBrains Mono', monospace !important; }
` : ''}`
}
function buildLayoutCSS(): string {
return `
.reveal-viewport {
background: var(--p-bg);
background-image: linear-gradient(var(--p-border) 1px, transparent 1px), linear-gradient(90deg, var(--p-border) 1px, transparent 1px);
background-size: 40px 40px;
}
.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;
box-shadow: 0 10px 20px rgba(0,0,0,0.15);
}
.reveal .s-cards .card:nth-child(odd) .card-num { color: rgba(255,255,255,0.4); }
.reveal .s-cards .card:nth-child(odd) .card-text { color: #ffffff; font-weight: 300; }
.reveal .s-cards .card:nth-child(even) {
background: #ffffff; border: 1px solid var(--p-border-accent);
}
.reveal .s-cards .card:nth-child(even) .card-num { color: var(--p-accent); opacity: 0.6; }
.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, key)}
${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 }
}
const slideSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('title'), title: z.string(), subtitle: z.string().optional() }),
z.object({ type: z.literal('bullets'), title: z.string(), items: z.array(z.string()) }),
z.object({ type: z.literal('chart'), title: z.string(), chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']), data: z.array(z.object({ label: z.string(), value: z.number() })), subtitle: z.string().optional() }),
z.object({ type: z.literal('stats'), title: z.string(), stats: z.array(z.object({ value: z.string(), label: z.string() })) }),
z.object({ type: z.literal('table'), title: z.string(), headers: z.array(z.string()), rows: z.array(z.array(z.string())) }),
z.object({ type: z.literal('cards'), title: z.string(), cards: z.array(z.object({ title: z.string(), description: z.string() })) }),
z.object({ type: z.literal('timeline'), title: z.string(), events: z.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() })) }),
z.object({ type: z.literal('quote'), quote: z.string(), author: z.string().optional(), context: z.string().optional() }),
z.object({ type: z.literal('comparison'), title: z.string(), left: z.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }), right: z.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }) }),
z.object({ type: z.literal('equation'), title: z.string(), equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })), explanation: z.string().optional() }),
z.object({ type: z.literal('image'), title: z.string(), url: z.string().optional(), caption: z.string().optional() }),
z.object({ type: z.literal('summary'), title: z.string(), items: z.array(z.string()) }),
])
toolRegistry.register({
name: 'generate_slides',
description: 'Generate a beautiful HTML presentation with Reveal.js and save it for viewing.',
description: 'Renders a structured presentation from JSON data into a full animated HTML file and saves it.',
isInternal: true,
buildTool: (ctx) =>
tool({
description: `Generate a beautiful HTML presentation using Reveal.js and save it.
description: `Create a presentation from structured slide data. Each slide has a type and corresponding content.
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
Available slide types:
- "title": title, subtitle (opening slide with particles)
- "bullets": title, items[] (bullet list with 4-6 items of 15+ words each)
- "chart": title, chartType (bar|horizontal-bar|line|donut|radar), data[{label,value}], subtitle
- "stats": title, stats[{value:"98%", label:"KPI name"}] (3-4 animated KPIs)
- "table": title, headers[], rows[][] (data table)
- "cards": title, cards[{title, description}] (3-6 info cards)
- "timeline": title, events[{date, title, description}] (chronological events)
- "quote": quote, author, context (citation with analysis)
- "comparison": title, left{title, points[], score}, right{...} (A vs B)
- "equation": title, equations[{latex, label}], explanation (math formulas)
- "image": title, url, caption (image slide)
- "summary": title, items[] (conclusion with checkmarks)
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`,
- 6-12 slides per presentation
- First slide MUST be type "title"
- Last slide MUST be type "summary"
- Include at least 1 "chart" or "stats" slide
- Use VARIED types — never 2 identical types in a row
- All text content must come from the source notes (never invent data)
- Each bullet/card must be a real sentence (15+ words, not generic)`,
inputSchema: z.object({
title: z.string().describe('Title for the presentation'),
slides: z.string().describe('JSON presentation specification'),
title: z.string().describe('Short presentation title (6 words max)'),
theme: z.string().optional().describe('Visual recipe: architectural-saas, midnight-cathedral, aurora-borealis, venture-pitch, clinical-precision, coastal-morning, etc.'),
slides: z.array(slideSchema).describe('Array of slide objects, each with a "type" field'),
}),
execute: async ({ title, slides }) => {
execute: async ({ title, theme, slides }) => {
try {
console.log('[Slides Tool] INPUT title:', title)
console.log('[Slides Tool] INPUT slides (first 500 chars):', slides?.substring(0, 500))
console.log('[Slides Tool] Building presentation:', title, '| Slides:', slides.length, '| Theme:', theme)
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 html = buildPresentationHTML({ title, theme, slides: slides as any })
const canvas = await prisma.canvas.create({
data: {
name: title || spec.title || 'Presentation',
name: title || 'Présentation',
data: JSON.stringify({
type: 'slides',
title: spec.title,
theme: spec.theme,
slideCount: spec.slides.length,
title: title || 'Présentation',
html,
slideCount: slides.length,
theme: theme || 'architectural-saas',
spec: { title, theme, slides },
}),
userId: ctx.userId,
},
})
console.log('[Slides Tool] Canvas created:', canvas.id, canvas.name)
console.log('[Slides Tool] Canvas created:', canvas.id, '| Slides:', slides.length, '| Size:', Math.round(html.length / 1024), 'KB')
if (ctx.actionId) {
await prisma.agentAction.update({
where: { id: ctx.actionId },
data: {
status: 'success',
result: canvas.id,
log: `Slides generated: ${slides.length} slides, ${Math.round(html.length / 1024)}KB`,
},
}).catch(err => console.error('[Slides Tool] Failed to update action status:', err))
}
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.`,
success: true,
canvasId: canvas.id,
canvasName: canvas.name,
slideCount: slides.length,
message: `Presentation "${canvas.name}" created with ${slides.length} slides.`,
}
} catch (e: any) {
console.error('[Slides Tool] FATAL:', e)