perf: memo GridCard, fuse save fns, fix slash tab active color
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m32s
CI / Deploy production (on server) (push) Has been skipped

This commit is contained in:
Antigravity
2026-06-14 14:06:05 +00:00
parent a8785ed4f1
commit a623454347
120 changed files with 12301 additions and 785 deletions

View File

@@ -2,7 +2,7 @@
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath, updateTag } from 'next/cache'
import { revalidatePath, revalidateTag } from 'next/cache'
export type UserAISettingsData = {
titleSuggestions?: boolean
@@ -25,6 +25,7 @@ export type UserAISettingsData = {
fontFamily?: 'inter' | 'playfair' | 'jetbrains' | 'system'
autoSave?: boolean
aiProcessingConsent?: boolean
svgComplexity?: 'simple' | 'illustrated' | 'rich'
}
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
@@ -49,6 +50,7 @@ const USER_AI_SETTINGS_PRISMA_KEYS = [
'fontFamily',
'autoSave',
'aiProcessingConsent',
'svgComplexity',
] as const
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
@@ -99,7 +101,7 @@ export async function updateAISettings(settings: UserAISettingsData) {
revalidatePath('/settings/ai', 'page')
revalidatePath('/settings/appearance', 'page')
revalidatePath('/', 'layout')
updateTag('ai-settings')
revalidateTag('ai-settings')
return { success: true }
} catch (error) {
@@ -153,6 +155,7 @@ const getCachedAISettings = unstable_cache(
fontFamily: 'inter' as const,
autoSave: true,
aiProcessingConsent: false,
svgComplexity: 'simple' as const,
}
}
@@ -179,6 +182,7 @@ const getCachedAISettings = unstable_cache(
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'playfair' | 'jetbrains' | 'system',
autoSave: settings.autoSave ?? true,
aiProcessingConsent: settings.aiProcessingConsent ?? false,
svgComplexity: ((settings as any).svgComplexity ?? 'simple') as 'simple' | 'illustrated' | 'rich',
}
} catch (error) {
console.error('Error getting AI settings:', error)
@@ -205,6 +209,7 @@ const getCachedAISettings = unstable_cache(
fontFamily: 'inter' as const,
autoSave: true,
aiProcessingConsent: false,
svgComplexity: 'simple' as const,
}
}
},
@@ -244,6 +249,7 @@ export async function getAISettings(userId?: string) {
fontFamily: 'inter' as const,
autoSave: true,
aiProcessingConsent: false,
svgComplexity: 'simple' as const,
}
}

View File

@@ -0,0 +1,71 @@
'use server'
import { auth } from '@/auth'
import { updateAISettings } from './ai-settings'
import { getAISettings } from './ai-settings'
import type { ConsentRecord } from '@/lib/consent/cookie-consent'
/**
* Get cookie consent from database for authenticated users.
* Returns null for guests or when no DB preference exists.
*/
export async function getCookieConsentFromDB(): Promise<ConsentRecord | null> {
const session = await auth()
if (!session?.user?.id) {
return null
}
try {
const settings = await getAISettings(session.user.id)
// Only return consent if user has explicitly set it
// If anonymousAnalytics is undefined or false, treat as "no preference set"
if (settings.anonymousAnalytics === true) {
return {
version: 1,
necessary: true,
analytics: true,
marketing: false,
updatedAt: new Date().toISOString(),
}
}
// If explicitly false, return that too
if (settings.anonymousAnalytics === false) {
return {
version: 1,
necessary: true,
analytics: false,
marketing: false,
updatedAt: new Date().toISOString(),
}
}
} catch (error) {
console.error('[getCookieConsentFromDB] Failed to load:', error)
}
return null
}
/**
* Save cookie consent and sync to database for authenticated users.
*
* For logged-in users, this updates UserAISettings.anonymousAnalytics in the database.
* For guests, this no-ops silently (they rely on localStorage only).
*
* Call this from client components after setting local consent.
*/
export async function saveCookieConsent(consent: ConsentRecord) {
const session = await auth()
// Only sync to DB for authenticated users
if (session?.user?.id) {
try {
await updateAISettings({ anonymousAnalytics: consent.analytics })
} catch (error) {
console.error('[saveCookieConsent] Failed to sync to DB:', error)
// Don't throw — local consent is already set, DB sync is best-effort
}
}
return { success: true }
}

View File

