Files
Momento/memento-note/app/actions/note-illustration.ts
Antigravity 96e7902f01
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped
feat: publication IA (magazine/brief/essay) + fixes critique
Publication IA:
- 4 templates (magazine, brief, essay, simple) avec CSS riche
- Rewrite IA (article/exercises/tutorial/reference/mixed)
- Modération avec timeout 12s + fallback safe
- Quotas publish_enhance par tier (basic=2, pro=15, business=100)
- Détection contenu stale (hash)
- Migration DB publishedContent/publishedTemplate/publishedSourceHash

Fixes:
- cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast
- _isShared ajouté au type Note (champ virtuel serveur)
- callout colors PDF export: extraction fonction pure testable
- admin/published: guard note.userId null
- Cmd+S fonctionne en mode dialog (pas seulement fullPage)

i18n:
- 23 clés publish* traduites dans les 15 locales
- Extension Web Clipper: 13 locales mise à jour

Tests:
- callout-colors.test.ts (6 tests)
- note-visible-in-view.test.ts (5 tests)
- entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs
- 199/199 tests passent

Tracker: user-stories.md sync avec sprint-status.yaml
2026-06-28 07:32:57 +00:00

274 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use server'
import DOMPurify from 'isomorphic-dompurify'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { getAISettings } from '@/app/actions/ai-settings'
import { revalidatePath } from 'next/cache'
type SvgComplexity = 'simple' | 'illustrated' | 'rich'
// Palette de l'application Memento — à utiliser dans TOUS les SVGs
const APP_PALETTE = `
APP COLOR PALETTE (use ONLY these colors — no other palettes):
- Background warm beige: #F2F0E9
- Desk warm grey: #E5E2D9
- Brand copper: #A47148 (main accent — use for key shapes)
- Sage green: #ACB995 (secondary accent)
- Ink charcoal: #2C2A26 (for dark shapes, text)
- Warm tan: #C9B8A1 (mid tones)
- Soft cream: #FAF8F4 (light elements)
- Dusty rose: #C4998B (warm pink accent)
- Muted slate: #8D8D8D (subtle elements)
- Deep bark: #6B4C35 (dark brown for depth)
FORBIDDEN colors: cold blue, navy, electric cyan, neon, pure white (#fff), pure black (#000)
The overall mood must be WARM, EDITORIAL, like aged paper with copper accents.
`
function extractSvgSnippet(raw: string): string | null {
let text = raw
.replace(/<think>[\s\S]*?<\/think>/gi, '')
.replace(/```(?:svg|xml)?\s*/gi, '')
.replace(/```/g, '')
.trim()
const start = text.indexOf('<svg')
const end = text.lastIndexOf('</svg>')
if (start === -1 || end === -1 || end <= start) return null
return text.slice(start, end + 6)
}
function sanitizeSvgMarkup(svg: string): string {
return DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: [
'use', 'defs', 'linearGradient', 'radialGradient', 'stop',
'filter', 'feDropShadow', 'feGaussianBlur', 'feBlend', 'feComposite',
'feMerge', 'feMergeNode', 'feColorMatrix', 'feOffset', 'feTurbulence',
'feDisplacementMap', 'clipPath', 'mask', 'pattern', 'symbol', 'marker',
],
ADD_ATTR: [
'viewBox', 'xmlns', 'preserveAspectRatio',
'gradientUnits', 'gradientTransform', 'spreadMethod',
'offset', 'stop-color', 'stop-opacity',
'stdDeviation', 'dx', 'dy', 'flood-color', 'flood-opacity',
'in', 'in2', 'result', 'type', 'values',
'markerWidth', 'markerHeight', 'refX', 'refY', 'orient',
'font-family', 'font-size', 'font-weight', 'text-anchor',
'dominant-baseline', 'letter-spacing', 'word-spacing',
'baseFrequency', 'numOctaves', 'seed', 'scale',
],
})
}
function buildPrompt(
complexity: SvgComplexity,
plainTitle: string,
plainBody: string,
): string {
const title = (plainTitle || 'Note').slice(0, 200)
const bodyContext = plainBody.slice(0, 2000)
const sharedRules = `
MULTILINGUAL: The title/content may be in French, Persian, Arabic, English or any language. Ignore the language — produce VISUAL output only.
${APP_PALETTE}
ABSOLUTELY FORBIDDEN:
- Generic/meaningless shapes: plain eye shapes, nested concentric circles with a triangle, abstract orbits, random geometric blobs
- Cold colors: blue, navy, cyan, neon green, purple, electric tones
- Shapes that have NO visual connection to the topic
- Gradients that go dark-to-dark with cold tones
- Dashed circles or crosshair-style patterns unless the topic is literally about targeting/aiming
WHAT MAKES A GOOD ILLUSTRATION:
- A viewer who sees the SVG and reads the title should say "yes, that makes sense"
- Concrete objects > abstract shapes (airplane > circle, book > rectangle, brain > circle with bumps)
- The most recognizable iconic form of the concept
- Warmth and editorial feel matching the app palette
`
if (complexity === 'simple') {
return `You are a precision SVG icon designer creating note card thumbnails for a warm editorial note-taking app.
Your task: create ONE clear, iconic pictogram that represents THIS SPECIFIC note topic.
${sharedRules}
OUTPUT: Raw SVG ONLY. <svg viewBox="0 0 400 300"> ... </svg>. No markdown, no comments.
CANVAS: viewBox="0 0 400 300" — NO width/height attributes.
SIZE TARGET: 800-1400 bytes of SVG markup.
DESIGN APPROACH — "Warm Stamp" style:
- Background: fill entire canvas with warm beige #F2F0E9
- Add a subtle texture rectangle: <rect width="400" height="300" fill="#E5E2D9" opacity="0.3"/>
- Main pictogram: centered, using brand copper #A47148 as primary color
- Secondary element: sage green #ACB995 for accents or depth
- The pictogram fills roughly 40-55% of the canvas height — large and readable
SPECIFIC TOPIC → SPECIFIC SHAPE rules (think like a professional icon designer):
- AI / Machine Learning → neural network nodes connected by lines, OR a stylized brain with circuit paths
- Tech analysis / research → magnifying glass over a document or graph bars
- Travel / flights → an airplane silhouette (side view, classic shape with wings)
- Governance / regulation → scales of justice, OR a shield with checkmark
- Innovation / startup → rocket ship, OR a lightbulb with circuit inside
- Cooking / food → specific food item mentioned, OR bowl with utensils
- Finance / money → ascending bar chart, OR coin stack
- Nature / environment → tree with roots showing, OR mountain peaks
- Music → treble clef, OR speaker with sound waves
- Health / medical → heartbeat line, OR medical cross
- Education / learning → open book with pages, OR graduation cap
- Writing / literature → quill pen, OR typewriter keys
- Philosophy / thinking → head silhouette with thought bubble, OR question mark with gears
- Calendar / date / event → calendar grid with highlighted date
- Sport → the specific sport equipment mentioned
- Data / statistics → bar chart or scatter plot dots
- Social / community → group of overlapping person silhouettes
DO NOT use a generic shape if the topic is specific. Read CAREFULLY:
- "Tensions IA" → NOT a circle+triangle → USE: two opposing arrows pulling apart, OR a scale with AI chip on one side
- "Veille IA & Tech" → NOT an eye → USE: a magnifying glass over circuit board, OR stacked tech layers with a search icon
- "Gouvernance" → USE: shield with checkmark, OR gavel/hammer of judge
- "Innovation" → USE: rocket or lightbulb
TITLE: "${title}"
CONTENT (for context):
${bodyContext.slice(0, 500)}`
}
if (complexity === 'illustrated') {
return `You are a professional SVG illustrator creating rich editorial card illustrations for a warm note-taking app.
${sharedRules}
OUTPUT: Raw SVG ONLY. <svg viewBox="0 0 400 300"> ... </svg>. No markdown, no comments.
CANVAS: viewBox="0 0 400 300" — NO width/height attributes.
SIZE TARGET: 2000-3500 bytes.
DESIGN APPROACH — "Editorial Print" style:
- Layered composition: background fill → texture layer → mid elements → focal element → foreground details
- Use <defs> with 1-2 gradients (warm tones only — from the app palette)
- 12-20 SVG elements
- The illustration should tell a visual story about the note content
- Add subtle paper texture: <rect fill="#E5E2D9" opacity="0.2" width="400" height="300"/>
- Focal element is specific and recognizable (not abstract)
- Depth through opacity stacking (0.2, 0.4, 0.7, 1.0 layers)
Example structure for "AI Tech Analysis":
- Background: warm beige rect
- Mid: faint grid lines in #C9B8A1 suggesting a document or data surface
- Focal: a magnifying glass (#A47148) with a neural net pattern visible inside its lens
- Foreground: small data points or chip-like squares in #ACB995
TITLE: "${title}"
CONTENT:
${bodyContext.slice(0, 1000)}`
}
// 'rich' — carte conceptuelle
return `You are an expert information designer creating SVG concept maps for a warm editorial note-taking app.
${sharedRules}
OUTPUT: Raw SVG ONLY. <svg viewBox="0 0 400 300"> ... </svg>. No markdown, no comments.
CANVAS: viewBox="0 0 400 300" — NO width/height attributes.
SIZE TARGET: 2500-4000 bytes.
DESIGN APPROACH — "Warm Knowledge Map":
- Background: warm beige #F2F0E9 (NOT dark — this is a LIGHT warm background)
- Node style: rounded <rect rx="8"> with #A47148 fill for main node, #ACB995 for secondary, #C9B8A1 for tertiary
- Text inside nodes: <text fill="#FAF8F4" font-family="Georgia, serif" font-size="11" font-weight="bold">
- Connection lines: <line stroke="#A47148" stroke-width="1.5" opacity="0.5">
- Arrow markers: <defs><marker> with copper fill
- Drop shadows on nodes: <filter><feDropShadow dx="1" dy="2" stdDeviation="2" flood-color="#00000022">
STRUCTURE:
1. Central node (title concept) — large, centered or slightly left, #A47148
2. 3-5 satellite nodes for key sub-concepts from the content body
3. Labels: SHORT (2-3 words), in the app's serif style
4. Spread across the canvas — use the full 400×300 space
TITLE: "${title}"
CONTENT (extract 3-5 key concepts):
${bodyContext.slice(0, 1500)}`
}
/**
* Génère ou regénère une illustration SVG pour une note.
*/
export async function generateNoteIllustrationSvg(
noteId: string,
options?: { skipRevalidation?: boolean },
): Promise<{ ok: true } | { ok: false; error: string }> {
const session = await auth()
if (!session?.user?.id) return { ok: false, error: 'Non autorisé' }
try {
const settings = await getAISettings(session.user.id)
if (settings.paragraphRefactor === false) {
return { ok: false, error: 'Assistant IA désactivé dans vos paramètres.' }
}
const complexity: SvgComplexity =
(settings.svgComplexity as SvgComplexity) ?? 'simple'
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true, title: true, content: true },
})
if (!note) return { ok: false, error: 'Note introuvable' }
const plainTitle = (note.title || '').slice(0, 200)
const plainBody = note.content
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!plainBody && !plainTitle) {
return { ok: false, error: 'Ajoutez du contenu avant de générer une illustration.' }
}
const config = await getSystemConfig()
const provider = getChatProvider(config)
const prompt = buildPrompt(complexity, plainTitle, plainBody)
const raw = await provider.generateText(prompt)
const extracted = extractSvgSnippet(raw)
if (!extracted) {
return { ok: false, error: 'Le modèle n\'a pas renvoyé un SVG valide. Réessayez.' }
}
const safe = sanitizeSvgMarkup(extracted)
if (!safe.includes('<svg')) {
return { ok: false, error: 'SVG rejeté après sécurisation.' }
}
await prisma.note.update({
where: { id: noteId, userId: session.user.id },
data: {
illustrationSvg: safe,
lastAiAnalysis: new Date(),
},
})
if (!options?.skipRevalidation) {
revalidatePath('/home')
}
return { ok: true }
} catch (e) {
console.error('[note-illustration]', e)
const msg = e instanceof Error ? e.message : 'Erreur inconnue'
return {
ok: false,
error: msg.includes('required')
? 'Configurez un fournisseur IA (admin ou paramètres système).'
: msg,
}
}
}