Files
Momento/memento-note/app/actions/note-illustration.ts
Antigravity 8c7ca69640
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
fix: brainstorm infinite loop, ghost cursor, embedding ::vector cast, semantic search, billing stats, usage meter accordion
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup
- Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders
- Fix all SQL embedding queries: add ::vector cast on text columns
- Fix embedding truncation to 15000 chars (under 8192 token limit)
- Fix NoteEmbedding INSERT: remove non-existent updatedAt column
- Fix billing page: show all quota stats in grid instead of single metric
- Fix usage meter: accordion expand/collapse, per-feature detail
- Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch
- Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
2026-05-16 18:50:34 +00:00

130 lines
5.2 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'
function extractSvgSnippet(raw: string): string | null {
const trimmed = raw.trim()
const fenced = trimmed.match(/```(?:svg)?\s*([\s\S]*?)```/i)
const candidate = (fenced ? fenced[1] : trimmed).trim()
const start = candidate.indexOf('<svg')
const end = candidate.lastIndexOf('</svg>')
if (start === -1 || end === -1 || end <= start) return null
return candidate.slice(start, end + 6)
}
function sanitizeSvgMarkup(svg: string): string {
return DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['use'],
ADD_ATTR: ['viewBox', 'xmlns', 'preserveAspectRatio'],
})
}
/**
* Génère une miniature SVG abstraite pour le flux éditorial (via modèle chat configuré).
* Respecte les préférences utilisateur (assistant IA activé) et nettoie le SVG.
*/
export async function generateNoteIllustrationSvg(noteId: string): 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 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()
.slice(0, 1200)
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 = `Create a small SVG thumbnail that VISUALLY REPRESENTS this note's topic.
OUTPUT: Only raw SVG markup. No markdown, no code fences, no comments. Start with <svg and end with </svg>.
SPECIFICATIONS:
- viewBox="0 0 224 168", NO fixed width/height attributes
- Maximum 1000 bytes
- Background: soft warm beige (#F5F0E8) or transparent
- Color palette (pick 2-3): warm charcoal (#2C2C2C), slate gray (#6B7280), soft sage (#A8B5A0), muted ochre (#C4A882), dusty rose (#C9A9A6), teal (#5F9EA0), burgundy (#8B4513)
- NO text, NO scripts, NO foreignObject, NO external links
CRITICAL: The illustration MUST be recognizably related to the topic.
Think of it like an ICON or PICTOGRAM for the title. Not abstract random shapes.
TOPIC: "${plainTitle || 'untitled'}"
How to illustrate this topic (pick the BEST match):
- If the topic is about CODE/DEV: Show angle brackets <>, curly braces {}, a terminal window shape, or circuit-like lines
- If the topic is about MUSIC: Show sound waves, musical notes shapes, or speaker icon
- If the topic is about FOOD/COOKING: Show a pot shape, utensils, or plate
- If the topic is about TRAVEL: Show a path/road, mountain peaks, or compass
- If the topic is about SCIENCE: Show atom orbits, flask/beaker, or molecule bonds
- If the topic is about BUSINESS/FINANCE: Show ascending chart lines, coins, or briefcase
- If the topic is about HEALTH: Show heart shape, pulse line, or leaf
- If the topic is about EDUCATION: Show book shape, graduation cap, or pencil
- If the topic is about NATURE: Show tree, mountain, water wave, or sun
- If the topic is about DESIGN/ART: Show palette, brush stroke, or frame
- If the topic is about PEOPLE/TEAM: Show overlapping circles, handshake, or connected nodes
- If the topic is about ARCHITECTURE: Show building outline, blueprint grid, or columns
- Otherwise: Extract the KEY CONCEPT from the title and draw its SIMPLEST iconic representation
TECHNICAL RULES:
- Use simple shapes: <circle>, <rect>, <line>, <path>, <ellipse>, <polygon>, <g>
- Keep it FLAT and MINIMAL — 2-4 elements max
- Use opacity for depth (0.3-0.8)
- The icon should be immediately recognizable even at small size
Additional context from the note:
${plainBody.slice(0, 200)}`
const raw = await provider.generateText(prompt)
const extracted = extractSvgSnippet(raw)
if (!extracted) {
return { ok: false, error: 'Le modèle na 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(),
},
})
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 }
}
}