@@ -0,0 +1,118 @@
'use server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
import { checkEntitlementOrThrow, incrementUsageAsync } from '@/lib/entitlements'
import { toolRegistry } from '@/lib/ai/tools/registry'
// S'assurer que l'outil est importé pour s'enregistrer dans le registre
import '@/lib/ai/tools/excalidraw.tool'
const SYSTEM_PROMPT = `Tu es un architecte visuel expert dans la création de diagrammes Excalidraw extrêmement détaillés, techniques et intelligents.
Analyse le texte ou l'architecture fournie par l'utilisateur et génère un diagramme riche, complet, informatif et hautement professionnel.
Tu DOIS impérativement répondre UNIQUEMENT avec un objet JSON valide au format simplifié suivant :
{
"title": "Titre du diagramme",
"type": "auto",
"style": "default",
"zones": [
{"id": "z1", "label": "Nom de la Zone (ex: Frontend)"}
],
"nodes": [
{"id": "n1", "label": "Nom du composant/classe/fichier\\n• Propriété 1\\n• Propriété 2", "type": "rect", "zoneId": "z1"}
],
"edges": [
{"from": "n1", "to": "n2", "label": "relation"}
]
}
RÈGLES D'INTELLIGENCE ET DE RICHESSE TECHNIQUE (CRITIQUES) :
1. **Détails internes des Nœuds (CRITIQUE) :** Pour chaque fichier, classe, modèle de base de données ou service, utilise ABSOLUMENT des sauts de ligne échappés (\\n) pour lister ses attributs clés, chemins de fichiers, sous-fichiers, propriétés ou fonctions décrits dans le texte (ex: utilise des listes à puces comme \\n• Propriété 1\\n• Propriété 2). L'utilisateur doit pouvoir lire les détails techniques (ex: les champs "userId", "tier", "status" dans un modèle de base de données, ou les sous-composants comme "Embedded Checkout" et "Toggle mensuel/annuel" dans un fichier UI) DIRECTEMENT dans le corps du nœud. Ne crée JAMAIS de nœuds vides ou purement génériques !
2. **Zones & Groupements :** Utilise systématiquement des zones ("zones") pour regrouper les nœuds par couche logique ou technologique (ex: Frontend, API / Backend, Base de données, Services Externes, Cache). Renseigne "zoneId" sur chaque nœud pour le placer dans sa zone.
3. **Fidélité absolue au texte :** Intègre tous les chemins de fichiers (ex: "/settings/billing", "lib/stripe.ts", "billing-plans.tsx"), actions (ex: "POST /api/billing/webhook") et variables importants décrits.
4. **Types de nœuds :** Utilise "ellipse" pour les points d'entrée/sorties ou rôles utilisateurs, "diamond" pour les choix/décisions, et "rect" (par défaut) pour les composants, fichiers, tables et services.
5. **Format Strict :** De 5 à 15 nœuds maximum pour un diagramme d'architecture lisible. Échappe correctement tous les sauts de ligne comme \\n dans tes chaînes JSON. Ne renvoie AUCUN texte en dehors du JSON. Pas de blabla, pas de markdown.`;
function extractJsonFromText(text: string): any {
if (!text) return null
try {
const parsed = JSON.parse(text.trim())
if (parsed && typeof parsed === 'object') return parsed
} catch (e) {}
const jsonBlockRegex = /```json\s*([\s\S]*?)\s*```/i
const match = text.match(jsonBlockRegex)
if (match && match[1]) {
try {
const parsed = JSON.parse(match[1].trim())
if (parsed && typeof parsed === 'object') return parsed
} catch (e) {}
}
const braceRegex = /(\{[\s\S]*\})/
const braceMatch = text.match(braceRegex)
if (braceMatch && braceMatch[1]) {
try {
const parsed = JSON.parse(braceMatch[1].trim())
if (parsed && typeof parsed === 'object') return parsed
} catch (e) {}
}
return null
}
export async function generateDiagramFromText(text: string): Promise<{ success: boolean; canvasId?: string; error?: string }> {
const session = await auth()
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' }
}
const userId = session.user.id
try {
// 1. Vérification et déduction des quotas
await checkEntitlementOrThrow(userId, 'excalidraw_generate')
// 2. Instancier le modèle de chat IA
const systemConfig = await getSystemConfig()
const provider = getChatProvider(systemConfig)
// 3. Appel du modèle
const prompt = `${SYSTEM_PROMPT}\n\nTexte à analyser :\n${text}`
const aiResponse = await provider.generateText(prompt)
// 4. Extraction du JSON
const parsedJson = extractJsonFromText(aiResponse)
if (!parsedJson || !parsedJson.nodes || !Array.isArray(parsedJson.nodes)) {
console.error('[generateDiagramFromText] Invalid JSON from AI:', aiResponse)
return { success: false, error: "L'IA n'a pas généré un format de diagramme valide. Veuillez réessayer." }
}
// 5. Invoquer le tool d'Excalidraw
const registered = toolRegistry.get('generate_excalidraw')
if (!registered) {
return { success: false, error: "Outil de génération Excalidraw non disponible." }
}
const ctx = { userId, config: systemConfig }
const toolInstance = registered.buildTool(ctx as any)
const result = await toolInstance.execute({
title: parsedJson.title || "Diagramme",
diagram: JSON.stringify(parsedJson)
})
if (!result.success || !result.canvasId) {
return { success: false, error: result.error || "La création du canevas a échoué." }
}
// 6. Incrémenter le quota
await incrementUsageAsync(userId, 'excalidraw_generate')
return { success: true, canvasId: result.canvasId }
} catch (err: any) {
console.error('[generateDiagramFromText] Error:', err)
return { success: false, error: err.message || 'Une erreur inattendue est survenue.' }
}
}

