Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux legacy, usage multi-features, hints de coût, anti-flash thème et hydratation admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
203 lines
7.3 KiB
TypeScript
203 lines
7.3 KiB
TypeScript
'use server'
|
||
|
||
import { tool } from 'ai'
|
||
import { z } from 'zod'
|
||
import { toolRegistry } from './registry'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { buildPresentationHTML } from './slides-html-builder'
|
||
import { normalizeSlideDeck, SLIDE_HARD_CAP } from '@/lib/ai/services/slide-content-quality'
|
||
|
||
const notesField = z.string().optional().describe('Optional speaker notes (1–2 sentences for the presenter, not shown on slide)')
|
||
|
||
const slideSchema = z.discriminatedUnion('type', [
|
||
z.object({
|
||
type: z.literal('title'),
|
||
title: z.string().describe('Action title / deck name'),
|
||
subtitle: z.string().optional().describe('One-line context or audience promise'),
|
||
notes: notesField,
|
||
}),
|
||
z.object({
|
||
type: z.literal('bullets'),
|
||
title: z.string().describe('Action title = the insight, not a topic label'),
|
||
items: z.array(z.string()).max(5).describe('3–5 short claims (8–18 words), each distinct, from the note'),
|
||
notes: notesField,
|
||
}),
|
||
z.object({
|
||
type: z.literal('chart'),
|
||
title: z.string().describe('Action title stating what the chart proves'),
|
||
chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']),
|
||
data: z.array(z.object({ label: z.string(), value: z.number() })).min(2).max(8),
|
||
subtitle: z.string().optional(),
|
||
notes: notesField,
|
||
}),
|
||
z.object({
|
||
type: z.literal('stats'),
|
||
title: z.string(),
|
||
stats: z.array(z.object({ value: z.string(), label: z.string() })).min(2).max(4),
|
||
notes: notesField,
|
||
}),
|
||
z.object({
|
||
type: z.literal('table'),
|
||
title: z.string(),
|
||
headers: z.array(z.string()).max(6),
|
||
rows: z.array(z.array(z.string())).max(8),
|
||
notes: notesField,
|
||
}),
|
||
z.object({
|
||
type: z.literal('cards'),
|
||
title: z.string(),
|
||
cards: z
|
||
.array(z.object({ title: z.string(), description: z.string() }))
|
||
.min(2)
|
||
.max(6)
|
||
.describe('2–4 cards preferred; short titles + 1–2 sentence descriptions'),
|
||
notes: notesField,
|
||
}),
|
||
z.object({
|
||
type: z.literal('timeline'),
|
||
title: z.string(),
|
||
events: z
|
||
.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() }))
|
||
.min(2)
|
||
.max(6),
|
||
notes: notesField,
|
||
}),
|
||
z.object({
|
||
type: z.literal('quote'),
|
||
quote: z.string().describe('Exact or faithful quote from the note'),
|
||
author: z.string().optional(),
|
||
context: z.string().optional().describe('Why this quote matters'),
|
||
notes: notesField,
|
||
}),
|
||
z.object({
|
||
type: z.literal('comparison'),
|
||
title: z.string().describe('Action title of the comparison insight'),
|
||
left: z.object({
|
||
title: z.string(),
|
||
points: z.array(z.string()).max(4),
|
||
score: z.string().optional(),
|
||
}),
|
||
right: z.object({
|
||
title: z.string(),
|
||
points: z.array(z.string()).max(4),
|
||
score: z.string().optional(),
|
||
}),
|
||
notes: notesField,
|
||
}),
|
||
z.object({
|
||
type: z.literal('equation'),
|
||
title: z.string(),
|
||
equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })).max(4),
|
||
explanation: z.string().optional(),
|
||
notes: notesField,
|
||
}),
|
||
z.object({
|
||
type: z.literal('image'),
|
||
title: z.string(),
|
||
url: z.string().optional().describe('Only if a real image URL exists in the note'),
|
||
caption: z.string().optional(),
|
||
notes: notesField,
|
||
}),
|
||
z.object({
|
||
type: z.literal('summary'),
|
||
title: z.string().describe('e.g. Next steps / Key takeaways'),
|
||
items: z.array(z.string()).max(5).describe('3–5 actionable takeaways'),
|
||
notes: notesField,
|
||
}),
|
||
])
|
||
|
||
toolRegistry.register({
|
||
name: 'generate_slides',
|
||
description: `Create an executive presentation from structured slide data (rendered to HTML by the server).
|
||
|
||
CONTENT RULES (quality > quantity):
|
||
- One idea per slide; titles are ACTION TITLES (insights), not topic labels like "Context" or "Analysis"
|
||
- Narrative: title → problem/context → insight → evidence → implications → summary
|
||
- Bullets: 3–5 max, short claims (≈8–18 words), no filler paragraphs
|
||
- chart/stats ONLY with real numbers from the source note — NEVER invent KPIs or radar scores
|
||
- First slide type "title", last type "summary"
|
||
- Hard limit: ${SLIDE_HARD_CAP} slides; fewer for short notes
|
||
- All facts from the note; optional "notes" field for speaker cues
|
||
|
||
Slide types: title | bullets | chart | stats | table | cards | timeline | quote | comparison | equation | image | summary`,
|
||
isInternal: true,
|
||
buildTool: (ctx) =>
|
||
tool({
|
||
description: `Create a high-quality executive deck. Prefer short action titles and sparse, high-signal content over dense filler.
|
||
|
||
Types:
|
||
- title: title, subtitle?
|
||
- bullets: title (insight), items[3-5] short claims
|
||
- chart: ONLY real numbers — chartType bar|horizontal-bar|line|donut|radar, data[{label,value}]
|
||
- stats: 2–4 real KPIs {value,label}
|
||
- table / cards / timeline / quote / comparison / equation / image / summary
|
||
|
||
Rules: first=title, last=summary, max ${SLIDE_HARD_CAP} slides, no invented data, no fake charts.`,
|
||
inputSchema: z.object({
|
||
title: z.string().describe('Short deck title (max ~8 words)'),
|
||
theme: z
|
||
.string()
|
||
.optional()
|
||
.describe(
|
||
'Visual recipe: architectural-saas, midnight-cathedral, aurora-borealis, venture-pitch, clinical-precision, coastal-morning, etc.',
|
||
),
|
||
slides: z.array(slideSchema).max(SLIDE_HARD_CAP).describe(`Slide array, 2–${SLIDE_HARD_CAP} items`),
|
||
}),
|
||
|
||
execute: async ({ title, theme, slides }) => {
|
||
try {
|
||
const normalized = normalizeSlideDeck({ title, theme, slides: slides as unknown[] })
|
||
|
||
const html = buildPresentationHTML({
|
||
title: normalized.title,
|
||
theme: normalized.theme,
|
||
slides: normalized.slides as any,
|
||
})
|
||
|
||
const canvas = await prisma.canvas.create({
|
||
data: {
|
||
name: normalized.title || 'Présentation',
|
||
data: JSON.stringify({
|
||
type: 'slides',
|
||
title: normalized.title || 'Présentation',
|
||
html,
|
||
slideCount: normalized.slides.length,
|
||
theme: normalized.theme || 'architectural-saas',
|
||
spec: {
|
||
title: normalized.title,
|
||
theme: normalized.theme,
|
||
slides: normalized.slides,
|
||
},
|
||
}),
|
||
userId: ctx.userId,
|
||
},
|
||
})
|
||
|
||
if (ctx.actionId) {
|
||
await prisma.agentAction
|
||
.update({
|
||
where: { id: ctx.actionId },
|
||
data: {
|
||
status: 'success',
|
||
result: canvas.id,
|
||
log: `Slides generated: ${normalized.slides.length} slides, ${Math.round(html.length / 1024)}KB (quality-normalized)`,
|
||
},
|
||
})
|
||
.catch((err) => console.error('[Slides Tool] Failed to update action status:', err))
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
canvasId: canvas.id,
|
||
canvasName: canvas.name,
|
||
slideCount: normalized.slides.length,
|
||
message: `Presentation "${canvas.name}" created with ${normalized.slides.length} slides.`,
|
||
}
|
||
} catch (e: any) {
|
||
console.error('[Slides Tool] FATAL:', e)
|
||
return { success: false, error: `Failed: ${e.message}` }
|
||
}
|
||
},
|
||
}),
|
||
})
|