All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
- 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
130 lines
5.2 KiB
TypeScript
130 lines
5.2 KiB
TypeScript
'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 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(),
|
||
},
|
||
})
|
||
|
||
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 }
|
||
}
|
||
}
|