View File

@@ -8,27 +8,198 @@ 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 Momento — à 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 {
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>')
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 candidate.slice(start, end + 6)
return text.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'],
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 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.
* Génère ou regénère une illustration SVG pour une note.
*/
export async function generateNoteIllustrationSvg(
noteId: string,
@@ -43,6 +214,9 @@ export async function generateNoteIllustrationSvg(
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 },
@@ -54,7 +228,6 @@ export async function generateNoteIllustrationSvg(
.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.' }
@@ -63,50 +236,11 @@ export async function generateNoteIllustrationSvg(
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 prompt = buildPrompt(complexity, plainTitle, plainBody)
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.' }
return { ok: false, error: 'Le modèle n\'a pas renvoyé un SVG valide. Réessayez.' }
}
const safe = sanitizeSvgMarkup(extracted)
@@ -129,6 +263,11 @@ ${plainBody.slice(0, 200)}`
} 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 }
return {
ok: false,
error: msg.includes('required')
? 'Configurez un fournisseur IA (admin ou paramètres système).'
: msg,
}
}
}

View File

@@ -22,6 +22,10 @@ import {
} from '@/lib/note-history'
import { NOTE_LIST_SELECT } from '@/lib/note-select'
function stripNullBytes(str: string | null | undefined): string | null {
if (str === null || str === undefined) return null
return str.replace(/\u0000/g, '')
}
async function ensureSessionUserExists(sessionUser: { id: string; email?: string | null; name?: string | null }) {
const fallbackEmail = `user-${sessionUser.id}@local.momento`
@@ -400,8 +404,8 @@ export async function createNote(data: {
const note = await prisma.note.create({
data: {
userId: session.user.id,
title: data.title || null,
content: data.content,
title: stripNullBytes(data.title) || null,
content: stripNullBytes(data.content) || '',
color: data.color || 'default',
type: data.type || 'richtext',
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
@@ -563,6 +567,12 @@ export async function updateNote(id: string, data: {
const oldNotebookId = oldNote?.notebookId
const updateData: any = { ...data }
if (updateData.title !== undefined) {
updateData.title = stripNullBytes(updateData.title)
}
if (updateData.content !== undefined) {
updateData.content = stripNullBytes(updateData.content)
}
// Reset isReminderDone only when reminder date actually changes (not on every save)
if ('reminder' in data && data.reminder !== null) {
@@ -984,6 +994,7 @@ export async function getAllNotes(includeArchived = false, notebookId?: string)
const sharedNotes = acceptedShares
.map(share => share.note)
.filter(Boolean)
.filter(note => includeArchived || !note.isArchived)
.map(note => ({ ...note, _isShared: true }))

View File

@@ -11,6 +11,20 @@ const requestSchema = z.object({
content: z.string().min(1, "Le contenu ne peut pas être vide"),
})
/** Supprime les balises HTML pour extraire le texte brut */
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
export async function POST(req: NextRequest) {
try {
// Check authentication and user setting
@@ -42,10 +56,13 @@ export async function POST(req: NextRequest) {
console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err);
}
const body = await req.json()
const { content } = requestSchema.parse(body)
const { content: rawContent } = requestSchema.parse(body)
// Nettoyer le HTML (l'éditeur TipTap envoie du HTML)
const content = stripHtml(rawContent)
// Vérifier qu'il y a au moins 10 mots
const wordCount = content.split(/\s+/).length
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (wordCount < 10) {
return NextResponse.json(
@@ -57,7 +74,6 @@ export async function POST(req: NextRequest) {
const config = await getSystemConfig()
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)
const hasNonLatinChars = /[\u0400-\u04FF\u0600-\u06FF\u4E00-\u9FFF\u0E00-\u0E7F]/.test(content)
const isPersian = /[\u0600-\u06FF]/.test(content)
const isChinese = /[\u4E00-\u9FFF]/.test(content)
const isRussian = /[\u0400-\u04FF]/.test(content)
@@ -99,7 +115,7 @@ IMPORTANT INSTRUCTIONS:
- Focus on the main topics and themes in THIS SPECIFIC content
- Be specific to what is actually discussed
CONTENT_START: ${content.substring(0, 500)} CONTENT_END
CONTENT_START: ${content.substring(0, 3000)} CONTENT_END
Respond ONLY with a JSON array: [{"title": "title1", "confidence": 0.95}, {"title": "title2", "confidence": 0.85}, {"title": "title3", "confidence": 0.75}]`
: `Tu es un générateur de titres. Génère 3 titres concis et descriptifs pour le contenu suivant en ${responseLanguage}.
@@ -110,7 +126,7 @@ INSTRUCTIONS IMPORTANTES :
- Concentre-toi sur les sujets principaux et thèmes de CE CONTENU SPÉCIFIQUE
- Sois spécifique à ce qui est réellement discuté
CONTENT_START: ${content.substring(0, 500)} CONTENT_END
CONTENT_START: ${content.substring(0, 3000)} CONTENT_END
Réponds SEULEMENT avec un tableau JSON: [{"title": "titre1", "confidence": 0.95}, {"title": "titre2", "confidence": 0.85}, {"title": "titre3", "confidence": 0.75}]`

View File

@@ -70,6 +70,7 @@ export async function POST(req: NextRequest) {
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
customer_update: { address: 'auto' },
allow_promotion_codes: true,
};
const checkoutSession = await stripe.checkout.sessions.create(sessionParams as any);

View File

@@ -3,7 +3,7 @@ import { auth } from '@/auth';
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
import { stripe } from '@/lib/stripe';
import type Stripe from 'stripe';
import { priceIdToTier } from '@/lib/billing/stripe-prices';
import { priceIdToTier, getDynamicPrices } from '@/lib/billing/stripe-prices';
export const dynamic = 'force-dynamic';
@@ -26,7 +26,7 @@ export async function GET(req: NextRequest) {
? checkoutSession.subscription
: (checkoutSession.subscription as any).id;
const sub = await stripe.subscriptions.retrieve(subId);
const sub = await stripe.subscriptions.retrieve(subId) as any;
const priceId = sub.items.data[0].price.id;
const tier = priceIdToTier(priceId) || (checkoutSession.metadata?.tier as any) || 'PRO';
@@ -70,11 +70,11 @@ export async function GET(req: NextRequest) {
console.error('[billing/status] Failed to sync Stripe session:', err);
}
}
try {
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
const effectiveTier = await getEffectiveTier(userId);
const subscription = await prisma.subscription.findUnique({ where: { userId } });
const prices = await getDynamicPrices();
return NextResponse.json({
tier,
@@ -84,6 +84,7 @@ export async function GET(req: NextRequest) {
currentPeriodStart: subscription?.currentPeriodStart ?? null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
prices,
});
} catch (error) {
console.error('[billing/status]', error);

View File

@@ -9,6 +9,7 @@ import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
import { toolRegistry } from '@/lib/ai/tools'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { ByokUnavailableError } from '@/lib/byok'
import { trackFeatureUsage } from '@/lib/usage-tracker'
import { readFile } from 'fs/promises'
import path from 'path'
@@ -70,6 +71,12 @@ export async function POST(req: Request) {
if (err instanceof QuotaExceededError) {
return Response.json(err.toJSON(), { status: 402 })
}
if (err instanceof ByokUnavailableError) {
return Response.json(
{ error: 'byok_unavailable', message: 'Votre clé API BYOK est configurée mais n\'a pas pu être chargée. Vérifiez vos paramètres BYOK.' },
{ status: 503 }
)
}
console.error('[chat] Quota check error (fail-open):', err)
}
@@ -410,40 +417,49 @@ Focus ONLY on this note unless asked otherwise.`
]
const wantsChart = chartKeywords.some(k => lastMessage.includes(k))
const { result, usedByok } = await runLaneWithBillingUser(
'chat',
sysConfig,
userId,
async (provider) =>
streamText({
model: provider.getModel(),
system: systemPrompt,
messages: incomingMessages,
tools: chatTools,
toolChoice: wantsChart && chatTools.insert_chart ? { type: 'tool', toolName: 'insert_chart' } : undefined,
stopWhen: stepCountIs(5),
onFinish: async (final) => {
const userContent = incomingMessages[incomingMessages.length - 1].content
await prisma.chatMessage.create({
data: { conversationId: conversation.id, role: 'user', content: userContent },
})
await prisma.chatMessage.create({
data: { conversationId: conversation.id, role: 'assistant', content: final.text },
})
if (!usedByok) {
trackFeatureUsage(userId, 'chat', final.usage?.totalTokens ?? 0)
incrementUsageAsync(userId, 'chat')
}
logAuditEvent({
userId,
action: 'AI_REQUEST',
resource: 'chat',
metadata: { tokens: final.usage?.totalTokens, byok: usedByok },
ip: getClientIp(req),
})
},
}),
)
return result.toUIMessageStreamResponse()
try {
const { result, usedByok } = await runLaneWithBillingUser(
'chat',
sysConfig,
userId,
async (provider) =>
streamText({
model: provider.getModel(),
system: systemPrompt,
messages: incomingMessages,
tools: chatTools,
toolChoice: wantsChart && chatTools.insert_chart ? { type: 'tool', toolName: 'insert_chart' } : undefined,
stopWhen: stepCountIs(5),
onFinish: async (final) => {
const userContent = incomingMessages[incomingMessages.length - 1].content
await prisma.chatMessage.create({
data: { conversationId: conversation.id, role: 'user', content: userContent },
})
await prisma.chatMessage.create({
data: { conversationId: conversation.id, role: 'assistant', content: final.text },
})
if (!usedByok) {
trackFeatureUsage(userId, 'chat', final.usage?.totalTokens ?? 0)
incrementUsageAsync(userId, 'chat')
}
logAuditEvent({
userId,
action: 'AI_REQUEST',
resource: 'chat',
metadata: { tokens: final.usage?.totalTokens, byok: usedByok },
ip: getClientIp(req),
})
},
}),
)
return result.toUIMessageStreamResponse()
} catch (err) {
if (err instanceof ByokUnavailableError) {
return Response.json(
{ error: 'byok_unavailable', message: 'Votre clé API BYOK est configurée mais n\'a pas pu être chargée. Vérifiez vos paramètres dans Réglages > Clés API.' },
{ status: 503 }
)
}
throw err
}
}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getSystemConfig } from '@/lib/config';
import { getAnyActiveByokForUser } from '@/lib/byok';
import { resolveAiRoute } from '@/lib/ai/router';
/**
* GET /api/user/ai-status
* Returns the effective AI provider and model for the current user.
* Used by the UI to show which model is active (BYOK vs admin).
*/
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getSystemConfig();
const adminRoute = resolveAiRoute('chat', config);
const byok = await getAnyActiveByokForUser(session.user.id, adminRoute.providerType);
if (byok) {
const model = (byok.model && byok.model.trim()) ? byok.model : adminRoute.modelName;
return NextResponse.json({
usedByok: true,
provider: byok.provider,
model,
source: 'byok',
});
}
return NextResponse.json({
usedByok: false,
provider: adminRoute.providerType,
model: adminRoute.modelName,
source: 'admin',
});
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';
import { encryptApiKey, hashApiKey } from '@/lib/crypto';
import { VALID_PROVIDERS } from '@/lib/ai/router';
type RouteContext = { params: Promise<{ provider: string }> };
@@ -16,14 +17,34 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'Unknown provider' }, { status: 400 });
}
const body = (await req.json().catch(() => ({}))) as { isActive?: boolean };
if (typeof body.isActive !== 'boolean') {
return NextResponse.json({ error: 'isActive boolean required' }, { status: 400 });
const body = (await req.json().catch(() => ({}))) as {
isActive?: boolean;
model?: string;
alias?: string;
baseUrl?: string;
apiKey?: string; // optional — only when rotating the key
};
const data: Record<string, unknown> = {};
if (typeof body.isActive === 'boolean') data.isActive = body.isActive;
if (body.model !== undefined) data.model = body.model || null;
if (body.alias !== undefined) data.alias = body.alias || '';
if (body.baseUrl !== undefined) data.baseUrl = body.baseUrl || null;
// Key rotation: only when new key explicitly provided
if (body.apiKey && body.apiKey.length >= 8) {
data.encryptedKey = await encryptApiKey(body.apiKey);
data.keyHash = hashApiKey(body.apiKey);
}
if (Object.keys(data).length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
const updated = await prisma.userAPIKey.updateMany({
where: { userId: session.user.id, provider },
data: { isActive: body.isActive },
data,
});
if (updated.count === 0) {

View File

@@ -1,15 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getEffectiveTier } from '@/lib/entitlements';
import { getAllowedByokProviders, isByokProviderAllowed } from '@/lib/byok';
import { fetchLiveModelsForProvider } from '@/lib/ai/models-list';
import { isByokProviderAllowed } from '@/lib/byok';
import { fetchLiveModelsForProvider, PROVIDER_MODEL_SUGGESTIONS, type FetchModelsResult } from '@/lib/ai/models-list';
import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router';
// Providers that return static suggestions regardless of key
const STATIC_PROVIDERS = new Set(['anthropic', 'anthropic_custom', 'custom_anthropic', 'google', 'minimax']);
/**
* GET /api/user/api-keys/live-models?provider=<provider>&key=<api_key>&baseUrl=<optional_custom_url>
* GET /api/user/api-keys/live-models?provider=<provider>[&key=<api_key>][&baseUrl=<url>]
*
* Dynamically queries the third-party provider's API with the user's key to fetch
* actual available models dynamically.
* - Static providers (minimax, anthropic, google): returns suggestions immediately, no key needed.
* - Live providers (openai, deepseek…): requires key to fetch live from provider.
*/
export async function GET(request: NextRequest) {
const session = await auth();
@@ -24,11 +27,11 @@ export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const provider = searchParams.get('provider') as AiGatewayProvider;
const apiKey = searchParams.get('key');
const apiKey = searchParams.get('key') ?? '';
const baseUrl = searchParams.get('baseUrl') ?? undefined;
if (!provider || !apiKey) {
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 });
if (!provider) {
return NextResponse.json({ error: 'Missing provider' }, { status: 400 });
}
if (!VALID_PROVIDERS.has(provider)) {
@@ -39,7 +42,18 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Tier restricted' }, { status: 403 });
}
const models = await fetchLiveModelsForProvider(provider, apiKey, baseUrl);
// Static suggestion providers: return immediately without a key
if (STATIC_PROVIDERS.has(provider)) {
const base = provider === 'anthropic_custom' || provider === 'custom_anthropic' ? 'anthropic' : provider;
const models = PROVIDER_MODEL_SUGGESTIONS[base] ?? [];
return NextResponse.json({ success: true, models, fromApi: false });
}
return NextResponse.json({ success: true, models });
// Live providers need a key
if (!apiKey || apiKey.length < 4) {
return NextResponse.json({ success: true, models: PROVIDER_MODEL_SUGGESTIONS[provider] ?? [], fromApi: false });
}
const result: FetchModelsResult = await fetchLiveModelsForProvider(provider, apiKey, baseUrl);
return NextResponse.json({ success: true, models: result.models, fromApi: result.fromApi });
}

View File

@@ -19,7 +19,7 @@ const createSchema = z.object({
apiKey: z.string().min(8),
alias: z.string().max(120).optional(),
model: z.string().max(120).optional(),
baseUrl: z.string().url().optional(),
baseUrl: z.string().max(2000).optional(),
});
import { PROVIDER_MODEL_SUGGESTIONS } from '@/lib/ai/models-list';
@@ -75,8 +75,9 @@ export async function POST(req: NextRequest) {
);
}
const effectiveBaseUrl = provider === 'custom' ? body.baseUrl : undefined;
if (provider !== 'custom' && body.baseUrl) {
const effectiveBaseUrl = (provider === 'custom' || provider === 'custom_openai' || provider === 'custom_anthropic') ? body.baseUrl : undefined;
const allowsBaseUrl = provider === 'custom' || provider === 'custom_openai' || provider === 'custom_anthropic';
if (!allowsBaseUrl && body.baseUrl) {
return NextResponse.json(
{ error: 'INVALID_REQUEST', message: 'baseUrl is only allowed for custom providers' },
{ status: 400 },
@@ -96,6 +97,7 @@ export async function POST(req: NextRequest) {
plaintext: body.apiKey,
alias: body.alias,
model: body.model,
baseUrl: effectiveBaseUrl,
});
return NextResponse.json({ key: toPublicApiKey(row) });

View File

@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getEffectiveTier } from '@/lib/entitlements';
import { isByokProviderAllowed } from '@/lib/byok';
import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router';
function cleanReply(text: string): string {
// Strip <think>...</think> reasoning blocks (DeepSeek, MiniMax, etc.)
return text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/<thinking>[\s\S]*?<\/thinking>/gi, '').trim() || text.trim()
}
const PROVIDER_URLS: Record<string, string> = {
openai: 'https://api.openai.com/v1',
deepseek: 'https://api.deepseek.com/v1',
openrouter: 'https://openrouter.ai/api/v1',
mistral: 'https://api.mistral.ai/v1',
zai: 'https://api.zukijourney.com/v1',
minimax: 'https://api.minimax.io/v1',
glm: 'https://open.bigmodel.ai/api/paas/v4',
};
/**
* GET /api/user/api-keys/test-model?provider=X&key=Y&model=Z[&baseUrl=...]
* Sends a minimal chat completion to verify the key + model work end-to-end.
*/
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const tier = await getEffectiveTier(session.user.id);
if (tier === 'BASIC') return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { searchParams } = request.nextUrl;
const provider = searchParams.get('provider') as AiGatewayProvider;
const apiKey = searchParams.get('key') ?? '';
const model = searchParams.get('model') ?? '';
const baseUrl = searchParams.get('baseUrl') ?? undefined;
if (!provider || apiKey.length < 4 || !model) {
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 });
}
if (!VALID_PROVIDERS.has(provider)) {
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
}
if (!isByokProviderAllowed(tier, provider)) {
return NextResponse.json({ error: 'Tier restricted' }, { status: 403 });
}
const start = Date.now();
try {
// Anthropic has a different API format
if (provider === 'anthropic' || provider === 'anthropic_custom' || provider === 'custom_anthropic') {
const url = baseUrl
? `${baseUrl.replace(/\/$/, '')}/messages`
: 'https://api.anthropic.com/v1/messages';
const res = await fetch(url, {
method: 'POST',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model,
max_tokens: 60,
messages: [{ role: 'user', content: 'Say: ok' }],
}),
signal: AbortSignal.timeout(20_000),
});
const latency = Date.now() - start;
if (res.status === 401 || res.status === 403) {
return NextResponse.json({ ok: false, error: 'Clé API invalide' });
}
if (res.status === 404) {
return NextResponse.json({ ok: false, error: `Modèle "${model}" introuvable` });
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
return NextResponse.json({ ok: false, error: body?.error?.message ?? `Erreur ${res.status}` });
}
const body = await res.json();
const raw = body?.content?.[0]?.text ?? '(réponse reçue)';
return NextResponse.json({ ok: true, latency, reply: cleanReply(raw) });
}
// Google AI
if (provider === 'google') {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${apiKey}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ contents: [{ parts: [{ text: 'Reply with: ok' }] }], generationConfig: { maxOutputTokens: 5 } }),
signal: AbortSignal.timeout(20_000),
});
const latency = Date.now() - start;
if (!res.ok) {
const body = await res.json().catch(() => ({}));
if (res.status === 400 && body?.error?.message?.includes('not found')) {
return NextResponse.json({ ok: false, error: `Modèle "${model}" introuvable` });
}
return NextResponse.json({ ok: false, error: body?.error?.message ?? `Erreur ${res.status}` });
}
const body = await res.json();
const raw = body?.candidates?.[0]?.content?.parts?.[0]?.text ?? '(réponse reçue)';
return NextResponse.json({ ok: true, latency, reply: cleanReply(raw) });
}
// OpenAI-compatible (openai, deepseek, minimax, openrouter, mistral, glm, zai, custom_openai, custom, lmstudio)
const url = (provider === 'custom' || provider === 'custom_openai')
? `${(baseUrl ?? '').replace(/\/$/, '')}/chat/completions`
: `${PROVIDER_URLS[provider] ?? ''}/chat/completions`;
if (!url || url.startsWith('/')) {
return NextResponse.json({ ok: false, error: 'URL de l\'API manquante' });
}
const headers: Record<string, string> = {
'content-type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
};
if (provider === 'openrouter') {
headers['HTTP-Referer'] = 'https://memento-note.com';
headers['X-Title'] = 'Memento';
}
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
model,
max_tokens: 5,
messages: [{ role: 'user', content: 'Reply with: ok' }],
}),
signal: AbortSignal.timeout(20_000),
});
const latency = Date.now() - start;
if (res.status === 401 || res.status === 403) {
return NextResponse.json({ ok: false, error: 'Clé API invalide ou refusée' });
}
if (res.status === 404) {
return NextResponse.json({ ok: false, error: `Modèle "${model}" introuvable` });
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const msg = body?.error?.message ?? body?.message ?? `Erreur ${res.status}`;
return NextResponse.json({ ok: false, error: msg });
}
const body = await res.json();
const raw = body?.choices?.[0]?.message?.content ?? '(réponse reçue)';
return NextResponse.json({ ok: true, latency, reply: cleanReply(raw) });
} catch (err: unknown) {
const latency = Date.now() - start;
if (err instanceof Error && err.name === 'TimeoutError') {
return NextResponse.json({ ok: false, error: 'Délai d\'attente dépassé (>20s)' });
}
return NextResponse.json({ ok: false, error: err instanceof Error ? err.message : 'Erreur inconnue', latency });
}
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getEffectiveTier } from '@/lib/entitlements';
import { isByokProviderAllowed } from '@/lib/byok';
import { fetchLiveModelsForProvider, type FetchModelsResult } from '@/lib/ai/models-list';
import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router';
/**
* GET /api/user/api-keys/verify?provider=<provider>&key=<api_key>&baseUrl=<optional_custom_url>
*
* Verifies that the user's API key is valid by attempting to fetch models.
* Returns validity status and available models if successful.
*
* Key is only considered VALID if we can fetch models from the actual provider API.
* Fallback to hardcoded suggestions is NOT considered valid verification.
*/
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const tier = await getEffectiveTier(session.user.id);
if (tier === 'BASIC') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = request.nextUrl;
const provider = searchParams.get('provider') as AiGatewayProvider;
const apiKey = searchParams.get('key');
const baseUrl = searchParams.get('baseUrl') ?? undefined;
if (!provider || !apiKey) {
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 });
}
if (!VALID_PROVIDERS.has(provider)) {
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
}
if (!isByokProviderAllowed(tier, provider)) {
return NextResponse.json({ error: 'Tier restricted' }, { status: 403 });
}
try {
const result: FetchModelsResult = await fetchLiveModelsForProvider(provider, apiKey, baseUrl);
console.log(`[verify] ${provider}: fromApi=${result.fromApi}, models=${result.models.length}`, result.models);
// Only consider key valid if we got models from the REAL API (not fallbacks)
// Exception: Anthropic/Google/custom don't guarantee public /models endpoints, so we accept fallbacks for them
const acceptsFallbacks =
provider === 'anthropic' ||
provider === 'anthropic_custom' ||
provider === 'custom_anthropic' ||
provider === 'google' ||
provider === 'minimax' ||
provider === 'custom' ||
provider === 'custom_openai';
const isValid = result.models.length > 0 && (result.fromApi || acceptsFallbacks);
return NextResponse.json({
valid: isValid,
models: result.models,
fromApi: result.fromApi,
message: isValid
? result.fromApi
? `${result.models.length} modèle(s) trouvé(s) via l'API ${provider}`
: `${result.models.length} modèle(s) suggéré(s) pour ${provider}`
: 'Aucun modèle trouvé - vérifiez votre clé API'
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Clé API invalide';
console.error(`[verify] ${provider} failed:`, error);
return NextResponse.json({
valid: false,
models: [],
fromApi: false,
message
}, { status: 200 }); // Return 200 with valid:false for UI handling
}
}

View File

@@ -2051,15 +2051,15 @@ html.font-system * {
}
.notion-slash-tab-active {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
background: var(--color-brand-accent);
color: #FAF8F4;
border-color: var(--color-brand-accent);
}
.notion-slash-tab-active:hover {
opacity: 0.9;
background: var(--primary);
color: var(--primary-foreground);
background: var(--color-brand-accent);
color: #FAF8F4;
}
.notion-slash-tab-ai {