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 {

View File

@@ -4,7 +4,7 @@ import { useState } from 'react'
import { updateAISettings } from '@/app/actions/ai-settings'
import { DemoModeToggle } from '@/components/demo-mode-toggle'
import { toast } from 'sonner'
import { Loader2, Sparkles, Brain, Languages, Tag, History, Wand2 } from 'lucide-react'
import { Loader2, Sparkles, Brain, Languages, Tag, History, Wand2, ImageIcon } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
@@ -23,6 +23,7 @@ interface AISettingsPanelProps {
autoLabeling: boolean
noteHistory: boolean
noteHistoryMode: 'manual' | 'auto'
svgComplexity?: 'simple' | 'illustrated' | 'rich'
}
}
@@ -73,6 +74,20 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
}
}
const handleSvgComplexityChange = async (value: 'simple' | 'illustrated' | 'rich') => {
setSettings(prev => ({ ...prev, svgComplexity: value }))
try {
setIsPending(true)
await updateAISettings({ svgComplexity: value })
toast.success(t('aiSettings.saved'))
} catch {
toast.error(t('aiSettings.error'))
setSettings(initialSettings)
} finally {
setIsPending(false)
}
}
const handleDemoModeToggle = async (enabled: boolean) => {
setSettings(prev => ({ ...prev, demoMode: enabled }))
try {
@@ -255,6 +270,117 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
)}
</div>
{/* SVG Complexity selector */}
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-paper dark:bg-brand-accent/10 rounded-2xl border border-brand-accent/20 text-brand-accent">
<ImageIcon size={18} />
</div>
<div className="space-y-1">
<h4 className="text-[13px] font-bold text-ink">{t('aiSettings.svgComplexity')}</h4>
<p className="text-[10px] text-concrete leading-relaxed">{t('aiSettings.svgComplexityDesc')}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{([
{
value: 'simple' as const,
label: t('aiSettings.svgComplexitySimple'),
desc: t('aiSettings.svgComplexitySimpleDesc'),
preview: (
<svg viewBox="0 0 80 60" className="w-full h-full">
<rect width="80" height="60" fill="#F5F0E8" rx="4"/>
<circle cx="40" cy="28" r="14" fill="none" stroke="#C4A882" strokeWidth="2.5"/>
<line x1="40" y1="14" x2="40" y2="42" stroke="#C4A882" strokeWidth="2"/>
<line x1="26" y1="28" x2="54" y2="28" stroke="#C4A882" strokeWidth="2"/>
<circle cx="40" cy="28" r="3" fill="#8B4513"/>
</svg>
),
},
{
value: 'illustrated' as const,
label: t('aiSettings.svgComplexityIllustrated'),
desc: t('aiSettings.svgComplexityIllustratedDesc'),
preview: (
<svg viewBox="0 0 80 60" className="w-full h-full">
<defs>
<linearGradient id="bg-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#1a1a2e"/>
<stop offset="100%" stopColor="#16213e"/>
</linearGradient>
<radialGradient id="glow" cx="50%" cy="40%">
<stop offset="0%" stopColor="#5F9EA0" stopOpacity="0.6"/>
<stop offset="100%" stopColor="#5F9EA0" stopOpacity="0"/>
</radialGradient>
</defs>
<rect width="80" height="60" fill="url(#bg-grad)" rx="4"/>
<ellipse cx="40" cy="24" rx="28" ry="18" fill="url(#glow)"/>
<rect x="18" y="30" width="44" height="22" fill="#2a2a4a" rx="3"/>
<rect x="24" y="35" width="8" height="12" fill="#5F9EA0" rx="2" opacity="0.8"/>
<rect x="36" y="32" width="8" height="15" fill="#C4A882" rx="2" opacity="0.8"/>
<rect x="48" y="37" width="8" height="10" fill="#C9A9A6" rx="2" opacity="0.8"/>
<circle cx="40" cy="16" r="8" fill="none" stroke="#5F9EA0" strokeWidth="1.5" opacity="0.7"/>
<circle cx="40" cy="16" r="3" fill="#5F9EA0" opacity="0.9"/>
</svg>
),
},
{
value: 'rich' as const,
label: t('aiSettings.svgComplexityRich'),
desc: t('aiSettings.svgComplexityRichDesc'),
preview: (
<svg viewBox="0 0 80 60" className="w-full h-full">
<rect width="80" height="60" fill="#0F172A" rx="4"/>
<circle cx="40" cy="30" r="8" fill="#5F9EA0" opacity="0.9"/>
<circle cx="16" cy="18" r="6" fill="#C4A882" opacity="0.8"/>
<circle cx="64" cy="18" r="6" fill="#C9A9A6" opacity="0.8"/>
<circle cx="16" cy="42" r="6" fill="#A8B5A0" opacity="0.8"/>
<circle cx="64" cy="42" r="6" fill="#8B4513" opacity="0.8"/>
<line x1="40" y1="30" x2="16" y2="18" stroke="#5F9EA0" strokeWidth="1" opacity="0.5"/>
<line x1="40" y1="30" x2="64" y2="18" stroke="#C4A882" strokeWidth="1" opacity="0.5"/>
<line x1="40" y1="30" x2="16" y2="42" stroke="#A8B5A0" strokeWidth="1" opacity="0.5"/>
<line x1="40" y1="30" x2="64" y2="42" stroke="#C9A9A6" strokeWidth="1" opacity="0.5"/>
<text x="40" y="33" textAnchor="middle" fontSize="4" fill="white" fontFamily="sans-serif">MAIN</text>
<text x="16" y="21" textAnchor="middle" fontSize="3.5" fill="white" fontFamily="sans-serif">idea 1</text>
<text x="64" y="21" textAnchor="middle" fontSize="3.5" fill="white" fontFamily="sans-serif">idea 2</text>
</svg>
),
},
] as const).map((opt) => {
const isActive = (settings.svgComplexity ?? 'simple') === opt.value
return (
<button
key={opt.value}
type="button"
onClick={() => handleSvgComplexityChange(opt.value)}
className={cn(
'relative flex flex-col rounded-2xl border-2 overflow-hidden transition-all duration-200 text-left cursor-pointer',
isActive
? 'border-brand-accent shadow-md shadow-brand-accent/10'
: 'border-border hover:border-brand-accent/40 hover:shadow-sm',
)}
>
<div className="aspect-[4/3] w-full bg-muted/30">
{opt.preview}
</div>
<div className={cn(
'p-3 space-y-0.5 transition-colors',
isActive ? 'bg-brand-accent/5' : 'bg-transparent',
)}>
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold text-ink">{opt.label}</span>
{isActive && (
<div className="w-2.5 h-2.5 rounded-full bg-brand-accent"/>
)}
</div>
<p className="text-[9px] text-concrete leading-relaxed">{opt.desc}</p>
</div>
</button>
)
})}
</div>
</div>
<DemoModeToggle demoMode={settings.demoMode} onToggle={handleDemoModeToggle} />
</div>
</motion.div>

View File

@@ -2,11 +2,12 @@
import { useCallback, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { KeyRound, Loader2, Shield, Trash2 } from 'lucide-react'
import { KeyRound, Loader2, Shield, Trash2, CheckCircle2, XCircle, Zap, FlaskConical, Pencil, X } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
@@ -16,154 +17,359 @@ import {
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
type PublicKey = {
// ─── Provider display info ────────────────────────────────────────────────────
const PROVIDER_INFO: Record<string, { name: string; hint: string }> = {
openai: { name: 'OpenAI', hint: 'GPT-4o, GPT-4' },
anthropic: { name: 'Anthropic', hint: 'Claude 3.5 Sonnet, Haiku' },
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' },
openrouter: { name: 'OpenRouter', hint: 'Accès multi-fournisseurs' },
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
zai: { name: 'Zuki Journey', hint: 'Proxy OpenAI/Anthropic' },
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Proxy compatible Anthropic' },
custom_openai: { name: 'Compatible OpenAI', hint: 'Tout proxy compatible OpenAI' },
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Tout proxy compatible Anthropic' },
custom: { name: 'API personnalisée', hint: 'Votre propre endpoint' },
}
function displayName(provider: string): string {
return PROVIDER_INFO[provider]?.name ?? provider
}
const MANUAL_MODEL_PROVIDERS = new Set(['custom'])
const NEEDS_BASE_URL = new Set(['custom', 'custom_openai', 'custom_anthropic'])
function getDefaultBaseUrl(p: string): string {
if (p === 'custom_openai') return 'https://api.openai.com/v1'
if (p === 'custom_anthropic') return 'https://api.anthropic.com/v1'
return ''
}
// ─── Types ────────────────────────────────────────────────────────────────────
type SavedKey = {
provider: string
alias: string
alias: string | null
model: string | null
baseUrl: string | null
isActive: boolean
lastUsedAt: string | null
}
async function fetchByokKeys(): Promise<{
keys: PublicKey[]
allowedProviders: string[]
providerModels: Record<string, string[]>
}> {
async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> {
const res = await fetch('/api/user/api-keys')
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || body.error || 'Failed to load keys')
}
if (!res.ok) throw new Error('Erreur de chargement')
return res.json()
}
function providerLabel(t: (key: string) => string, provider: string): string {
const key = `byokSettings.providers.${provider}`
const translated = t(key)
return translated === key ? provider : translated
async function fetchModelsFromServer(provider: string, key?: string, baseUrl?: string): Promise<string[]> {
const q = new URLSearchParams({ provider })
if (key) q.set('key', key)
if (baseUrl) q.set('baseUrl', baseUrl)
const res = await fetch(`/api/user/api-keys/live-models?${q}`)
if (!res.ok) return []
const body = await res.json()
return body.success && Array.isArray(body.models) ? body.models : []
}
// ─── Inline edit form for a saved key ────────────────────────────────────────
function EditKeyForm({
savedKey,
onDone,
onInvalidate,
}: {
savedKey: SavedKey
onDone: () => void
onInvalidate: () => void
}) {
const [alias, setAlias] = useState(savedKey.alias ?? '')
const [model, setModel] = useState(savedKey.model ?? '')
const [baseUrl, setBaseUrl] = useState(savedKey.baseUrl ?? getDefaultBaseUrl(savedKey.provider))
const [newKey, setNewKey] = useState('') // empty = keep existing key
const [models, setModels] = useState<string[]>([])
const [loadingModels, setLoadingModels] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null)
const needsUrl = NEEDS_BASE_URL.has(savedKey.provider)
const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider)
// Load models on mount
useState(() => {
if (manualModel) return
fetchModelsFromServer(savedKey.provider, undefined, baseUrl || undefined).then((list) => {
setModels(list)
if (list.length > 0 && !model) setModel(list[0])
})
})
const saveMutation = useMutation({
mutationFn: async () => {
const payload: Record<string, string | undefined> = {
model: model || undefined,
alias: alias.trim() || undefined,
}
if (needsUrl) payload.baseUrl = baseUrl
if (newKey.length >= 8) payload.apiKey = newKey
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(savedKey.provider)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
},
onSuccess: () => {
toast.success(`${displayName(savedKey.provider)} mis à jour ✓`)
onInvalidate()
onDone()
},
onError: (err: Error) => toast.error(err.message),
})
async function testModel() {
const keyToUse = newKey.length >= 8 ? newKey : ''
if (!keyToUse && !model) return
if (!keyToUse) {
toast.info('Pour tester, entrez la clé API dans le champ "Nouvelle clé"')
return
}
setTesting(true)
setTestResult(null)
try {
const q = new URLSearchParams({ provider: savedKey.provider, key: keyToUse, model })
if (needsUrl && baseUrl) q.append('baseUrl', baseUrl)
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
setTestResult(await res.json())
} catch {
setTestResult({ ok: false, error: 'Impossible de contacter le serveur' })
} finally {
setTesting(false)
}
}
const showModelDropdown = !manualModel && models.length > 0
const showModelInput = manualModel || (!loadingModels && models.length === 0)
return (
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-violet-600 dark:text-violet-400">
Modifier {displayName(savedKey.provider)}
</p>
{/* Alias */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Alias</Label>
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="ex. Ma clé pro" />
</div>
{needsUrl && (
<div className="space-y-1">
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">URL de l'API</Label>
<Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
</div>
)}
</div>
{/* Model */}
<div className="space-y-1">
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
{showModelDropdown ? (
<Select value={model} onValueChange={setModel}>
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder="Choisir..." /></SelectTrigger>
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
) : showModelInput ? (
<Input id={`edit-model-${savedKey.provider}`} value={model} onChange={(e) => setModel(e.target.value)} placeholder="ex. MiniMax-M2.7" />
) : <div className="flex items-center gap-2 text-xs text-concrete py-1"><Loader2 className="h-3 w-3 animate-spin" />Chargement...</div>}
</div>
{/* Key rotation (optional) */}
<div className="space-y-1">
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">
Nouvelle clé API <span className="normal-case font-normal text-concrete">(laisser vide pour conserver l'actuelle)</span>
</Label>
<Input
id={`edit-key-${savedKey.provider}`}
type="password"
autoComplete="off"
value={newKey}
onChange={(e) => { setNewKey(e.target.value); setTestResult(null) }}
placeholder="sk-... (optionnel)"
/>
</div>
{/* Test result */}
{testResult && (
<div className={cn(
'flex items-start gap-2 rounded-lg px-3 py-2 text-[10px] border',
testResult.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-400' : 'bg-rose-500/10 border-rose-500/20 text-rose-700 dark:text-rose-400'
)}>
{testResult.ok ? <CheckCircle2 size={12} className="shrink-0 mt-0.5" /> : <XCircle size={12} className="shrink-0 mt-0.5" />}
<span>
{testResult.ok
? `✓ Opérationnel${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
: testResult.error ?? 'Échec'
}
</span>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
type="button"
disabled={!newKey || newKey.length < 8 || testing}
onClick={testModel}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-violet-400 transition-colors disabled:opacity-40 disabled:pointer-events-none"
>
{testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
Tester
</button>
<Button
type="button"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
className="flex-1 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.12em] bg-ink text-paper shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
>
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Enregistrer les modifications'}
</Button>
<button
type="button"
onClick={onDone}
className="h-9 w-9 rounded-lg flex items-center justify-center border border-border text-concrete hover:text-ink hover:bg-muted transition-colors"
>
<X size={13} />
</button>
</div>
</div>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function ByokSettingsPanel() {
const { t } = useLanguage()
const queryClient = useQueryClient()
const [provider, setProvider] = useState('')
const [apiKey, setApiKey] = useState('')
const [alias, setAlias] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [model, setModel] = useState('')
const [customModel, setCustomModel] = useState('')
const [isCustomModel, setIsCustomModel] = useState(false)
const [models, setModels] = useState<string[]>([])
const [loadingModels, setLoadingModels] = useState(false)
const [keyOk, setKeyOk] = useState(false)
const [verifying, setVerifying] = useState(false)
const [editingProvider, setEditingProvider] = useState<string | null>(null)
// Dynamic models fetched directly via user's API Key
const [liveModels, setLiveModels] = useState<string[]>([])
const [isFetchingLiveModels, setIsFetchingLiveModels] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null)
const { data, isLoading, error } = useQuery({
queryKey: ['user', 'api-keys'],
queryFn: fetchByokKeys,
queryFn: loadKeys,
})
const providerModels = data?.providerModels ?? {}
const handleProviderChange = (p: string) => {
setProvider(p)
setApiKey('')
setLiveModels([])
setIsCustomModel(false)
setModel('')
setCustomModel('')
}
// Triggered dynamically to fetch models when user enters/pastes their API key
const fetchLiveModels = async (p: string, key: string) => {
if (!p || !key || key.length < 8) return;
setIsFetchingLiveModels(true)
try {
const query = new URLSearchParams({ provider: p, key })
const res = await fetch(`/api/user/api-keys/live-models?${query.toString()}`)
if (res.ok) {
const body = await res.json()
if (body.success && Array.isArray(body.models)) {
setLiveModels(body.models)
if (body.models.length > 0) {
setModel(body.models[0])
setIsCustomModel(false)
} else {
setIsCustomModel(true)
}
return;
}
}
} catch (err) {
console.error('[fetchLiveModels] Failed:', err)
} finally {
setIsFetchingLiveModels(false)
}
// Fallback if request fails
const fallbackList = providerModels[p] || []
setLiveModels(fallbackList)
if (fallbackList.length > 0) {
setModel(fallbackList[0])
setIsCustomModel(false)
} else {
setIsCustomModel(true)
}
}
const invalidate = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['user', 'api-keys'] })
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] })
}, [queryClient])
async function onProviderChange(p: string) {
setProvider(p)
setApiKey('')
setBaseUrl(getDefaultBaseUrl(p))
setKeyOk(false)
setTestResult(null)
setModel('')
setModels([])
if (MANUAL_MODEL_PROVIDERS.has(p)) return
setLoadingModels(true)
try {
const list = await fetchModelsFromServer(p)
setModels(list)
if (list.length > 0) setModel(list[0])
} finally { setLoadingModels(false) }
}
async function refreshModels(p: string, key: string, _baseUrl?: string) {
if (!p || key.length < 8 || MANUAL_MODEL_PROVIDERS.has(p)) return
const effectiveBaseUrl = _baseUrl ?? (NEEDS_BASE_URL.has(p) ? baseUrl : undefined)
setLoadingModels(true)
try {
const list = await fetchModelsFromServer(p, key, effectiveBaseUrl)
if (list.length > 0) { setModels(list); if (!model) setModel(list[0]) }
} finally { setLoadingModels(false) }
}
async function verifyKey() {
if (!provider || apiKey.length < 8) return
if (NEEDS_BASE_URL.has(provider) && !baseUrl) { toast.error("Veuillez renseigner l'URL de l'API"); return }
setVerifying(true)
setTestResult(null)
try {
const q = new URLSearchParams({ provider, key: apiKey })
if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl)
const res = await fetch(`/api/user/api-keys/verify?${q}`)
const body = await res.json()
if (res.ok && body.valid) {
setKeyOk(true)
if (Array.isArray(body.models) && body.models.length > 0) { setModels(body.models); if (!model) setModel(body.models[0]) }
toast.success('Clé API valide ✓')
} else {
setKeyOk(false)
toast.error(body.message ?? 'Clé API invalide')
}
} catch { setKeyOk(false); toast.error('Erreur de vérification') }
finally { setVerifying(false) }
}
async function testModel() {
if (!provider || apiKey.length < 8 || !model) return
setTesting(true)
setTestResult(null)
try {
const q = new URLSearchParams({ provider, key: apiKey, model })
if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl)
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
setTestResult(await res.json())
} catch { setTestResult({ ok: false, error: 'Impossible de contacter le serveur' }) }
finally { setTesting(false) }
}
const saveMutation = useMutation({
mutationFn: async () => {
const resolvedModel = isCustomModel ? customModel : model
const payload: Record<string, string | undefined> = {
provider, apiKey,
model: model || undefined,
alias: alias.trim() || undefined,
}
if (NEEDS_BASE_URL.has(provider)) payload.baseUrl = baseUrl
const res = await fetch('/api/user/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider,
apiKey,
alias: alias || undefined,
model: resolvedModel || undefined,
}),
body: JSON.stringify(payload),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(body.message || body.error || 'Save failed')
}
return body
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
},
onSuccess: () => {
toast.success(t('byokSettings.saved'))
setApiKey('')
setAlias('')
setProvider('')
setModel('')
setCustomModel('')
setLiveModels([])
setIsCustomModel(false)
toast.success(`Clé ${displayName(provider)} enregistrée ✓`)
setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('')
setModel(''); setModels([]); setKeyOk(false); setTestResult(null)
invalidate()
},
onError: (err: Error) => {
toast.error(err.message || t('byokSettings.error'))
},
onError: (err: Error) => toast.error(err.message),
})
const toggleMutation = useMutation({
mutationFn: async ({
provider: p,
isActive,
}: {
provider: string
isActive: boolean
}) => {
mutationFn: async ({ p, isActive }: { p: string; isActive: boolean }) => {
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive }),
})
if (!res.ok) throw new Error('Update failed')
if (!res.ok) throw new Error('Erreur')
},
onSuccess: invalidate,
onError: () => toast.error(t('byokSettings.error')),
@@ -171,246 +377,238 @@ export function ByokSettingsPanel() {
const deleteMutation = useMutation({
mutationFn: async (p: string) => {
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Delete failed')
},
onSuccess: () => {
toast.success(t('byokSettings.deleted'))
invalidate()
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Erreur')
},
onSuccess: () => { toast.success('Clé supprimée'); invalidate() },
onError: () => toast.error(t('byokSettings.error')),
})
const allowed = data?.allowedProviders ?? []
const keys = data?.keys ?? []
const hasActiveByok = keys.some((k) => k.isActive)
const activeKey = keys.find((k) => k.isActive)
const tierBlocked = !isLoading && allowed.length === 0
const saveDisabled = !provider || apiKey.length < 8 || saveMutation.isPending || (NEEDS_BASE_URL.has(provider) && baseUrl.length < 4)
const canTest = provider && apiKey.length >= 8 && model.length > 0
const showModelDropdown = !MANUAL_MODEL_PROVIDERS.has(provider) && models.length > 0
const showModelInput = !!provider && (MANUAL_MODEL_PROVIDERS.has(provider) || (!loadingModels && models.length === 0))
if (isLoading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground p-5">
<Loader2 className="h-4 w-4 animate-spin" />
{t('byokSettings.loading')}
</div>
)
}
if (error) {
return (
<p className="text-sm text-destructive p-5">{t('byokSettings.loadError')}</p>
)
}
if (isLoading) return <div className="flex items-center gap-2 text-sm text-muted-foreground p-5"><Loader2 className="h-4 w-4 animate-spin" />{t('byokSettings.loading')}</div>
if (error) return <p className="text-sm text-destructive p-5">{t('byokSettings.loadError')}</p>
return (
<div
id="byok"
className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 scroll-mt-6 space-y-6"
>
<div id="byok" className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 scroll-mt-6 space-y-6">
{/* Header */}
<div className="flex items-start gap-5">
<div className="p-3 bg-violet-500/10 rounded-2xl text-violet-500 border border-violet-500/20">
<KeyRound size={18} />
</div>
<div className="space-y-1">
<h3 className="text-[13px] font-bold text-ink flex items-center gap-2 flex-wrap">
{t('byokSettings.title')}
{hasActiveByok && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 text-[10px] font-medium">
<Shield size={10} />
{t('byokSettings.badgeActive')}
</span>
)}
</h3>
<div className="p-3 bg-violet-500/10 rounded-2xl text-violet-500 border border-violet-500/20"><KeyRound size={18} /></div>
<div className="flex-1 space-y-1">
<h3 className="text-[13px] font-bold text-ink">{t('byokSettings.title')}</h3>
<p className="text-[10px] text-concrete leading-relaxed">{t('byokSettings.description')}</p>
</div>
</div>
{tierBlocked ? (
<p className="text-[11px] text-amber-600 dark:text-amber-400 bg-amber-500/10 rounded-xl px-4 py-3">
{t('byokSettings.tierRequired')}
</p>
) : (
<div className="space-y-4 border border-border/60 rounded-2xl p-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="byok-provider" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
<Select
value={provider}
onValueChange={handleProviderChange}
disabled={saveMutation.isPending}
>
<SelectTrigger id="byok-provider">
<SelectValue placeholder={t('byokSettings.providerPlaceholder')} />
</SelectTrigger>
<SelectContent>
{allowed.map((p) => (
<SelectItem key={p} value={p}>
{providerLabel(t, p)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="byok-alias" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.alias')}</Label>
<Input
id="byok-alias"
value={alias}
onChange={(e) => setAlias(e.target.value)}
placeholder={t('byokSettings.aliasPlaceholder')}
disabled={saveMutation.isPending}
/>
</div>
</div>
{/* Model Selection Row */}
{provider && (
<div className="grid gap-4 sm:grid-cols-2 pt-2 border-t border-border/40">
<div className="space-y-2">
<Label htmlFor="byok-model-select" className="text-[10px] font-bold uppercase tracking-widest text-concrete">
Modèle de l'IA (Optionnel)
</Label>
{isFetchingLiveModels ? (
<div className="flex items-center gap-2 text-xs text-concrete py-2">
<Loader2 className="h-3 w-3 animate-spin text-brand-accent" />
Récupération de vos modèles disponibles...
</div>
) : liveModels && liveModels.length > 0 ? (
<Select
value={isCustomModel ? 'custom' : model}
onValueChange={(val) => {
if (val === 'custom') {
setIsCustomModel(true)
} else {
setIsCustomModel(false)
setModel(val)
}
}}
disabled={saveMutation.isPending}
>
<SelectTrigger id="byok-model-select">
<SelectValue placeholder="Choisir un modèle..." />
</SelectTrigger>
<SelectContent>
{liveModels.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
<SelectItem value="custom">Autre / Modèle personnalisé...</SelectItem>
</SelectContent>
</Select>
) : (
<div className="text-xs text-concrete py-2 italic">
Entrez votre clé API ci-dessous pour charger vos modèles.
</div>
)}
</div>
{(isCustomModel || (!isFetchingLiveModels && !(liveModels && liveModels.length > 0))) && (
<div className="space-y-2">
<Label htmlFor="byok-model-custom" className="text-[10px] font-bold uppercase tracking-widest text-concrete">Saisir le nom du modèle</Label>
<Input
id="byok-model-custom"
value={isCustomModel ? customModel : model}
onChange={(e) => {
if (isCustomModel) {
setCustomModel(e.target.value)
} else {
setModel(e.target.value)
}
}}
placeholder="ex. deepseek-reasoner, minimax-abab6.5"
disabled={saveMutation.isPending}
/>
</div>
)}
</div>
{/* BYOK Status */}
{!tierBlocked && (
<div className={cn(
'flex items-center gap-3 rounded-xl px-4 py-3 text-[11px] font-medium border',
activeKey
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20'
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
)}>
{activeKey ? (
<><Zap size={14} className="shrink-0" /><span>BYOK actif · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
) : (
<><Shield size={14} className="shrink-0" /><span>Aucune clé active utilisation des quotas Momento</span></>
)}
<div className="space-y-2">
<Label htmlFor="byok-key" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.apiKey')}</Label>
<Input
id="byok-key"
type="password"
autoComplete="off"
value={apiKey}
onChange={(e) => {
const val = e.target.value
setApiKey(val)
fetchLiveModels(provider, val)
}}
placeholder={t('byokSettings.apiKeyPlaceholder')}
disabled={saveMutation.isPending}
/>
</div>
<button
type="button"
disabled={!provider || apiKey.length < 8 || saveMutation.isPending}
onClick={() => saveMutation.mutate()}
className={cn(
'px-6 py-3 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300',
'bg-ink text-paper shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
'disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none'
)}
>
<div className="flex items-center justify-center gap-2">
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{t('byokSettings.save')}
</div>
</button>
</div>
)}
{keys.length > 0 && (
<ul className="space-y-3">
{keys.map((key) => (
<li
key={key.provider}
className="flex items-center justify-between gap-3 rounded-2xl border border-border/60 px-4 py-3"
>
<div className="min-w-0">
<div className="text-[13px] font-bold text-ink">{providerLabel(t, key.provider)}</div>
<div className="flex flex-col gap-0.5 mt-0.5">
{key.alias ? (
<p className="text-[10px] text-concrete truncate">{key.alias}</p>
) : null}
{key.model ? (
<p className="text-[9px] text-brand-accent font-mono truncate">Modèle : {key.model}</p>
) : null}
</div>
{tierBlocked ? (
<p className="text-[11px] text-amber-600 dark:text-amber-400 bg-amber-500/10 rounded-xl px-4 py-3">{t('byokSettings.tierRequired')}</p>
) : (
<>
{/* Saved keys */}
{keys.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">Clés enregistrées</p>
<ul className="space-y-1">
{keys.map((key) => (
<li key={key.provider}>
<div className={cn(
'flex items-center justify-between gap-3 rounded-xl border px-4 py-3 transition-colors',
key.isActive ? 'border-emerald-500/30 bg-emerald-500/5' : 'border-border/60',
editingProvider === key.provider && 'rounded-b-none border-b-0'
)}>
<div className="min-w-0 flex items-center gap-3">
<div className={cn('h-2 w-2 rounded-full shrink-0', key.isActive ? 'bg-emerald-500' : 'bg-gray-300 dark:bg-white/20')} />
<div>
<div className="text-[13px] font-semibold text-ink leading-tight">{displayName(key.provider)}</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{key.model && <code className="text-[9px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
{key.alias && <span className="text-[9px] text-concrete">{key.alias}</span>}
<span className={cn('text-[9px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
{key.isActive ? '● Actif' : '○ Inactif'}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{/* Edit button */}
<button
type="button"
className={cn(
'h-7 w-7 rounded-lg flex items-center justify-center transition-colors',
editingProvider === key.provider
? 'text-violet-600 bg-violet-500/10 border border-violet-500/30'
: 'text-concrete hover:text-ink hover:bg-muted border border-transparent'
)}
onClick={() => setEditingProvider(editingProvider === key.provider ? null : key.provider)}
>
<Pencil size={12} />
</button>
{/* Toggle */}
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox" className="sr-only peer" checked={key.isActive}
onChange={(e) => toggleMutation.mutate({ p: key.provider, isActive: e.target.checked })}
disabled={toggleMutation.isPending}
/>
<div className="w-9 h-5 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[16px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[3px] after:left-[3px] after:bg-white after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-emerald-500" />
</label>
{/* Delete */}
<button
type="button"
className="h-7 w-7 rounded-lg flex items-center justify-center text-destructive/50 hover:text-destructive hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors"
disabled={deleteMutation.isPending}
onClick={() => { if (confirm(`Supprimer la clé ${displayName(key.provider)} ?`)) deleteMutation.mutate(key.provider) }}
>
<Trash2 size={12} />
</button>
</div>
</div>
{/* Inline edit form */}
{editingProvider === key.provider && (
<div className="border border-violet-500/20 border-t-0 rounded-b-xl overflow-hidden">
<EditKeyForm
savedKey={key}
onDone={() => setEditingProvider(null)}
onInvalidate={invalidate}
/>
</div>
)}
</li>
))}
</ul>
</div>
)}
{/* Add key form */}
<div className="space-y-4 border border-border/60 rounded-2xl p-5">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
{keys.length > 0 ? 'Ajouter / remplacer une clé' : 'Connecter votre fournisseur IA'}
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Fournisseur</Label>
<Select value={provider} onValueChange={onProviderChange} disabled={saveMutation.isPending}>
<SelectTrigger id="byok-provider"><SelectValue placeholder="Choisir un fournisseur..." /></SelectTrigger>
<SelectContent>
{allowed.map((p) => (
<SelectItem key={p} value={p}>
<span className="font-medium">{displayName(p)}</span>
{PROVIDER_INFO[p]?.hint && <span className="ml-2 text-[10px] text-concrete">{PROVIDER_INFO[p].hint}</span>}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3 shrink-0">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={key.isActive}
onChange={(e) => toggleMutation.mutate({ provider: key.provider, isActive: e.target.checked })}
disabled={toggleMutation.isPending}
<div className="space-y-1.5">
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Alias <span className="normal-case font-normal">(optionnel)</span></Label>
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="ex. Ma clé pro" disabled={saveMutation.isPending} />
</div>
</div>
{NEEDS_BASE_URL.has(provider) && (
<div className="space-y-1.5">
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">URL de l'API</Label>
<Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Clé API</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="byok-key" type="password" autoComplete="off" value={apiKey}
onChange={(e) => { const val = e.target.value; setApiKey(val); setKeyOk(false); setTestResult(null); refreshModels(provider, val, baseUrl) }}
placeholder="sk-..." disabled={saveMutation.isPending}
className={cn(keyOk && 'border-emerald-500/60')}
/>
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-brand-accent" />
</label>
{keyOk && <CheckCircle2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-emerald-500 pointer-events-none" />}
</div>
<button
type="button"
className="h-8 w-8 rounded-lg flex items-center justify-center text-destructive/60 hover:text-destructive hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors"
disabled={deleteMutation.isPending}
onClick={() => {
if (confirm(t('byokSettings.confirmDelete'))) {
deleteMutation.mutate(key.provider)
}
}}
type="button" disabled={!provider || apiKey.length < 8 || verifying} onClick={verifyKey}
className={cn('shrink-0 px-4 py-2 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-violet-400')}
>
<Trash2 size={14} />
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : 'Vérifier'}
</button>
</div>
</li>
))}
</ul>
)}
</div>
{!tierBlocked && keys.length === 0 && (
<p className="text-[11px] text-concrete">{t('byokSettings.empty')}</p>
{provider && (
<div className="space-y-1.5">
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
{loadingModels ? (
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />Récupération des modèles...</div>
) : showModelDropdown ? (
<Select value={model} onValueChange={(v) => { setModel(v); setTestResult(null) }} disabled={saveMutation.isPending}>
<SelectTrigger id="byok-model"><SelectValue placeholder="Choisir un modèle..." /></SelectTrigger>
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
) : showModelInput ? (
<Input id="byok-model" value={model} onChange={(e) => { setModel(e.target.value); setTestResult(null) }} placeholder="ex. gpt-4o-mini, MiniMax-M2.7" disabled={saveMutation.isPending} />
) : null}
</div>
)}
{testResult && (
<div className={cn('flex items-start gap-3 rounded-xl px-4 py-3 text-[11px] border',
testResult.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-400' : 'bg-rose-500/10 border-rose-500/20 text-rose-700 dark:text-rose-400')}>
{testResult.ok ? <CheckCircle2 size={14} className="shrink-0 mt-0.5" /> : <XCircle size={14} className="shrink-0 mt-0.5" />}
<div className="space-y-0.5">
{testResult.ok ? (
<><p className="font-semibold">Modèle opérationnel ✓{testResult.latency && <span className="font-normal ml-1">({testResult.latency}ms)</span>}</p>{testResult.reply && <p className="opacity-70 font-mono text-[10px]">Réponse : {testResult.reply}</p>}</>
) : <p className="font-semibold">{testResult.error ?? 'Échec du test'}</p>}
</div>
</div>
)}
<div className="flex gap-2">
<button
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-violet-400 disabled:opacity-40 disabled:pointer-events-none"
>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}Tester
</button>
<Button
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
className="flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.15em] transition-all duration-200 bg-ink text-paper shadow-lg hover:scale-[1.01] active:scale-[0.99] disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none"
>
<div className="flex items-center justify-center gap-2">
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{provider ? `Enregistrer — ${displayName(provider)}` : 'Enregistrer'}
</div>
</Button>
</div>
</div>
{keys.length === 0 && <p className="text-[11px] text-concrete">{t('byokSettings.empty')}</p>}
</>
)}
</div>
)

View File

@@ -13,7 +13,7 @@ import {
Trash2, Copy, Repeat, Link, ChevronRight,
Heading1, Heading2, Heading3, List, ListOrdered,
CheckSquare, Quote, CodeXml, Database,
ArrowUp, ArrowDown, AlignLeft, ClipboardCopy,
ArrowUp, ArrowDown, AlignLeft, ClipboardCopy, Sparkles,
} from 'lucide-react'
import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension'
@@ -80,7 +80,7 @@ function getBlockPlainContent(editor: Editor, blockPos: number, blockNode: PMNod
const from = blockPos + 1
const to = blockPos + node.nodeSize - 1
if (to > from) {
return editor.state.doc.textBetween(from, to, '\n', '\0').trim()
return editor.state.doc.textBetween(from, to, '\n', '').trim()
}
return node.textContent?.trim() ?? ''
}
@@ -208,6 +208,63 @@ export function BlockActionMenu({
onClose()
}, [blockNode, blockPos, editor, noteId, onBlockReferenceCopied, onClose, sourceNoteTitle, t])
const handleCreateDiagram = useCallback(async () => {
const text = getBlockPlainContent(editor, blockPos, blockNode)
if (!text || text.trim().length < 5) {
toast.error(t('blockAction.createDiagramEmpty') || "Le texte est trop court pour générer un diagramme.")
onClose()
return
}
onClose()
const toastId = toast.loading(t('blockAction.createDiagramLoading') || "Génération du diagramme Excalidraw par l'IA...")
try {
const { generateDiagramFromText } = await import('@/app/actions/diagram')
const res = await generateDiagramFromText(text)
if (!res.success || !res.canvasId) {
throw new Error(res.error || "La génération du diagramme a échoué.")
}
const canvasId = res.canvasId
const canvasRes = await fetch(`/api/canvas?id=${encodeURIComponent(canvasId)}`)
const canvasData = await canvasRes.json()
if (!canvasRes.ok || !canvasData.canvas?.data) {
throw new Error("Impossible de charger les données du diagramme généré.")
}
const { exportExcalidrawSceneToPngBlob } = await import('@/lib/client/excalidraw-export-image')
const blob = await exportExcalidrawSceneToPngBlob(canvasData.canvas.data)
if (!blob) {
throw new Error("Échec du rendu du diagramme en image PNG.")
}
const fd = new FormData()
fd.append('file', blob, `diagram-${canvasId.slice(-8)}.png`)
const up = await fetch('/api/upload', { method: 'POST', body: fd })
const upJson = await up.json()
if (!up.ok || !upJson.url) {
throw new Error("Échec du téléversement du diagramme généré.")
}
const imageUrl = upJson.url
const insertPos = blockPos + (blockNode ? blockNode.nodeSize : 0)
const htmlToInsert = `<p><a href="/lab?id=${canvasId}" target="_blank"><img src="${imageUrl}" alt="Diagramme Excalidraw" style="max-width: 100%; border-radius: 8px; border: 1px solid rgba(0,0,0,0.1);" /></a></p><p>🎨 <a href="/lab?id=${canvasId}" target="_blank"><strong>${t('blockAction.createDiagramSuccess') || "Éditer le diagramme dans le Lab Excalidraw"}</strong></a></p>`.trim();
editor.chain().focus().insertContentAt(insertPos, htmlToInsert).run()
toast.success(t('blockAction.createDiagramSuccess') || "Diagramme généré et inséré avec succès !", { id: toastId })
} catch (err: any) {
console.error('[handleCreateDiagram] Error:', err)
toast.error(err.message || "Une erreur est survenue lors de la génération.", { id: toastId })
}
}, [editor, blockPos, blockNode, onClose, t])
const handleTurnInto = useCallback((option: TurnIntoOption) => {
if (blockPos >= 0 && blockNode) {
if (option.isDatabase) {
@@ -312,6 +369,14 @@ export function BlockActionMenu({
<div className="block-action-separator" />
{/* Création de diagramme */}
<button type="button" className="block-action-item" onClick={() => { void handleCreateDiagram() }}>
<Sparkles size={16} className="text-amber-500 transition-all duration-200" />
<span className="font-medium text-amber-700 dark:text-amber-400">{t('blockAction.createDiagram')}</span>
</button>
<div className="block-action-separator" />
{/* Copier */}
<button type="button" className="block-action-item" onClick={() => { void handleCopyContent() }}>
<ClipboardCopy size={16} />

View File

@@ -557,6 +557,15 @@ export function HomeClient({
emitNoteChange({ type: 'updated', note: fresh })
}, [patchNoteInList])
const handleNoteIllustrationDeleted = useCallback((noteId: string) => {
patchNoteInList(noteId, { illustrationSvg: null })
setNotes((prev) => {
const updated = prev.find((n) => n.id === noteId)
if (updated) emitNoteChange({ type: 'updated', note: { ...updated, illustrationSvg: null } })
return prev
})
}, [patchNoteInList])
const handleGridReorder = useCallback(
async (orderedIds: string[]) => {
setSortOrder('manual')
@@ -1174,6 +1183,7 @@ export function HomeClient({
onMoveToNotebook={handleMoveNoteToNotebook}
onNotePatch={handleNoteContentPatch}
onNoteIllustrationGenerated={handleNoteIllustrationGenerated}
onNoteIllustrationDeleted={handleNoteIllustrationDeleted}
onGridReorder={handleGridReorder}
/>
)}

View File

@@ -1,7 +1,7 @@
'use client'
import { useLanguage } from '@/lib/i18n'
import { acceptEssentialsOnly, acceptAllOptional } from '@/lib/consent/cookie-consent'
import { saveConsentWithSync } from '@/lib/consent/cookie-consent'
interface CookieConsentBannerProps {
onManage: () => void
@@ -29,14 +29,14 @@ export function CookieConsentBanner({ onManage }: CookieConsentBannerProps) {
<div className="flex flex-wrap items-center gap-2 shrink-0">
<button
type="button"
onClick={() => acceptEssentialsOnly()}
onClick={() => saveConsentWithSync(false, false)}
className="px-4 py-2.5 border border-border rounded-xl text-[10px] font-bold uppercase tracking-[0.15em] text-ink hover:bg-white/60 dark:hover:bg-white/5 transition-colors"
>
{t('consent.banner.acceptEssentials')}
</button>
<button
type="button"
onClick={() => acceptEssentialsOnly()}
onClick={() => saveConsentWithSync(false, false)}
className="px-4 py-2.5 text-[10px] font-bold uppercase tracking-[0.15em] text-concrete hover:text-ink transition-colors"
>
{t('consent.banner.rejectNonEssential')}
@@ -50,7 +50,7 @@ export function CookieConsentBanner({ onManage }: CookieConsentBannerProps) {
</button>
<button
type="button"
onClick={() => acceptAllOptional()}
onClick={() => saveConsentWithSync(true, false)}
className="px-5 py-2.5 bg-foreground text-background rounded-xl text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 transition-opacity"
>
{t('consent.banner.acceptAll')}

View File

@@ -10,7 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { getConsent, setConsent } from '@/lib/consent/cookie-consent'
import { getConsent, saveConsentWithSync } from '@/lib/consent/cookie-consent'
import { toast } from 'sonner'
interface CookiePreferencesDialogProps {
@@ -29,7 +29,7 @@ export function CookiePreferencesDialog({ open, onOpenChange }: CookiePreference
}, [open])
const handleSave = () => {
setConsent({ analytics, marketing: false })
saveConsentWithSync(analytics, false)
toast.success(t('consent.preferences.saved'))
onOpenChange(false)
}

View File

@@ -20,6 +20,21 @@ interface MarkdownContentProps {
}
export const MarkdownContent = memo(function MarkdownContent({ content, className }: MarkdownContentProps) {
// Strip <think>...</think> and <thinking>...</thinking> blocks produced by reasoning models
// (MiniMax, DeepSeek R1, etc.) before passing to ReactMarkdown — rehypeRaw would otherwise
// try to render them as HTML elements causing React warnings.
// Also handles partial/unclosed blocks during streaming (e.g. <think> without </think> yet).
const safeContent = useMemo(
() => content
.replace(/<think>[\s\S]*?<\/think>/gi, '') // complete think blocks
.replace(/<think>[\s\S]*/i, '') // unclosed think block (still streaming)
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '') // complete thinking blocks
.replace(/<thinking>[\s\S]*/i, '') // unclosed thinking block
.trim(),
[content]
)
return (
<div dir="auto" className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
<ReactMarkdown
@@ -56,8 +71,9 @@ export const MarkdownContent = memo(function MarkdownContent({ content, classNam
},
}}
>
{content}
{safeContent}
</ReactMarkdown>
</div>
)
})

View File

@@ -13,7 +13,7 @@ import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { useSession } from 'next-auth/react'
import { getAISettings } from '@/app/actions/ai-settings'
import { getAISettings, updateAISettings } from '@/app/actions/ai-settings'
import { extractImagesFromHTML } from '@/lib/utils'
import { queryKeys } from '@/lib/query-keys'
import type { RichTextEditorHandle } from '@/components/rich-text-editor'
@@ -40,12 +40,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
useEffect(() => {
if (session?.user?.id) {
getAISettings(session.user.id).then(settings => {
setAiAssistantEnabled(settings.paragraphRefactor !== false)
setAutoLabelingEnabled(settings.autoLabeling !== false)
setAutoSaveEnabled(settings.autoSave !== false)
}).catch(err => console.error("Failed to fetch AI settings", err))
}
}, [session?.user?.id])
@@ -64,6 +66,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setContentState(newVal)
}, [])
// setContent : met à jour contentRef immédiatement (pour les saves),
// mais ne déclenche un re-render React qu'après 800ms (throttle) pour éviter
// un render à chaque frappe.
const setContent = useCallback((newVal: string) => {
contentRef.current = newVal
if (debounceTimeoutRef.current) {
@@ -71,7 +76,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
debounceTimeoutRef.current = setTimeout(() => {
setContentState(newVal)
}, 1000)
}, 800)
}, [])
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
const [labels, setLabels] = useState<string[]>(note.labels || [])
@@ -364,8 +369,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const handleGenerateTitles = async () => {
// Utiliser le contenu live de l'éditeur (TipTap) plutôt que le state debounced
const liveContent = resolveContentForSave()
const fullContentForAI = [
content,
liveContent,
...links.map(l => `${l.title || ''} ${l.description || ''}`)
]
.join(' ')
@@ -386,7 +393,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const response = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: fullContentForAI }),
body: JSON.stringify({ content: fullContentForAI.substring(0, 8000) }),
})
if (!response.ok) {
@@ -658,7 +665,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
}
const handleSave = async () => {
// Fonction de sauvegarde unifiée — évite la duplication handleSave/handleSaveInPlace
const performSave = useCallback(async ({ silent = false, inPlace = false }: { silent?: boolean; inPlace?: boolean } = {}) => {
setIsSaving(true)
try {
const contentToSave = resolveContentForSave()
@@ -677,7 +685,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
size,
}, { skipRevalidation: true })
if (contentToSave !== content) setContentImmediate(contentToSave)
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
if (imagesToSave.length !== images.length || imagesToSave.some((u, i) => u !== images[i])) setImages(imagesToSave)
prevNoteRef.current = { ...prevNoteRef.current, ...result }
const deletedImages = Array.from(new Set([
...removedImageUrls,
@@ -694,14 +702,19 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
emitNoteChange({ type: 'updated', note: result })
setIsDirty(false)
setLastSavedAt(new Date())
toast.success(t('notes.saved') || 'Note sauvegardée !')
if (!silent) toast.success(t('notes.saved') || 'Note sauvegardée !')
} catch (error) {
console.error('[SAVE] updateNote failed:', error)
toast.error(t('notes.saveFailed') || 'Erreur lors de la sauvegarde.')
} finally {
setIsSaving(false)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [note.id, note.notebookId, title, labels, images, links, color, currentReminder, isMarkdown, size, removedImageUrls, content, t])
// Aliases stables pour la compatibilité
const handleSave = useCallback((opts?: { silent?: boolean }) => performSave({ ...opts, inPlace: false }), [performSave])
const handleSaveInPlace = useCallback((opts?: { silent?: boolean }) => performSave({ ...opts, inPlace: true }), [performSave])
const handleCheckItem = (id: string) => {
setCheckItems(items =>
@@ -783,73 +796,47 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
}
const handleSaveInPlace = async () => {
setIsSaving(true)
try {
const contentToSave = resolveContentForSave()
const imagesToSave = resolveImagesForSave(contentToSave)
const updatePayload = {
title: title.trim() || null,
content: contentToSave,
checkItems: null,
labels,
images: imagesToSave,
links,
color,
reminder: currentReminder,
isMarkdown,
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
size,
}
const result = await updateNote(note.id, updatePayload, { skipRevalidation: true })
if (contentToSave !== content) setContentImmediate(contentToSave)
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
prevNoteRef.current = { ...prevNoteRef.current, ...result }
const deletedImages = Array.from(new Set([
...removedImageUrls,
...images.filter(url => !imagesToSave.includes(url))
]))
if (deletedImages.length > 0) {
cleanupOrphanedImages(deletedImages, note.id).catch(() => {})
setRemovedImageUrls([])
}
await refreshLabels()
onNoteSaved?.(result)
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
emitNoteChange({ type: 'updated', note: result })
setIsDirty(false)
setLastSavedAt(new Date())
toast.success(t('notes.saved') || 'Saved')
} catch (error) {
console.error('[SAVE] updateNote failed:', error)
toast.error(t('notes.saveFailed') || 'Save failed')
} finally {
setIsSaving(false)
}
}
// handleSaveInPlace était un alias de handleSave — supprimé, maintenant unifié dans performSave
const handleSaveInPlaceRef = useRef(handleSaveInPlace)
handleSaveInPlaceRef.current = handleSaveInPlace
const handleSaveRef = useRef(handleSave)
handleSaveRef.current = handleSave
// Auto-save : 2s après le dernier changement si isDirty
const toggleAutoSave = useCallback(async () => {
const nextVal = !autoSaveEnabled
setAutoSaveEnabled(nextVal)
if (session?.user?.id) {
try {
await updateAISettings({ autoSave: nextVal })
toast.success(
nextVal
? t('settings.autoSaveEnabled') || 'Auto-enregistrement activé !'
: t('settings.autoSaveDisabled') || 'Auto-enregistrement désactivé'
)
} catch (err) {
console.error("Failed to update autoSave setting:", err)
toast.error(t('general.error'))
}
}
}, [autoSaveEnabled, session?.user?.id, t])
// Auto-save : 8s après le dernier changement si isDirty et autoSave est activé (silencieux — pas de toast)
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!isDirty || isSaving || readOnly) return
if (!autoSaveEnabled || !isDirty || isSaving || readOnly) return
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current)
autoSaveTimerRef.current = setTimeout(() => {
if (fullPage) {
void handleSaveInPlaceRef.current()
void handleSaveInPlaceRef.current({ silent: true })
} else {
void handleSaveRef.current()
void handleSaveRef.current({ silent: true })
}
}, 2000)
}, 8000)
return () => {
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current)
}
}, [isDirty, isSaving, readOnly, fullPage])
}, [isDirty, isSaving, readOnly, fullPage, autoSaveEnabled])
useEffect(() => {
if (!fullPage) return
@@ -914,13 +901,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
allImages,
colorClasses,
quotaExceededFeature,
autoSaveEnabled,
}), [
title, content, checkItems, labels, images, links, newLabel, color, size,
showMarkdownPreview, removedImageUrls, isSaving, isDirty, lastSavedAt, isProcessingAI, aiOpen, infoOpen,
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses, quotaExceededFeature
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses, quotaExceededFeature, autoSaveEnabled
])
const actions: NoteEditorActions = useMemo(() => ({
@@ -975,27 +963,34 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setIsAnalyzingSuggestions: (_a) => { /* handled by useAutoTagging */ },
setPreviousContentForCopilot,
setQuotaExceededFeature,
toggleAutoSave,
}), [
handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem,
handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload,
handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave,
handleRemoveReminder, handleGenerateTitles, handleSelectTitle, handleReformulate,
handleApplyRefactor, handleClarifyDirect, handleShortenDirect, handleImproveDirect,
handleTransformMarkdown, handleSave, handleSaveInPlace, handleMakeCopy, setQuotaExceededFeature
handleTransformMarkdown, handleSave, handleSaveInPlace, handleMakeCopy, setQuotaExceededFeature, toggleAutoSave
])
// Notebooks mappés en dehors du useMemo pour éviter de recréer le tableau à chaque render du contexte
const mappedNotebooks = useMemo(
() => notebooks.map(nb => ({ id: nb.id, name: nb.name, parentId: nb.parentId, trashedAt: nb.trashedAt })),
[notebooks]
)
const value: NoteEditorContextValue = useMemo(() => ({
note,
readOnly,
fullPage,
state,
actions,
notebooks: notebooks.map(nb => ({ id: nb.id, name: nb.name, parentId: nb.parentId, trashedAt: nb.trashedAt })),
notebooks: mappedNotebooks,
globalLabels,
fileInputRef,
textareaRef,
richTextEditorRef,
}), [note, readOnly, fullPage, state, actions, notebooks, globalLabels])
}), [note, readOnly, fullPage, state, actions, mappedNotebooks, globalLabels])
return (
<NoteEditorContext.Provider value={value}>

View File

@@ -297,7 +297,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
<button
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
onClick={actions.handleSaveInPlace}
onClick={() => actions.handleSaveInPlace()}
disabled={state.isSaving || !state.isDirty}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
@@ -339,6 +339,21 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
{t('richTextEditor.importMarkdown')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={actions.toggleAutoSave} className="flex items-center justify-between cursor-pointer">
<span className="flex items-center">
<Save className="h-4 w-4 me-2 text-muted-foreground" />
{t('settings.autoSave') || 'Auto-enregistrement'}
</span>
<span className={cn(
'text-[10px] px-1.5 py-0.5 rounded font-bold uppercase tracking-wider',
state.autoSaveEnabled
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400'
: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400'
)}>
{state.autoSaveEnabled ? (t('common.on') || 'Actif') : (t('common.off') || 'Désactivé')}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={async () => {
try {
@@ -524,7 +539,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
<Button variant="ghost" onClick={onClose}>
{t('general.cancel')}
</Button>
<Button onClick={actions.handleSave} disabled={state.isSaving}>
<Button onClick={() => actions.handleSave()} disabled={state.isSaving}>
{state.isSaving ? t('notes.saving') : t('general.save')}
</Button>
</>

View File

@@ -20,6 +20,7 @@ export interface NoteEditorState {
isSaving: boolean
isDirty: boolean
lastSavedAt: Date | null
autoSaveEnabled: boolean
isProcessingAI: boolean
aiOpen: boolean
@@ -105,8 +106,8 @@ export interface NoteEditorActions {
handleImproveDirect: () => Promise<void>
handleTransformMarkdown: () => Promise<void>
handleSave: () => Promise<void>
handleSaveInPlace: () => Promise<void>
handleSave: (opts?: { silent?: boolean }) => Promise<void>
handleSaveInPlace: (opts?: { silent?: boolean }) => Promise<void>
handleMakeCopy: () => Promise<void>
setComparisonNotes: (notes: Array<Partial<Note>>) => void
@@ -122,6 +123,7 @@ export interface NoteEditorActions {
setIsAnalyzingSuggestions: (analyzing: boolean) => void
setPreviousContentForCopilot: (content: string | null) => void
setQuotaExceededFeature: (feature: string | null) => void
toggleAutoSave: () => void
}
export interface NoteEditorContextValue {

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo, useState, useEffect, useCallback } from 'react'
import { useMemo, useState, useEffect, useCallback, memo } from 'react'
import {
DndContext,
DragOverlay,
@@ -103,27 +103,46 @@ function NoteLabelsRow({
function NoteGridIllustrationButton({
busy,
onClick,
onDelete,
hasIllustration,
className,
}: {
busy: boolean
onClick: (e: React.MouseEvent) => void
onDelete?: (e: React.MouseEvent) => void
hasIllustration?: boolean
className?: string
}) {
const { t } = useLanguage()
return (
<button
type="button"
aria-label={t('notes.generateIllustration') || 'Générer une illustration IA'}
title={t('notes.generateIllustration') || 'Générer une illustration IA'}
className={cn(
'absolute bottom-2 end-2 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background/95 text-foreground shadow-card-rest backdrop-blur-sm transition-colors hover:bg-accent z-10',
className,
<div className={cn('absolute bottom-2 end-2 flex items-center gap-1.5 z-10', className)}>
{hasIllustration && onDelete && (
<button
type="button"
aria-label={t('notes.deleteIllustration') || 'Supprimer l\'illustration'}
title={t('notes.deleteIllustration') || 'Supprimer l\'illustration'}
className="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background/95 text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10 shadow-card-rest backdrop-blur-sm transition-colors"
onClick={onDelete}
disabled={busy}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
onClick={onClick}
disabled={busy}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4 text-primary" />}
</button>
<button
type="button"
aria-label={hasIllustration
? (t('notes.regenerateIllustration') || 'Regénérer l\'illustration')
: (t('notes.generateIllustration') || 'Générer une illustration IA')}
title={hasIllustration
? (t('notes.regenerateIllustration') || 'Regénérer')
: (t('notes.generateIllustration') || 'Générer une illustration IA')}
className="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background/95 text-foreground shadow-card-rest backdrop-blur-sm transition-colors hover:bg-accent"
onClick={onClick}
disabled={busy}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5 text-primary" />}
</button>
</div>
)
}
@@ -131,10 +150,12 @@ function NoteGridThumbnail({
note,
aiIllustrationEnabled,
onNoteIllustrationGenerated,
onNoteIllustrationDeleted,
}: {
note: Note
aiIllustrationEnabled?: boolean
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
onNoteIllustrationDeleted?: (noteId: string) => void | Promise<void>
}) {
const { t } = useLanguage()
const [busy, setBusy] = useState(false)
@@ -142,7 +163,7 @@ function NoteGridThumbnail({
const handleGenerateSvg = async (e: React.MouseEvent) => {
e.stopPropagation()
if (!aiIllustrationEnabled || busy || img) return
if (!aiIllustrationEnabled || busy) return
setBusy(true)
try {
const res = await generateNoteIllustrationSvg(note.id, { skipRevalidation: true })
@@ -157,7 +178,22 @@ function NoteGridThumbnail({
}
}
const aiButtonClass = 'opacity-0 group-hover/card:opacity-100 focus-visible:opacity-100'
const handleDeleteSvg = async (e: React.MouseEvent) => {
e.stopPropagation()
if (busy) return
setBusy(true)
try {
const { updateNote } = await import('@/app/actions/notes')
await updateNote(note.id, { illustrationSvg: null })
await onNoteIllustrationDeleted?.(note.id)
} catch {
toast.error('Erreur lors de la suppression')
} finally {
setBusy(false)
}
}
const aiButtonClass = 'opacity-0 group-hover/card:opacity-100 focus-visible:opacity-100 transition-opacity duration-200'
if (img) {
return (
@@ -177,7 +213,13 @@ function NoteGridThumbnail({
aria-hidden
/>
{aiIllustrationEnabled && (
<NoteGridIllustrationButton busy={busy} onClick={handleGenerateSvg} className={aiButtonClass} />
<NoteGridIllustrationButton
busy={busy}
onClick={handleGenerateSvg}
onDelete={handleDeleteSvg}
hasIllustration
className={aiButtonClass}
/>
)}
</>
)
@@ -188,12 +230,17 @@ function NoteGridThumbnail({
<FileText size={28} strokeWidth={1.25} />
</div>
{aiIllustrationEnabled && (
<NoteGridIllustrationButton busy={busy} onClick={handleGenerateSvg} className={aiButtonClass} />
<NoteGridIllustrationButton
busy={busy}
onClick={handleGenerateSvg}
className={aiButtonClass}
/>
)}
</>
)
}
export type { NoteCollectionActions } from '@/lib/note-change-sync'
type NotesListViewsProps = {
@@ -454,6 +501,7 @@ type GridCardSharedProps = {
onDeleteNote?: (note: Note) => void | Promise<void>
onMoveToNotebook?: (note: Note, notebookId: string | null) => void | Promise<void>
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
onNoteIllustrationDeleted?: (noteId: string) => void | Promise<void>
isOverlay?: boolean
}
@@ -623,7 +671,7 @@ function NotesGridSection({
)
}
function SortableGridCard(props: GridCardSharedProps) {
const SortableGridCard = memo(function SortableGridCard(props: GridCardSharedProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.note.id,
})
@@ -647,9 +695,9 @@ function SortableGridCard(props: GridCardSharedProps) {
<GridCard {...props} />
</div>
)
}
})
function GridCard({
const GridCard = memo(function GridCard({
note,
index,
untitled,
@@ -661,6 +709,7 @@ function GridCard({
onDeleteNote,
onMoveToNotebook,
onNoteIllustrationGenerated,
onNoteIllustrationDeleted,
isOverlay = false,
}: GridCardSharedProps) {
const router = useRouter()
@@ -699,6 +748,7 @@ function GridCard({
note={note}
aiIllustrationEnabled={aiIllustrationEnabled}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
onNoteIllustrationDeleted={onNoteIllustrationDeleted}
/>
{note.isPinned && (
<div className="absolute top-3 start-3 bg-background/90 backdrop-blur-sm p-1.5 rounded-full shadow-sm border border-border/40 text-amber-500">
@@ -771,4 +821,4 @@ function GridCard({
</div>
</motion.div>
)
}
})

View File

@@ -352,7 +352,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
if (!range) {
const { from } = ed.state.selection
const start = Math.max(0, from - 80)
const textBefore = ed.state.doc.textBetween(start, from, '\n', '\0')
const textBefore = ed.state.doc.textBetween(start, from, '\n', '')
const match = textBefore.match(/\[\[([^\]]*)$/)
if (match) {
range = { from: from - match[0].length, to: from }
@@ -531,7 +531,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
const { from, empty } = e.state.selection
if (!empty) return
const start = Math.max(0, from - 80)
const textBefore = e.state.doc.textBetween(start, from, '\n', '\0')
const textBefore = e.state.doc.textBetween(start, from, '\n', '')
const match = textBefore.match(/\[\[([^\]]*)$/)
if (match) {
noteLinkRangeRef.current = { from: from - match[0].length, to: from }

View File

@@ -22,6 +22,16 @@ interface BillingStatus {
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
hasStripeSubscription: boolean;
prices?: {
PRO: {
month: { display: string; amount: number; currency: string };
year: { display: string; amount: number; currency: string };
};
BUSINESS: {
month: { display: string; amount: number; currency: string };
year: { display: string; amount: number; currency: string };
};
};
}
const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development';
@@ -214,7 +224,8 @@ export function BillingPlans() {
{
id: 'pro',
name: t('billing.proPlan'),
price: interval === 'month' ? (t('billing.proPrice') || '9,90€') : (t('billing.proAnnualPrice') || '99€'),
price: status?.prices?.PRO?.[interval]?.display ??
(interval === 'month' ? (t('billing.proPrice') || '9,90€') : (t('billing.proAnnualPrice') || '99€')),
period: interval === 'month' ? '/mois' : '/an',
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
features: [
@@ -236,7 +247,8 @@ export function BillingPlans() {
{
id: 'business',
name: t('billing.businessPlan'),
price: interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€'),
price: status?.prices?.BUSINESS?.[interval]?.display ??
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
period: interval === 'month' ? '/mois' : '/an',
description: t('billing.businessDescription') || 'Pour les équipes et chefs de produit.',
features: [

View File

@@ -1457,6 +1457,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
type="button"
onClick={handleDailyNoteClick}
className="sidebar-inbox-item"
title={t('sidebar.dailyNoteTooltip') || "Ouvrir ou créer la note personnelle pour le journal d'aujourd'hui"}
>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0 bg-paper dark:bg-white/5 text-muted-ink border-border">
<CalendarDays size={14} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* Script de diagnostic pour l'extension Momento
* Vérifie tous les fichiers et identifie les problèmes potentiels
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const extDir = __dirname
console.log('🔍 Diagnostic Extension Momento\n')
const issues = []
const warnings = []
// Vérifier la syntaxe des fichiers JS
function checkSyntax(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8')
// Pas de vérification syntaxique simple en Node.js sans eval
// On vérifie juste que le fichier est lisible
return true
} catch (error) {
issues.push(`Fichier illisible: ${filePath} - ${error.message}`)
return false
}
}
// Vérifier les event handlers inline dans le HTML
function checkInlineHandlers(htmlPath) {
try {
const content = fs.readFileSync(htmlPath, 'utf8')
const inlineHandlers = []
if (content.match(/onerror=/i)) inlineHandlers.push('onerror')
if (content.match(/onclick=/i)) inlineHandlers.push('onclick')
if (content.match(/onload=/i)) inlineHandlers.push('onload')
if (inlineHandlers.length > 0) {
issues.push(`Event handlers inline trouvés dans ${htmlPath}: ${inlineHandlers.join(', ')}`)
} else {
console.log('✓ Pas d\'event handlers inline dans le HTML')
}
} catch (error) {
issues.push(`Impossible de lire ${htmlPath}: ${error.message}`)
}
}
// Vérifier les fixes CSP dans sidepanel.js
function checkCSPFixes(jsPath) {
try {
const content = fs.readFileSync(jsPath, 'utf8')
// Vérifier l'absence de onerror inline
if (content.match(/onerror=/)) {
issues.push('Event handler onerror trouvé dans sidepanel.js')
} else {
console.log('✓ Pas de onerror inline dans sidepanel.js')
}
// Vérifier la présence du fix avec data-fallback
if (content.includes('data-fallback')) {
console.log('✓ Fix CSP data-favicon présent')
} else {
warnings.push('Fix CSP data-favicon可能缺失')
}
// Vérifier le handler de favicon
if (content.includes("querySelector('.page-favicon')")) {
console.log('✓ Handler error pour favicon présent')
} else {
warnings.push('Handler error pour favicon可能缺失')
}
} catch (error) {
issues.push(`Impossible de lire ${jsPath}: ${error.message}`)
}
}
// Vérifier le fix pick mode
function checkPickModeFix(jsPath) {
try {
const content = fs.readFileSync(jsPath, 'utf8')
// Vérifier le handler visibilitychange
if (content.includes('visibilityState === \'hidden\'')) {
console.log('✓ Handler visibilitychange pour hidden présent')
} else {
issues.push('Handler visibilitychange pour hidden manquant')
}
// Vérifier l'appel à setPickModeOnTab(false)
if (content.match(/visibilityState === 'hidden'.*setPickModeOnTab\(false\)/s)) {
console.log('✓ Appel setPickModeOnTab(false) dans visibilitychange présent')
} else {
issues.push('Appel setPickModeOnTab(false) dans visibilitychange可能缺失')
}
} catch (error) {
issues.push(`Impossible de lire ${jsPath}: ${error.message}`)
}
}
// Vérifier le manifest
function checkManifest(manifestPath) {
try {
const content = fs.readFileSync(manifestPath, 'utf8')
const manifest = JSON.parse(content)
console.log('✓ Manifest.json valide')
console.log(` Version: ${manifest.version}`)
console.log(` Permissions: ${manifest.permissions.join(', ')}`)
console.log(` Host permissions: ${manifest.host_permissions.length}`)
} catch (error) {
issues.push(`Manifest.json invalide: ${error.message}`)
}
}
// Exécuter les tests
console.log('📋 Vérification des fichiers...\n')
checkSyntax(path.join(extDir, 'sidepanel.js'))
checkSyntax(path.join(extDir, 'content.js'))
checkSyntax(path.join(extDir, 'background.js'))
console.log('\n🔒 Vérification CSP...\n')
checkInlineHandlers(path.join(extDir, 'sidepanel.html'))
checkCSPFixes(path.join(extDir, 'sidepanel.js'))
console.log('\n🎯 Vérification fix pick mode...\n')
checkPickModeFix(path.join(extDir, 'sidepanel.js'))
console.log('\n📦 Vérification manifest...\n')
checkManifest(path.join(extDir, 'manifest.json'))
// Résumé
console.log('\n' + '='.repeat(50))
if (issues.length === 0 && warnings.length === 0) {
console.log('✅ Aucun problème détecté !')
} else {
if (issues.length > 0) {
console.log('\n❌ Problèmes détectés:')
issues.forEach(issue => console.log(`${issue}`))
}
if (warnings.length > 0) {
console.log('\n⚠ Warnings:')
warnings.forEach(warning => console.log(`${warning}`))
}
}
console.log('\n📝 Instructions:')
console.log('1. Rechargez l\'extension dans chrome://extensions (bouton 🔄)')
console.log('2. Ouvrez une page web normale')
console.log('3. Cliquez sur l\'icône Momento')
console.log('4. Fermez le sidepanel - la bannière doit disparaître')
console.log('5. Ouvrez la console (F12) - pas d\'erreur CSP')

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "مومنتو ويب كليبر"
},
"extDescription": {
"message": "التقط صفحات الويب والنص المميز في دفاتر ملاحظات Momento الخاصة بك - ويتصل بخادم Momento الخاص بك."
},
"extActionTitle": {
"message": "مقطع إلى مومنتو"
},
"webClipper": {
"message": "مقص الويب"
},
"connected": {
"message": "متصل"
},
"disconnected": {
"message": "غير متصل"
},
"instanceSettings": {
"message": "عنوان URL لمومنتو"
},
"instanceUrlLabel": {
"message": "عنوان URL لمثيل Momento الخاص بك"
},
"presetProduction": {
"message": "إعداد مسبق للإنتاج · memento-note.com"
},
"applyReconnect": {
"message": "تطبيق وإعادة الاتصال"
},
"openMomento": {
"message": "افتح مومنتو"
},
"settingsHint": {
"message": "الصق عنوان URL الخاص بـ HTTPS (أو LAN) لخادم Momento الخاص بك. تتعامل ملفات تعريف الارتباط الموجودة في هذا المتصفح مع تسجيل الدخول."
},
"footerVersion": {
"message": "Momento Web Clipper <<<الإصدار>>>"
},
"errPermissionDenied": {
"message": "لا يستطيع Momento الوصول إلى علامة التبويب هذه. تحقق من أذونات ملحق لوحة المفاتيح/الموقع — أو افتح اللوحة الجانبية."
},
"notebookUnnamed": {
"message": "دفتر بلا عنوان"
},
"noNotebooks": {
"message": "لا توجد دفاتر ملاحظات حتى الآن"
},
"readingTimeOne": {
"message": "~1 دقيقة قراءة"
},
"readingTimeOther": {
"message": "تقريبا. $COUNT$ دقيقة قراءة",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "تم الكشف عن التحديد"
},
"ignore": {
"message": "يتجاهل"
},
"selectionHint": {
"message": "نصيحة: قم بتمييز النص الموجود على الصفحة لقص التحديد الدقيق كملاحظة."
},
"clipSelection": {
"message": "اختيار المقطع"
},
"clipPage": {
"message": "قص هذه الصفحة"
},
"saveLinkOnly": {
"message": "حفظ الرابط فقط"
},
"pageNotAccessible": {
"message": "لا يمكن القص هنا — هذه الصفحة تحظر الوصول إلى الإضافات."
},
"errLoginRequired": {
"message": "يرجى تسجيل الدخول إلى Momento في هذا المتصفح أولاً."
},
"errLoadNotebooks": {
"message": "تعذر تحميل دفاتر الملاحظات. حاول إعادة الاتصال."
},
"notebooksLoaded": {
"message": "تم تحميل دفاتر الملاحظات"
},
"connecting": {
"message": "جارٍ الاتصال…"
},
"connectedToUrl": {
"message": "متصل بـ $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "صفحة مقيدة - قم بالقص عبر شريط أدوات Momento أو اللوحة الجانبية."
},
"destinationNotebook": {
"message": "دفتر الوجهة"
},
"activePage": {
"message": "صفحة نشطة"
},
"previewBeforeSave": {
"message": "المراجعة قبل الحفظ"
},
"noteTitleLabel": {
"message": "عنوان"
},
"excerptLabel": {
"message": "مقتطفات"
},
"saveToMomento": {
"message": "حفظ إلى مومنتو"
},
"back": {
"message": "خلف"
},
"analyzingSource": {
"message": "تحليل المصدر"
},
"statusAnalyzing": {
"message": "جارٍ التحليل…"
},
"statusSaving": {
"message": "توفير…"
},
"processingDetail": {
"message": "إنشاء العلامات والملخص الدلالي والتضمينات."
},
"noteSaved": {
"message": "تم حفظ الملاحظة"
},
"sentToNotebook": {
"message": "تم الحفظ في $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "عرض في مومنتو"
},
"clipAnother": {
"message": "قص صفحة أخرى"
},
"failure": {
"message": "لا يمكن إكماله"
},
"genericError": {
"message": "حدث خطأ ما أثناء الوصول إلى مثيل Momento."
},
"retry": {
"message": "أعد المحاولة"
},
"errNoSelection": {
"message": "حدد النص أولاً، أو قم بقص الصفحة بأكملها."
},
"errAnalyzeFailed": {
"message": "لا يمكن تحليل هذه الصفحة."
},
"errSaveFailed": {
"message": "لا يمكن حفظ ملاحظتك."
},
"errNetwork": {
"message": "مشكلة في الشبكة - تحقق من اتصالك وعنوان URL الخاص بـ Momento."
},
"bannerPickText": {
"message": "قم بتمييز النص الموجود على الصفحة، أو قم بقص الصفحة بأكملها."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento Web Clipper"
},
"extDescription": {
"message": "Erfassen Sie Webseiten und hervorgehobenen Text in Ihren Momento-Notizbüchern stellt eine Verbindung zu Ihrem eigenen Momento-Server her."
},
"extActionTitle": {
"message": "Clip auf Momento"
},
"webClipper": {
"message": "Web Clipper"
},
"connected": {
"message": "Verbunden"
},
"disconnected": {
"message": "Nicht verbunden"
},
"instanceSettings": {
"message": "Momento-URL"
},
"instanceUrlLabel": {
"message": "Ihre Momento-Instanz-URL"
},
"presetProduction": {
"message": "Produktionsvoreinstellung · memento-note.com"
},
"applyReconnect": {
"message": "Anwenden und erneut verbinden"
},
"openMomento": {
"message": "Öffnen Sie Momento"
},
"settingsHint": {
"message": "Fügen Sie die HTTPS- (oder LAN-)URL Ihres Momento-Servers ein. Cookies in diesem Browser verarbeiten die Anmeldung."
},
"footerVersion": {
"message": "Momento Web Clipper 0.3.1"
},
"errPermissionDenied": {
"message": "Momento kann nicht auf diese Registerkarte zugreifen. Überprüfen Sie die Tastatur-/Site-Erweiterungsberechtigungen oder öffnen Sie den Seitenbereich."
},
"notebookUnnamed": {
"message": "Notizbuch ohne Titel"
},
"noNotebooks": {
"message": "Noch keine Notizbücher"
},
"readingTimeOne": {
"message": "~1 Minute gelesen"
},
"readingTimeOther": {
"message": "Ca. $COUNT$ min. gelesen",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Auswahl erkannt"
},
"ignore": {
"message": "ignorieren"
},
"selectionHint": {
"message": "Tipp: Markieren Sie Text auf der Seite, um eine präzise Auswahl als Notiz auszuschneiden."
},
"clipSelection": {
"message": "Clip-Auswahl"
},
"clipPage": {
"message": "Clip diese Seite aus"
},
"saveLinkOnly": {
"message": "Nur Link speichern"
},
"pageNotAccessible": {
"message": "Hier kann kein Clip erstellt werden diese Seite blockiert den Zugriff auf die Erweiterung."
},
"errLoginRequired": {
"message": "Bitte melden Sie sich zunächst in diesem Browser bei Momento an."
},
"errLoadNotebooks": {
"message": "Notebooks konnten nicht geladen werden. Versuchen Sie, die Verbindung wiederherzustellen."
},
"notebooksLoaded": {
"message": "Notizbücher geladen"
},
"connecting": {
"message": "Verbinden…"
},
"connectedToUrl": {
"message": "Verbunden mit $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Eingeschränkte Seite Ausschneiden über die Momento-Symbolleiste oder den Seitenbereich."
},
"destinationNotebook": {
"message": "Zielnotizbuch"
},
"activePage": {
"message": "Aktive Seite"
},
"previewBeforeSave": {
"message": "Vor dem Speichern überprüfen"
},
"noteTitleLabel": {
"message": "Titel"
},
"excerptLabel": {
"message": "Auszug"
},
"saveToMomento": {
"message": "In Momento speichern"
},
"back": {
"message": "Zurück"
},
"analyzingSource": {
"message": "Quelle analysieren"
},
"statusAnalyzing": {
"message": "Analysieren…"
},
"statusSaving": {
"message": "Sparen…"
},
"processingDetail": {
"message": "Generieren von Tags, einer semantischen Zusammenfassung und Einbettungen."
},
"noteSaved": {
"message": "Notiz gespeichert"
},
"sentToNotebook": {
"message": "Gespeichert unter $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "In Momento ansehen"
},
"clipAnother": {
"message": "Schneiden Sie eine weitere Seite aus"
},
"failure": {
"message": "Konnte nicht abgeschlossen werden"
},
"genericError": {
"message": "Beim Erreichen Ihrer Momento-Instanz ist ein Fehler aufgetreten."
},
"retry": {
"message": "Wiederholen"
},
"errNoSelection": {
"message": "Wählen Sie zuerst den Text aus oder schneiden Sie die gesamte Seite aus."
},
"errAnalyzeFailed": {
"message": "Diese Seite konnte nicht analysiert werden."
},
"errSaveFailed": {
"message": "Ihre Notiz konnte nicht gespeichert werden."
},
"errNetwork": {
"message": "Netzwerkproblem überprüfen Sie Ihre Verbindung und Momento-URL."
},
"bannerPickText": {
"message": "Markieren Sie Text auf der Seite oder schneiden Sie die gesamte Seite aus."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento Web Clipper"
},
"extDescription": {
"message": "Capture web pages and highlighted text into your Momento notebooks — connects to your own Momento server."
},
"extActionTitle": {
"message": "Clip to Momento"
},
"webClipper": {
"message": "Web Clipper"
},
"connected": {
"message": "Connected"
},
"disconnected": {
"message": "Not connected"
},
"instanceSettings": {
"message": "Momento URL"
},
"instanceUrlLabel": {
"message": "Your Momento instance URL"
},
"presetProduction": {
"message": "Production preset · memento-note.com"
},
"applyReconnect": {
"message": "Apply and reconnect"
},
"openMomento": {
"message": "Open Momento"
},
"settingsHint": {
"message": "Paste the HTTPS (or LAN) URL of your Momento server. Cookies in this browser handle sign-in."
},
"footerVersion": {
"message": "Momento Web Clipper 0.3.1"
},
"errPermissionDenied": {
"message": "Momento can't access this tab. Check keyboard/site extension permissions — or open the Side Panel."
},
"notebookUnnamed": {
"message": "Untitled notebook"
},
"noNotebooks": {
"message": "No notebooks yet"
},
"readingTimeOne": {
"message": "~1 minute read"
},
"readingTimeOther": {
"message": "Approx. $COUNT$ min read",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Selection detected"
},
"ignore": {
"message": "ignore"
},
"selectionHint": {
"message": "Tip: highlight text on the page to clip a precise selection as a note."
},
"clipSelection": {
"message": "Clip selection"
},
"clipPage": {
"message": "Clip this page"
},
"saveLinkOnly": {
"message": "Save link only"
},
"pageNotAccessible": {
"message": "Can't clip here — this page blocks extension access."
},
"errLoginRequired": {
"message": "Please sign in to Momento in this browser first."
},
"errLoadNotebooks": {
"message": "Could not load notebooks. Try reconnecting."
},
"notebooksLoaded": {
"message": "Notebooks loaded"
},
"connecting": {
"message": "Connecting…"
},
"connectedToUrl": {
"message": "Connected to $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Restricted page — clip via the Momento toolbar or Side Panel."
},
"destinationNotebook": {
"message": "Destination notebook"
},
"activePage": {
"message": "Active page"
},
"previewBeforeSave": {
"message": "Review before saving"
},
"noteTitleLabel": {
"message": "Title"
},
"excerptLabel": {
"message": "Excerpt"
},
"saveToMomento": {
"message": "Save to Momento"
},
"back": {
"message": "Back"
},
"analyzingSource": {
"message": "Analyzing source"
},
"statusAnalyzing": {
"message": "Analyzing…"
},
"statusSaving": {
"message": "Saving…"
},
"processingDetail": {
"message": "Generating tags, a semantic summary, and embeddings."
},
"noteSaved": {
"message": "Note saved"
},
"sentToNotebook": {
"message": "Saved to $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "View in Momento"
},
"clipAnother": {
"message": "Clip another page"
},
"failure": {
"message": "Could not complete"
},
"genericError": {
"message": "Something went wrong reaching your Momento instance."
},
"retry": {
"message": "Retry"
},
"errNoSelection": {
"message": "Select text first, or clip the full page."
},
"errAnalyzeFailed": {
"message": "Could not analyze this page."
},
"errSaveFailed": {
"message": "Could not save your note."
},
"errNetwork": {
"message": "Network issue — check your connection and Momento URL."
},
"bannerPickText": {
"message": "Highlight text on the page, or clip the whole page."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Cortadora web Momento"
},
"extDescription": {
"message": "Capture páginas web y texto resaltado en sus cuadernos Momento: se conecta a su propio servidor Momento."
},
"extActionTitle": {
"message": "Clip al momento"
},
"webClipper": {
"message": "Cortadora web"
},
"connected": {
"message": "Conectado"
},
"disconnected": {
"message": "No conectado"
},
"instanceSettings": {
"message": "URL del momento"
},
"instanceUrlLabel": {
"message": "La URL de tu instancia de Momento"
},
"presetProduction": {
"message": "Preajuste de producción · memento-note.com"
},
"applyReconnect": {
"message": "Aplicar y reconectar"
},
"openMomento": {
"message": "Momento abierto"
},
"settingsHint": {
"message": "Pegue la URL HTTPS (o LAN) de su servidor Momento. Las cookies en este navegador controlan el inicio de sesión."
},
"footerVersion": {
"message": "Momento Web Clipper <<<VERSIÓN>>>"
},
"errPermissionDenied": {
"message": "Momento no puede acceder a esta pestaña. Verifique los permisos de extensión del sitio/teclado o abra el Panel lateral."
},
"notebookUnnamed": {
"message": "Cuaderno sin título"
},
"noNotebooks": {
"message": "Aún no hay cuadernos"
},
"readingTimeOne": {
"message": "~1 minuto de lectura"
},
"readingTimeOther": {
"message": "Aprox. <<<CONTAR>>> lectura mínima (Approx. $COUNT$ min read)",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Selección detectada"
},
"ignore": {
"message": "ignorar"
},
"selectionHint": {
"message": "Consejo: resalte el texto en la página para recortar una selección precisa como nota."
},
"clipSelection": {
"message": "Selección de clips"
},
"clipPage": {
"message": "Recortar esta página"
},
"saveLinkOnly": {
"message": "Guardar enlace solamente"
},
"pageNotAccessible": {
"message": "No se puede recortar aquí: esta página bloquea el acceso a la extensión."
},
"errLoginRequired": {
"message": "Primero inicie sesión en Momento en este navegador."
},
"errLoadNotebooks": {
"message": "No se pudieron cargar los cuadernos. Intente volver a conectarse."
},
"notebooksLoaded": {
"message": "Cuadernos cargados"
},
"connecting": {
"message": "Conectando…"
},
"connectedToUrl": {
"message": "Conectado a $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Página restringida: recorte mediante la barra de herramientas de Momento o el panel lateral."
},
"destinationNotebook": {
"message": "Cuaderno de destino"
},
"activePage": {
"message": "Página activa"
},
"previewBeforeSave": {
"message": "Revisar antes de guardar"
},
"noteTitleLabel": {
"message": "Título"
},
"excerptLabel": {
"message": "Extracto"
},
"saveToMomento": {
"message": "Guardar en momento"
},
"back": {
"message": "Atrás"
},
"analyzingSource": {
"message": "Analizando fuente"
},
"statusAnalyzing": {
"message": "Analizando…"
},
"statusSaving": {
"message": "Ahorro…"
},
"processingDetail": {
"message": "Generación de etiquetas, resumen semántico e incrustaciones."
},
"noteSaved": {
"message": "Nota guardada"
},
"sentToNotebook": {
"message": "Guardado en $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Ver en momento"
},
"clipAnother": {
"message": "Recortar otra página"
},
"failure": {
"message": "No se pudo completar"
},
"genericError": {
"message": "Algo salió mal al llegar a tu instancia de Momento."
},
"retry": {
"message": "Rever"
},
"errNoSelection": {
"message": "Seleccione el texto primero o recorte la página completa."
},
"errAnalyzeFailed": {
"message": "No se pudo analizar esta página."
},
"errSaveFailed": {
"message": "No se pudo guardar tu nota."
},
"errNetwork": {
"message": "Problema de red: verifique su conexión y la URL de Momento."
},
"bannerPickText": {
"message": "Resalte el texto de la página o recorte toda la página."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento Web Clipper"
},
"extDescription": {
"message": "صفحات وب و متن هایلایت شده را در نوت بوک های Momento خود ضبط کنید — به سرور Momento خودتان متصل می شود."
},
"extActionTitle": {
"message": "کلیپ به لحظه"
},
"webClipper": {
"message": "Web Clipper"
},
"connected": {
"message": "متصل شد"
},
"disconnected": {
"message": "متصل نیست"
},
"instanceSettings": {
"message": "آدرس لحظه ای"
},
"instanceUrlLabel": {
"message": "URL نمونه Momento شما"
},
"presetProduction": {
"message": "پیش تنظیم تولید · memento-note.com"
},
"applyReconnect": {
"message": "درخواست کنید و دوباره وصل شوید"
},
"openMomento": {
"message": "Momento را باز کنید"
},
"settingsHint": {
"message": "URL HTTPS (یا LAN) سرور Momento خود را جایگذاری کنید. کوکی‌های این مرورگر ورود به سیستم را کنترل می‌کنند."
},
"footerVersion": {
"message": "Momento Web Clipper 0.3.1"
},
"errPermissionDenied": {
"message": "Momento نمی تواند به این برگه دسترسی پیدا کند. مجوزهای افزونه صفحه کلید/سایت را بررسی کنید - یا پانل جانبی را باز کنید."
},
"notebookUnnamed": {
"message": "دفترچه بدون عنوان"
},
"noNotebooks": {
"message": "هنوز نوت بوک نیست"
},
"readingTimeOne": {
"message": "~ 1 دقیقه مطالعه کنید"
},
"readingTimeOther": {
"message": "تقریبا $COUNT$ دقیقه خواندن",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "انتخاب شناسایی شد"
},
"ignore": {
"message": "نادیده گرفتن"
},
"selectionHint": {
"message": "نکته: متن را در صفحه برجسته کنید تا یک انتخاب دقیق به عنوان یادداشت بریده شود."
},
"clipSelection": {
"message": "انتخاب کلیپ"
},
"clipPage": {
"message": "این صفحه را کلیپ کنید"
},
"saveLinkOnly": {
"message": "فقط لینک را ذخیره کنید"
},
"pageNotAccessible": {
"message": "در اینجا نمی توان کلیپ کرد - این صفحه دسترسی برنامه های افزودنی را مسدود می کند."
},
"errLoginRequired": {
"message": "لطفاً ابتدا با این مرورگر وارد Momento شوید."
},
"errLoadNotebooks": {
"message": "نوت‌بوک‌ها بارگیری نشد. سعی کنید دوباره وصل شوید."
},
"notebooksLoaded": {
"message": "نوت بوک ها بارگیری شدند"
},
"connecting": {
"message": "در حال اتصال…"
},
"connectedToUrl": {
"message": "به $URL$ متصل شد",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "صفحه محدود - از طریق نوار ابزار Momento یا پانل جانبی کلیپ کنید."
},
"destinationNotebook": {
"message": "دفترچه یادداشت مقصد"
},
"activePage": {
"message": "صفحه فعال"
},
"previewBeforeSave": {
"message": "قبل از ذخیره بررسی کنید"
},
"noteTitleLabel": {
"message": "عنوان"
},
"excerptLabel": {
"message": "گزیده"
},
"saveToMomento": {
"message": "ذخیره در Momento"
},
"back": {
"message": "برگشت"
},
"analyzingSource": {
"message": "تجزیه و تحلیل منبع"
},
"statusAnalyzing": {
"message": "در حال تجزیه و تحلیل…"
},
"statusSaving": {
"message": "در حال ذخیره…"
},
"processingDetail": {
"message": "تولید برچسب ها، خلاصه معنایی، و جاسازی ها."
},
"noteSaved": {
"message": "یادداشت ذخیره شد"
},
"sentToNotebook": {
"message": "در $NOTEBOOK$ ذخیره شد",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "مشاهده در Momento"
},
"clipAnother": {
"message": "یک صفحه دیگر را کلیپ کنید"
},
"failure": {
"message": "تکمیل نشد"
},
"genericError": {
"message": "هنگام رسیدن به نمونه Momento شما مشکلی پیش آمد."
},
"retry": {
"message": "دوباره امتحان کنید"
},
"errNoSelection": {
"message": "ابتدا متن را انتخاب کنید، یا صفحه کامل را کلیپ کنید."
},
"errAnalyzeFailed": {
"message": "نمی توان این صفحه را تجزیه و تحلیل کرد."
},
"errSaveFailed": {
"message": "یادداشت شما ذخیره نشد."
},
"errNetwork": {
"message": "مشکل شبکه - اتصال و URL Momento خود را بررسی کنید."
},
"bannerPickText": {
"message": "متن را در صفحه برجسته کنید یا کل صفحه را برش دهید."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento · Web Clipper"
},
"extDescription": {
"message": "Enregistrez des pages web et du texte surligné dans vos carnets Momento — connecté à votre propre serveur Momento."
},
"extActionTitle": {
"message": "Clipper vers Momento"
},
"webClipper": {
"message": "Web Clipper"
},
"connected": {
"message": "Connecté"
},
"disconnected": {
"message": "Non connecté"
},
"instanceSettings": {
"message": "Adresse Momento"
},
"instanceUrlLabel": {
"message": "URL de votre instance Momento"
},
"presetProduction": {
"message": "Préréglage production · memento-note.com"
},
"applyReconnect": {
"message": "Appliquer et reconnecter"
},
"openMomento": {
"message": "Ouvrir Momento"
},
"settingsHint": {
"message": "Collez l'URL HTTPS (ou LAN) de votre serveur Momento. Les cookies de ce navigateur gèrent la connexion."
},
"footerVersion": {
"message": "Momento Web Clipper 0.3.1"
},
"errPermissionDenied": {
"message": "Momento ne peut pas accéder à cet onglet. Vérifiez les autorisations du clavier/extension de site ou ouvrez le panneau latéral."
},
"notebookUnnamed": {
"message": "Carnet sans titre"
},
"noNotebooks": {
"message": "Pas encore de carnets"
},
"readingTimeOne": {
"message": "≈ 1 minute de lecture"
},
"readingTimeOther": {
"message": "≈ $COUNT$ minutes de lecture",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Sélection détectée"
},
"ignore": {
"message": "ignorer"
},
"selectionHint": {
"message": "Astuce : surlignez du texte à lécran pour clipper une sélection précise de la page en tant que note."
},
"clipSelection": {
"message": "Clipper la sélection"
},
"clipPage": {
"message": "Clipper cette page"
},
"saveLinkOnly": {
"message": "Enregistrer le lien uniquement"
},
"pageNotAccessible": {
"message": "Impossible de clipper ici — cette page bloque l'accès aux extensions."
},
"errLoginRequired": {
"message": "Veuillez d'abord vous connecter à Momento dans ce navigateur."
},
"errLoadNotebooks": {
"message": "Impossible de charger les carnets. Essayez de vous reconnecter."
},
"notebooksLoaded": {
"message": "Carnets chargés"
},
"connecting": {
"message": "Connexion…"
},
"connectedToUrl": {
"message": "Connecté à $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Page restreinte : clip via la barre d'outils Momento ou le panneau latéral."
},
"destinationNotebook": {
"message": "Carnet de destination"
},
"activePage": {
"message": "Page active"
},
"previewBeforeSave": {
"message": "Vérifier avant d'enregistrer"
},
"noteTitleLabel": {
"message": "Titre"
},
"excerptLabel": {
"message": "Extrait"
},
"saveToMomento": {
"message": "Enregistrer dans Momento"
},
"back": {
"message": "Retour"
},
"analyzingSource": {
"message": "Analyse de la source"
},
"statusAnalyzing": {
"message": "Analyse…"
},
"statusSaving": {
"message": "Enregistrement…"
},
"processingDetail": {
"message": "Génération automatique des tags, résumé sémantique et calcul des embeddings."
},
"noteSaved": {
"message": "Note enregistrée"
},
"sentToNotebook": {
"message": "Enregistré dans $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Voir dans Momento"
},
"clipAnother": {
"message": "Clipper une autre page"
},
"failure": {
"message": "Impossible de terminer"
},
"genericError": {
"message": "Une erreur est survenue lors de la communication avec votre instance Momento."
},
"retry": {
"message": "Réessayer"
},
"errNoSelection": {
"message": "Sélectionnez d'abord du texte, ou clippez la page entière."
},
"errAnalyzeFailed": {
"message": "Impossible d'analyser cette page."
},
"errSaveFailed": {
"message": "Impossible d'enregistrer votre note."
},
"errNetwork": {
"message": "Problème de réseau : vérifiez votre connexion et l'URL Momento."
},
"bannerPickText": {
"message": "Surlignez le texte à clipper"
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "मोमेंटो वेब क्लिपर"
},
"extDescription": {
"message": "अपने मोमेंटो नोटबुक में वेब पेज और हाइलाइट किए गए टेक्स्ट को कैप्चर करें - यह आपके अपने मोमेंटो सर्वर से जुड़ता है।"
},
"extActionTitle": {
"message": "मोमेंटो पर क्लिप करें"
},
"webClipper": {
"message": "वेब क्लिपर"
},
"connected": {
"message": "जुड़े हुए"
},
"disconnected": {
"message": "जुड़े नहीं हैं"
},
"instanceSettings": {
"message": "मोमेंटो यूआरएल"
},
"instanceUrlLabel": {
"message": "आपका मोमेंटो इंस्टेंस यूआरएल"
},
"presetProduction": {
"message": "प्रोडक्शन प्रीसेट · memento-note.com"
},
"applyReconnect": {
"message": "आवेदन करें और पुनः कनेक्ट करें"
},
"openMomento": {
"message": "मोमेंटो खोलें"
},
"settingsHint": {
"message": "अपने मोमेंटो सर्वर का HTTPS (या LAN) URL चिपकाएँ। इस ब्राउज़र में कुकीज़ साइन-इन को संभालती हैं।"
},
"footerVersion": {
"message": "मोमेंटो वेब क्लिपर <<<संस्करण>>>"
},
"errPermissionDenied": {
"message": "मोमेंटो इस टैब तक नहीं पहुंच सकता. कीबोर्ड/साइट एक्सटेंशन अनुमतियां जांचें - या साइड पैनल खोलें।"
},
"notebookUnnamed": {
"message": "शीर्षक रहित नोटबुक"
},
"noNotebooks": {
"message": "अभी तक कोई नोटबुक नहीं"
},
"readingTimeOne": {
"message": "~1 मिनट पढ़ें"
},
"readingTimeOther": {
"message": "लगभग। $COUNT$ मिनट पढ़ा",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "चयन का पता चला"
},
"ignore": {
"message": "अनदेखा करना"
},
"selectionHint": {
"message": "युक्ति: किसी सटीक चयन को नोट के रूप में क्लिप करने के लिए पृष्ठ पर टेक्स्ट को हाइलाइट करें।"
},
"clipSelection": {
"message": "क्लिप चयन"
},
"clipPage": {
"message": "इस पृष्ठ को क्लिप करें"
},
"saveLinkOnly": {
"message": "केवल लिंक सहेजें"
},
"pageNotAccessible": {
"message": "यहां क्लिप नहीं किया जा सकता - यह पेज एक्सटेंशन एक्सेस को ब्लॉक करता है।"
},
"errLoginRequired": {
"message": "कृपया पहले इस ब्राउज़र में मोमेंटो में साइन इन करें।"
},
"errLoadNotebooks": {
"message": "नोटबुक लोड नहीं हो सकीं. पुनः कनेक्ट करने का प्रयास करें."
},
"notebooksLoaded": {
"message": "नोटबुक लोड किए गए"
},
"connecting": {
"message": "कनेक्ट हो रहा है..."
},
"connectedToUrl": {
"message": "$URL$ से कनेक्ट किया गया",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "प्रतिबंधित पृष्ठ - मोमेंटो टूलबार या साइड पैनल के माध्यम से क्लिप करें।"
},
"destinationNotebook": {
"message": "गंतव्य नोटबुक"
},
"activePage": {
"message": "सक्रिय पृष्ठ"
},
"previewBeforeSave": {
"message": "सहेजने से पहले समीक्षा करें"
},
"noteTitleLabel": {
"message": "शीर्षक"
},
"excerptLabel": {
"message": "अंश"
},
"saveToMomento": {
"message": "मोमेंटो में सहेजें"
},
"back": {
"message": "पीछे"
},
"analyzingSource": {
"message": "स्रोत का विश्लेषण"
},
"statusAnalyzing": {
"message": "विश्लेषण कर रहा हूँ..."
},
"statusSaving": {
"message": "सहेजा जा रहा है..."
},
"processingDetail": {
"message": "टैग, सिमेंटिक सारांश और एम्बेडिंग तैयार करना।"
},
"noteSaved": {
"message": "नोट सहेजा गया"
},
"sentToNotebook": {
"message": "$NOTEBOOK$ में सहेजा गया",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "मोमेंटो में देखें"
},
"clipAnother": {
"message": "दूसरे पेज को क्लिप करें"
},
"failure": {
"message": "पूरा नहीं हो सका"
},
"genericError": {
"message": "आपके मोमेंटो इंस्टेंस तक पहुँचने में कुछ गड़बड़ी हुई।"
},
"retry": {
"message": "पुन: प्रयास करें"
},
"errNoSelection": {
"message": "पहले टेक्स्ट चुनें, या पूरा पेज क्लिप करें।"
},
"errAnalyzeFailed": {
"message": "इस पृष्ठ का विश्लेषण नहीं किया जा सका."
},
"errSaveFailed": {
"message": "आपका नोट सहेजा नहीं जा सका."
},
"errNetwork": {
"message": "नेटवर्क समस्या - अपना कनेक्शन और मोमेंटो यूआरएल जांचें।"
},
"bannerPickText": {
"message": "पृष्ठ पर टेक्स्ट को हाइलाइट करें, या पूरे पृष्ठ को क्लिप करें।"
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento Web Clipper"
},
"extDescription": {
"message": "Cattura pagine web e testo evidenziato nei tuoi taccuini Momento: si connette al tuo server Momento."
},
"extActionTitle": {
"message": "Clip su Momento"
},
"webClipper": {
"message": "Tagliatore di fotoricettore"
},
"connected": {
"message": "Collegato"
},
"disconnected": {
"message": "Non connesso"
},
"instanceSettings": {
"message": "URL del momento"
},
"instanceUrlLabel": {
"message": "L'URL dell'istanza di Momento"
},
"presetProduction": {
"message": "Preimpostazione di produzione · memento-note.com"
},
"applyReconnect": {
"message": "Applicare e riconnettersi"
},
"openMomento": {
"message": "Momento aperto"
},
"settingsHint": {
"message": "Incolla l'URL HTTPS (o LAN) del tuo server Momento. I cookie in questo browser gestiscono l'accesso."
},
"footerVersion": {
"message": "Momento Web Clipper <<<VERSIONE>>>"
},
"errPermissionDenied": {
"message": "Momento non può accedere a questa scheda. Controlla le autorizzazioni per tastiera/estensione del sito oppure apri il pannello laterale."
},
"notebookUnnamed": {
"message": "Taccuino senza titolo"
},
"noNotebooks": {
"message": "Nessun taccuino ancora"
},
"readingTimeOne": {
"message": "~1 minuto di lettura"
},
"readingTimeOther": {
"message": "ca. $COUNT$ min letto",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Selezione rilevata"
},
"ignore": {
"message": "ignorare"
},
"selectionHint": {
"message": "Suggerimento: evidenzia il testo sulla pagina per ritagliare una selezione precisa come nota."
},
"clipSelection": {
"message": "Selezione clip"
},
"clipPage": {
"message": "Ritaglia questa pagina"
},
"saveLinkOnly": {
"message": "Salva solo il collegamento"
},
"pageNotAccessible": {
"message": "Impossibile ritagliare qui: questa pagina blocca l'accesso all'estensione."
},
"errLoginRequired": {
"message": "Accedi prima a Momento in questo browser."
},
"errLoadNotebooks": {
"message": "Impossibile caricare i taccuini. Prova a riconnetterti."
},
"notebooksLoaded": {
"message": "Taccuini caricati"
},
"connecting": {
"message": "Connessione…"
},
"connectedToUrl": {
"message": "Connesso a $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Pagina limitata: ritaglia tramite la barra degli strumenti Momento o il pannello laterale."
},
"destinationNotebook": {
"message": "Taccuino di destinazione"
},
"activePage": {
"message": "Pagina attiva"
},
"previewBeforeSave": {
"message": "Rivedi prima di salvare"
},
"noteTitleLabel": {
"message": "Titolo"
},
"excerptLabel": {
"message": "Estratto"
},
"saveToMomento": {
"message": "Salva su Momento"
},
"back": {
"message": "Indietro"
},
"analyzingSource": {
"message": "Analisi della fonte"
},
"statusAnalyzing": {
"message": "Analizzando..."
},
"statusSaving": {
"message": "Risparmio…"
},
"processingDetail": {
"message": "Generazione di tag, riepilogo semantico e incorporamenti."
},
"noteSaved": {
"message": "Nota salvata"
},
"sentToNotebook": {
"message": "Salvato in $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Visualizza in Momento"
},
"clipAnother": {
"message": "Ritaglia un'altra pagina"
},
"failure": {
"message": "Impossibile completare"
},
"genericError": {
"message": "Qualcosa è andato storto nel raggiungere la tua istanza Momento."
},
"retry": {
"message": "Riprova"
},
"errNoSelection": {
"message": "Seleziona prima il testo o ritaglia l'intera pagina."
},
"errAnalyzeFailed": {
"message": "Impossibile analizzare questa pagina."
},
"errSaveFailed": {
"message": "Impossibile salvare la nota."
},
"errNetwork": {
"message": "Problema di rete: controlla la connessione e l'URL Momento."
},
"bannerPickText": {
"message": "Evidenzia il testo sulla pagina o ritaglia l'intera pagina."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "モーメントウェブクリッパー"
},
"extDescription": {
"message": "Web ページとハイライトされたテキストを Momento ノートブックにキャプチャします。独自の Momento サーバーに接続します。"
},
"extActionTitle": {
"message": "モーメントにクリップ"
},
"webClipper": {
"message": "ウェブクリッパー"
},
"connected": {
"message": "接続済み"
},
"disconnected": {
"message": "接続されていません"
},
"instanceSettings": {
"message": "モーメントのURL"
},
"instanceUrlLabel": {
"message": "Momento インスタンスの URL"
},
"presetProduction": {
"message": "プロダクションプリセット・memento-note.com"
},
"applyReconnect": {
"message": "適用して再接続する"
},
"openMomento": {
"message": "モーメントを開く"
},
"settingsHint": {
"message": "Momento サーバーの HTTPS (または LAN) URL を貼り付けます。このブラウザの Cookie がサインインを処理します。"
},
"footerVersion": {
"message": "Momento Web クリッパー <<<バージョン>>>"
},
"errPermissionDenied": {
"message": "Momento はこのタブにアクセスできません。キーボード/サイト拡張機能の権限を確認するか、サイド パネルを開きます。"
},
"notebookUnnamed": {
"message": "無題のノート"
},
"noNotebooks": {
"message": "まだノートはありません"
},
"readingTimeOne": {
"message": "約 1 分で読めます"
},
"readingTimeOther": {
"message": "約$COUNT$ 分読み取り",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "選択が検出されました"
},
"ignore": {
"message": "無視する"
},
"selectionHint": {
"message": "ヒント: ページ上のテキストをハイライト表示して、正確な選択範囲をメモとしてクリップします。"
},
"clipSelection": {
"message": "クリップの選択"
},
"clipPage": {
"message": "このページをクリップします"
},
"saveLinkOnly": {
"message": "リンクのみを保存"
},
"pageNotAccessible": {
"message": "ここではクリップできません — このページは拡張機能へのアクセスをブロックしています。"
},
"errLoginRequired": {
"message": "まずこのブラウザで Momento にサインインしてください。"
},
"errLoadNotebooks": {
"message": "ノートブックをロードできませんでした。再接続してみてください。"
},
"notebooksLoaded": {
"message": "ノートブックがロードされました"
},
"connecting": {
"message": "接続中…"
},
"connectedToUrl": {
"message": "$URL$ に接続しました",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "制限されたページ — Momento ツールバーまたはサイド パネルを介してクリップします。"
},
"destinationNotebook": {
"message": "宛先ノートブック"
},
"activePage": {
"message": "アクティブなページ"
},
"previewBeforeSave": {
"message": "保存する前に確認してください"
},
"noteTitleLabel": {
"message": "タイトル"
},
"excerptLabel": {
"message": "抜粋"
},
"saveToMomento": {
"message": "モーメントに保存"
},
"back": {
"message": "戻る"
},
"analyzingSource": {
"message": "ソースを分析中"
},
"statusAnalyzing": {
"message": "分析中…"
},
"statusSaving": {
"message": "保存中…"
},
"processingDetail": {
"message": "タグ、意味の概要、および埋め込みを生成します。"
},
"noteSaved": {
"message": "メモが保存されました"
},
"sentToNotebook": {
"message": "$NOTEBOOK$ に保存されました",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "モメントで見る"
},
"clipAnother": {
"message": "別のページをクリップする"
},
"failure": {
"message": "完了できませんでした"
},
"genericError": {
"message": "Momento インスタンスに到達する際に問題が発生しました。"
},
"retry": {
"message": "リトライ"
},
"errNoSelection": {
"message": "最初にテキストを選択するか、ページ全体をクリップします。"
},
"errAnalyzeFailed": {
"message": "このページを分析できませんでした。"
},
"errSaveFailed": {
"message": "メモを保存できませんでした。"
},
"errNetwork": {
"message": "ネットワークの問題 — 接続と Momento URL を確認してください。"
},
"bannerPickText": {
"message": "ページ上のテキストを強調表示するか、ページ全体をクリップします。"
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "모멘토 웹 클리퍼"
},
"extDescription": {
"message": "웹 페이지와 강조 표시된 텍스트를 Momento 노트북에 캡처하여 자체 Momento 서버에 연결합니다."
},
"extActionTitle": {
"message": "순간에 클립"
},
"webClipper": {
"message": "웹 클리퍼"
},
"connected": {
"message": "연결됨"
},
"disconnected": {
"message": "연결되지 않음"
},
"instanceSettings": {
"message": "모멘토 URL"
},
"instanceUrlLabel": {
"message": "귀하의 Momento 인스턴스 URL"
},
"presetProduction": {
"message": "프로덕션 프리셋 · memento-note.com"
},
"applyReconnect": {
"message": "적용하고 다시 연결하세요"
},
"openMomento": {
"message": "모멘토 열기"
},
"settingsHint": {
"message": "Momento 서버의 HTTPS(또는 LAN) URL을 붙여넣습니다. 이 브라우저의 쿠키는 로그인을 처리합니다."
},
"footerVersion": {
"message": "Momento Web Clipper <<<버전>>>"
},
"errPermissionDenied": {
"message": "Momento는 이 탭에 접근할 수 없습니다. 키보드/사이트 확장 권한을 확인하거나 측면 패널을 엽니다."
},
"notebookUnnamed": {
"message": "제목 없는 노트"
},
"noNotebooks": {
"message": "아직 노트가 없습니다."
},
"readingTimeOne": {
"message": "~1분 읽기"
},
"readingTimeOther": {
"message": "대략. $COUNT$분 읽음",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "선택 항목이 감지되었습니다."
},
"ignore": {
"message": "무시하다"
},
"selectionHint": {
"message": "팁: 페이지의 텍스트를 강조 표시하여 정확한 선택 항목을 메모로 자릅니다."
},
"clipSelection": {
"message": "클립 선택"
},
"clipPage": {
"message": "이 페이지 클립"
},
"saveLinkOnly": {
"message": "링크만 저장"
},
"pageNotAccessible": {
"message": "여기서 클립할 수 없습니다. 이 페이지는 확장 프로그램 액세스를 차단합니다."
},
"errLoginRequired": {
"message": "먼저 이 브라우저에서 Momento에 로그인하세요."
},
"errLoadNotebooks": {
"message": "노트북을 로드할 수 없습니다. 다시 연결해 보세요."
},
"notebooksLoaded": {
"message": "노트북이 로드되었습니다."
},
"connecting": {
"message": "연결 중…"
},
"connectedToUrl": {
"message": "$URL$에 연결됨",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "제한된 페이지 — Momento 도구 모음 또는 측면 패널을 통해 클립합니다."
},
"destinationNotebook": {
"message": "대상 노트북"
},
"activePage": {
"message": "활성 페이지"
},
"previewBeforeSave": {
"message": "저장하기 전에 검토하세요"
},
"noteTitleLabel": {
"message": "제목"
},
"excerptLabel": {
"message": "발췌"
},
"saveToMomento": {
"message": "모멘토에 저장"
},
"back": {
"message": "뒤쪽에"
},
"analyzingSource": {
"message": "소스 분석 중"
},
"statusAnalyzing": {
"message": "분석 중…"
},
"statusSaving": {
"message": "절약…"
},
"processingDetail": {
"message": "태그, 의미 요약 및 임베딩을 생성합니다."
},
"noteSaved": {
"message": "메모가 저장되었습니다."
},
"sentToNotebook": {
"message": "$NOTEBOOK$에 저장됨",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Momento에서 보기"
},
"clipAnother": {
"message": "다른 페이지 자르기"
},
"failure": {
"message": "완료할 수 없습니다."
},
"genericError": {
"message": "Momento 인스턴스에 연결하는 데 문제가 발생했습니다."
},
"retry": {
"message": "다시 해 보다"
},
"errNoSelection": {
"message": "먼저 텍스트를 선택하거나 전체 페이지를 자릅니다."
},
"errAnalyzeFailed": {
"message": "이 페이지를 분석할 수 없습니다."
},
"errSaveFailed": {
"message": "메모를 저장할 수 없습니다."
},
"errNetwork": {
"message": "네트워크 문제 - 연결 및 Momento URL을 확인하세요."
},
"bannerPickText": {
"message": "페이지의 텍스트를 강조 표시하거나 전체 페이지를 자릅니다."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento Webclipper"
},
"extDescription": {
"message": "Leg webpagina's en gemarkeerde tekst vast in uw Momento-notebooks - maakt verbinding met uw eigen Momento-server."
},
"extActionTitle": {
"message": "Clip naar Momento"
},
"webClipper": {
"message": "Webclipper"
},
"connected": {
"message": "Aangesloten"
},
"disconnected": {
"message": "Niet verbonden"
},
"instanceSettings": {
"message": "Momento-URL"
},
"instanceUrlLabel": {
"message": "Uw Momento-instantie-URL"
},
"presetProduction": {
"message": "Productievoorinstelling · memento-note.com"
},
"applyReconnect": {
"message": "Toepassen en opnieuw verbinden"
},
"openMomento": {
"message": "Momento openen"
},
"settingsHint": {
"message": "Plak de HTTPS (of LAN) URL van uw Momento-server. Cookies in deze browser zorgen voor het inloggen."
},
"footerVersion": {
"message": "Momento Web Clipper <<<VERSIE>>>"
},
"errPermissionDenied": {
"message": "Momento heeft geen toegang tot dit tabblad. Controleer de rechten voor toetsenbord-/site-extensies — of open het zijpaneel."
},
"notebookUnnamed": {
"message": "Naamloos notitieboekje"
},
"noNotebooks": {
"message": "Nog geen notitieboekjes"
},
"readingTimeOne": {
"message": "~1 minuut lezen"
},
"readingTimeOther": {
"message": "Ongeveer. $COUNT$ min gelezen",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Selectie gedetecteerd"
},
"ignore": {
"message": "negeren"
},
"selectionHint": {
"message": "Tip: markeer tekst op de pagina om een precieze selectie als notitie te knippen."
},
"clipSelection": {
"message": "Clipselectie"
},
"clipPage": {
"message": "Knip deze pagina uit"
},
"saveLinkOnly": {
"message": "Alleen link opslaan"
},
"pageNotAccessible": {
"message": "Kan hier niet knippen: deze pagina blokkeert de toegang tot extensies."
},
"errLoginRequired": {
"message": "Meld u eerst aan bij Momento in deze browser."
},
"errLoadNotebooks": {
"message": "Kan notitieboekjes niet laden. Probeer opnieuw verbinding te maken."
},
"notebooksLoaded": {
"message": "Notitieboekjes geladen"
},
"connecting": {
"message": "Verbinden…"
},
"connectedToUrl": {
"message": "Verbonden met $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Beperkte pagina — clip via de Momento-werkbalk of het zijpaneel."
},
"destinationNotebook": {
"message": "Bestemmingsnotitieboekje"
},
"activePage": {
"message": "Actieve pagina"
},
"previewBeforeSave": {
"message": "Controleer voordat u opslaat"
},
"noteTitleLabel": {
"message": "Titel"
},
"excerptLabel": {
"message": "Uittreksel"
},
"saveToMomento": {
"message": "Opslaan in Momento"
},
"back": {
"message": "Rug"
},
"analyzingSource": {
"message": "Bron analyseren"
},
"statusAnalyzing": {
"message": "Analyseren…"
},
"statusSaving": {
"message": "Besparing…"
},
"processingDetail": {
"message": "Tags, een semantische samenvatting en insluitingen genereren."
},
"noteSaved": {
"message": "Notitie opgeslagen"
},
"sentToNotebook": {
"message": "Opgeslagen in $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Bekijk in Momento"
},
"clipAnother": {
"message": "Knip nog een pagina uit"
},
"failure": {
"message": "Kon niet voltooien"
},
"genericError": {
"message": "Er is iets misgegaan bij het bereiken van uw Momento-instantie."
},
"retry": {
"message": "Opnieuw proberen"
},
"errNoSelection": {
"message": "Selecteer eerst tekst of knip de volledige pagina uit."
},
"errAnalyzeFailed": {
"message": "Kan deze pagina niet analyseren."
},
"errSaveFailed": {
"message": "Kan uw notitie niet opslaan."
},
"errNetwork": {
"message": "Netwerkprobleem: controleer uw verbinding en Momento-URL."
},
"bannerPickText": {
"message": "Markeer tekst op de pagina of knip de hele pagina uit."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Narzędzie do strzyżenia sieci Momento"
},
"extDescription": {
"message": "Przechwytuj strony internetowe i zaznaczony tekst do swoich notatników Momento — łączy się z Twoim własnym serwerem Momento."
},
"extActionTitle": {
"message": "Klip do Momento"
},
"webClipper": {
"message": "Obcinacz sieci"
},
"connected": {
"message": "Połączony"
},
"disconnected": {
"message": "Nie podłączony"
},
"instanceSettings": {
"message": "Adres URL chwili"
},
"instanceUrlLabel": {
"message": "Adres URL Twojej instancji Momento"
},
"presetProduction": {
"message": "Wstępne ustawienia produkcyjne · memento-note.com"
},
"applyReconnect": {
"message": "Zastosuj i połącz ponownie"
},
"openMomento": {
"message": "Otwórz Momento"
},
"settingsHint": {
"message": "Wklej adres URL HTTPS (lub LAN) swojego serwera Momento. Pliki cookie w tej przeglądarce obsługują logowanie."
},
"footerVersion": {
"message": "Momento Web Clipper <<<WERSJA>>>"
},
"errPermissionDenied": {
"message": "Momento nie ma dostępu do tej karty. Sprawdź uprawnienia rozszerzenia klawiatury/witryny — lub otwórz Panel boczny."
},
"notebookUnnamed": {
"message": "Notatnik bez tytułu"
},
"noNotebooks": {
"message": "Nie ma jeszcze żadnych notatników"
},
"readingTimeOne": {
"message": "~1 minuta czytania"
},
"readingTimeOther": {
"message": "Około. $COUNT$ min odczytu",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Wykryto wybór"
},
"ignore": {
"message": "ignorować"
},
"selectionHint": {
"message": "Wskazówka: zaznacz tekst na stronie, aby wyciąć dokładne zaznaczenie jako notatkę."
},
"clipSelection": {
"message": "Wybór klipu"
},
"clipPage": {
"message": "Przytnij tę stronę"
},
"saveLinkOnly": {
"message": "Zapisz tylko link"
},
"pageNotAccessible": {
"message": "Nie można tutaj przyciąć — ta strona blokuje dostęp do rozszerzenia."
},
"errLoginRequired": {
"message": "Najpierw zaloguj się do Momento w tej przeglądarce."
},
"errLoadNotebooks": {
"message": "Nie udało się załadować notatników. Spróbuj połączyć się ponownie."
},
"notebooksLoaded": {
"message": "Notatniki załadowane"
},
"connecting": {
"message": "Złączony…"
},
"connectedToUrl": {
"message": "Połączono z $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Strona z ograniczeniami — klip za pomocą paska narzędzi Momento lub panelu bocznego."
},
"destinationNotebook": {
"message": "Notatnik docelowy"
},
"activePage": {
"message": "Aktywna strona"
},
"previewBeforeSave": {
"message": "Przejrzyj przed zapisaniem"
},
"noteTitleLabel": {
"message": "Tytuł"
},
"excerptLabel": {
"message": "Fragment"
},
"saveToMomento": {
"message": "Zapisz w Momento"
},
"back": {
"message": "Z powrotem"
},
"analyzingSource": {
"message": "Analizowanie źródła"
},
"statusAnalyzing": {
"message": "Analizuję…"
},
"statusSaving": {
"message": "Oszczędność…"
},
"processingDetail": {
"message": "Generowanie tagów, podsumowań semantycznych i osadzania."
},
"noteSaved": {
"message": "Uwaga zapisana"
},
"sentToNotebook": {
"message": "Zapisano w $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Zobacz w Momento"
},
"clipAnother": {
"message": "Wytnij kolejną stronę"
},
"failure": {
"message": "Nie udało się ukończyć"
},
"genericError": {
"message": "Coś poszło nie tak podczas docierania do Twojej instancji Momento."
},
"retry": {
"message": "Spróbować ponownie"
},
"errNoSelection": {
"message": "Najpierw zaznacz tekst lub wytnij całą stronę."
},
"errAnalyzeFailed": {
"message": "Nie można przeanalizować tej strony."
},
"errSaveFailed": {
"message": "Nie można zapisać notatki."
},
"errNetwork": {
"message": "Problem z siecią — sprawdź swoje połączenie i adres URL Momento."
},
"bannerPickText": {
"message": "Zaznacz tekst na stronie lub przytnij całą stronę."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento Web Clipper"
},
"extDescription": {
"message": "Capture páginas da web e texto destacado em seus blocos de anotações Momento conecte-se ao seu próprio servidor Momento."
},
"extActionTitle": {
"message": "Clipe para Momento"
},
"webClipper": {
"message": "Clipper da Web"
},
"connected": {
"message": "Conectado"
},
"disconnected": {
"message": "Não conectado"
},
"instanceSettings": {
"message": "URL do momento"
},
"instanceUrlLabel": {
"message": "URL da sua instância do Momento"
},
"presetProduction": {
"message": "Predefinição de produção · memento-note.com"
},
"applyReconnect": {
"message": "Aplicar e reconectar"
},
"openMomento": {
"message": "Momento aberto"
},
"settingsHint": {
"message": "Cole o URL HTTPS (ou LAN) do seu servidor Momento. Os cookies neste navegador controlam o login."
},
"footerVersion": {
"message": "Momento Web Clipper <<<VERSÃO>>>"
},
"errPermissionDenied": {
"message": "Momento não pode acessar esta guia. Verifique as permissões de extensão de teclado/site ou abra o painel lateral."
},
"notebookUnnamed": {
"message": "Caderno sem título"
},
"noNotebooks": {
"message": "Ainda não há cadernos"
},
"readingTimeOne": {
"message": "~1 minuto de leitura"
},
"readingTimeOther": {
"message": "Aprox. $COUNT$ minutos de leitura",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Seleção detectada"
},
"ignore": {
"message": "ignorar"
},
"selectionHint": {
"message": "Dica: destaque o texto na página para recortar uma seleção precisa como uma nota."
},
"clipSelection": {
"message": "Seleção de clipe"
},
"clipPage": {
"message": "Recorte esta página"
},
"saveLinkOnly": {
"message": "Salvar apenas link"
},
"pageNotAccessible": {
"message": "Não é possível recortar aqui esta página bloqueia o acesso à extensão."
},
"errLoginRequired": {
"message": "Faça login no Momento neste navegador primeiro."
},
"errLoadNotebooks": {
"message": "Não foi possível carregar os notebooks. Tente reconectar."
},
"notebooksLoaded": {
"message": "Cadernos carregados"
},
"connecting": {
"message": "Conectando…"
},
"connectedToUrl": {
"message": "Conectado a $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Página restrita recorte por meio da barra de ferramentas do Momento ou do painel lateral."
},
"destinationNotebook": {
"message": "Caderno de destino"
},
"activePage": {
"message": "Página ativa"
},
"previewBeforeSave": {
"message": "Revise antes de salvar"
},
"noteTitleLabel": {
"message": "Título"
},
"excerptLabel": {
"message": "Trecho"
},
"saveToMomento": {
"message": "Salvar no Momento"
},
"back": {
"message": "Voltar"
},
"analyzingSource": {
"message": "Analisando fonte"
},
"statusAnalyzing": {
"message": "Analisando…"
},
"statusSaving": {
"message": "Salvando…"
},
"processingDetail": {
"message": "Geração de tags, resumo semântico e incorporações."
},
"noteSaved": {
"message": "Nota salva"
},
"sentToNotebook": {
"message": "Salvo em $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Ver em Momento"
},
"clipAnother": {
"message": "Recortar outra página"
},
"failure": {
"message": "Não foi possível concluir"
},
"genericError": {
"message": "Algo deu errado ao chegar à sua instância do Momento."
},
"retry": {
"message": "Tentar novamente"
},
"errNoSelection": {
"message": "Selecione o texto primeiro ou recorte a página inteira."
},
"errAnalyzeFailed": {
"message": "Não foi possível analisar esta página."
},
"errSaveFailed": {
"message": "Não foi possível salvar sua nota."
},
"errNetwork": {
"message": "Problema de rede verifique sua conexão e URL do Momento."
},
"bannerPickText": {
"message": "Destaque o texto na página ou recorte a página inteira."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Веб-клипер Momento"
},
"extDescription": {
"message": "Сохраняйте веб-страницы и выделенный текст в свои блокноты Momento — подключайтесь к вашему собственному серверу Momento."
},
"extActionTitle": {
"message": "Клип на Momento"
},
"webClipper": {
"message": "Веб-клипер"
},
"connected": {
"message": "Подключено"
},
"disconnected": {
"message": "Не подключено"
},
"instanceSettings": {
"message": "URL-адрес момента"
},
"instanceUrlLabel": {
"message": "URL-адрес вашего экземпляра Momento"
},
"presetProduction": {
"message": "Настройки производства · memento-note.com"
},
"applyReconnect": {
"message": "Подать заявку и повторно подключиться"
},
"openMomento": {
"message": "Открыть Моменто"
},
"settingsHint": {
"message": "Вставьте URL-адрес HTTPS (или LAN) вашего сервера Momento. Файлы cookie в этом браузере обрабатывают вход в систему."
},
"footerVersion": {
"message": "Momento Web Clipper <<<ВЕРСИЯ>>>"
},
"errPermissionDenied": {
"message": "Momento не имеет доступа к этой вкладке. Проверьте разрешения для расширения клавиатуры/сайта или откройте боковую панель."
},
"notebookUnnamed": {
"message": "Блокнот без названия"
},
"noNotebooks": {
"message": "Блокнотов пока нет"
},
"readingTimeOne": {
"message": "~1 минута чтения"
},
"readingTimeOther": {
"message": "Прибл. $COUNT$ мин чтения",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Выбор обнаружен"
},
"ignore": {
"message": "игнорировать"
},
"selectionHint": {
"message": "Совет: выделите текст на странице, чтобы выделить его в виде заметки."
},
"clipSelection": {
"message": "Выбор клипа"
},
"clipPage": {
"message": "Вырезать эту страницу"
},
"saveLinkOnly": {
"message": "Сохранить только ссылку"
},
"pageNotAccessible": {
"message": "Невозможно обрезать здесь — эта страница блокирует доступ к расширению."
},
"errLoginRequired": {
"message": "Сначала войдите в Momento в этом браузере."
},
"errLoadNotebooks": {
"message": "Не удалось загрузить блокноты. Попробуйте переподключиться."
},
"notebooksLoaded": {
"message": "Ноутбуки загружены"
},
"connecting": {
"message": "Подключение…"
},
"connectedToUrl": {
"message": "Подключено к $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Страница с ограниченным доступом — вырезайте с помощью панели инструментов Momento или боковой панели."
},
"destinationNotebook": {
"message": "Блокнот назначения"
},
"activePage": {
"message": "Активная страница"
},
"previewBeforeSave": {
"message": "Проверьте перед сохранением"
},
"noteTitleLabel": {
"message": "Заголовок"
},
"excerptLabel": {
"message": "Отрывок"
},
"saveToMomento": {
"message": "Сохранить в Моменто"
},
"back": {
"message": "Назад"
},
"analyzingSource": {
"message": "Анализ источника"
},
"statusAnalyzing": {
"message": "Анализ…"
},
"statusSaving": {
"message": "Сохранение…"
},
"processingDetail": {
"message": "Генерация тегов, семантического резюме и вложений."
},
"noteSaved": {
"message": "Заметка сохранена."
},
"sentToNotebook": {
"message": "Сохранено в $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Посмотреть в Моменто"
},
"clipAnother": {
"message": "Вырезать другую страницу"
},
"failure": {
"message": "Не удалось завершить"
},
"genericError": {
"message": "Что-то пошло не так при получении вашего экземпляра Momento."
},
"retry": {
"message": "Повторить попытку"
},
"errNoSelection": {
"message": "Сначала выделите текст или вырежьте всю страницу."
},
"errAnalyzeFailed": {
"message": "Не удалось проанализировать эту страницу."
},
"errSaveFailed": {
"message": "Не удалось сохранить заметку."
},
"errNetwork": {
"message": "Проблема с сетью. Проверьте подключение и URL-адрес Momento."
},
"bannerPickText": {
"message": "Выделите текст на странице или вырежьте всю страницу."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento 网页剪辑器"
},
"extDescription": {
"message": "将网页和突出显示的文本捕获到您的 Momento 笔记本中 — 连接到您自己的 Momento 服务器。"
},
"extActionTitle": {
"message": "剪辑到时刻"
},
"webClipper": {
"message": "网页剪辑器"
},
"connected": {
"message": "已连接"
},
"disconnected": {
"message": "未连接"
},
"instanceSettings": {
"message": "时刻网址"
},
"instanceUrlLabel": {
"message": "您的 Momento 实例 URL"
},
"presetProduction": {
"message": "制作预设 · memento-note.com"
},
"applyReconnect": {
"message": "应用并重新连接"
},
"openMomento": {
"message": "打开时刻"
},
"settingsHint": {
"message": "粘贴 Momento 服务器的 HTTPS或 LANURL。此浏览器中的 Cookie 处理登录。"
},
"footerVersion": {
"message": "Momento Web Clipper <<<版本>>>"
},
"errPermissionDenied": {
"message": "Momento 无法访问此选项卡。检查键盘/站点扩展权限 - 或打开侧面板。"
},
"notebookUnnamed": {
"message": "无标题笔记本"
},
"noNotebooks": {
"message": "还没有笔记本"
},
"readingTimeOne": {
"message": "阅读约 1 分钟"
},
"readingTimeOther": {
"message": "大约。 $COUNT$ 最少阅读次数",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "检测到选择"
},
"ignore": {
"message": "忽略"
},
"selectionHint": {
"message": "提示:突出显示页面上的文本以将精确的选择剪辑为注释。"
},
"clipSelection": {
"message": "剪辑选择"
},
"clipPage": {
"message": "剪辑此页"
},
"saveLinkOnly": {
"message": "仅保存链接"
},
"pageNotAccessible": {
"message": "此处无法剪辑 — 此页面阻止扩展程序访问。"
},
"errLoginRequired": {
"message": "请先在此浏览器中登录 Momento。"
},
"errLoadNotebooks": {
"message": "无法加载笔记本。尝试重新连接。"
},
"notebooksLoaded": {
"message": "笔记本已加载"
},
"connecting": {
"message": "正在连接…"
},
"connectedToUrl": {
"message": "连接到 $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "受限页面 — 通过 Momento 工具栏或侧面板进行剪辑。"
},
"destinationNotebook": {
"message": "目的地笔记本"
},
"activePage": {
"message": "活动页面"
},
"previewBeforeSave": {
"message": "保存前查看"
},
"noteTitleLabel": {
"message": "标题"
},
"excerptLabel": {
"message": "摘抄"
},
"saveToMomento": {
"message": "保存到时刻"
},
"back": {
"message": "后退"
},
"analyzingSource": {
"message": "分析来源"
},
"statusAnalyzing": {
"message": "正在分析……"
},
"statusSaving": {
"message": "保存…"
},
"processingDetail": {
"message": "生成标签、语义摘要和嵌入。"
},
"noteSaved": {
"message": "注释已保存"
},
"sentToNotebook": {
"message": "保存至$NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "在 Momento 中查看"
},
"clipAnother": {
"message": "剪辑另一页"
},
"failure": {
"message": "无法完成"
},
"genericError": {
"message": "您的 Momento 实例出现问题。"
},
"retry": {
"message": "重试"
},
"errNoSelection": {
"message": "首先选择文本,或剪辑整个页面。"
},
"errAnalyzeFailed": {
"message": "无法分析此页面。"
},
"errSaveFailed": {
"message": "无法保存您的笔记。"
},
"errNetwork": {
"message": "网络问题 — 检查您的连接和 Momento URL。"
},
"bannerPickText": {
"message": "突出显示页面上的文本,或剪辑整个页面。"
}
}

View File

@@ -0,0 +1,8 @@
/** Service worker — ouvre le panneau latéral au clic sur licône. */
chrome.runtime.onInstalled.addListener(() => {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
})
chrome.runtime.onStartup.addListener(() => {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
})

View File

@@ -0,0 +1,211 @@
/**
* Content script Momento — sélection live, surlignage, communication avec le side panel.
* Injecté automatiquement sur http(s) ; ré-injecté à la demande si longlet était déjà ouvert.
*/
;(function initMementoClipperContent() {
if (globalThis.__mementoClipperContent) return
globalThis.__mementoClipperContent = true
const HIGHLIGHT_ID = 'memento-clipper-highlight-root'
const BANNER_ID = 'memento-clipper-banner-root'
const STYLE_ID = 'memento-clipper-styles'
let pickMode = false
let debounceTimer = null
function getSelectionText() {
return window.getSelection()?.toString().trim() || ''
}
function getPageMeta() {
const dir =
document.documentElement.getAttribute('dir') ||
document.body?.getAttribute('dir') ||
''
const lang = (
document.documentElement.getAttribute('lang') ||
document.body?.getAttribute('lang') ||
''
).split('-')[0]
return {
text: getSelectionText(),
dir,
lang,
url: location.href,
title: document.title,
}
}
function broadcastSelection() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
const payload = { type: 'SELECTION_CHANGED', ...getPageMeta() }
try {
chrome.runtime.sendMessage(payload).catch(() => {})
} catch {
/* ignore */
}
if (pickMode) paintHighlight()
}, 80)
}
function removeHighlight() {
document.getElementById(HIGHLIGHT_ID)?.remove()
}
function paintHighlight() {
removeHighlight()
const sel = window.getSelection()
if (!sel || sel.isCollapsed || !sel.rangeCount) return
let range
try {
range = sel.getRangeAt(0)
} catch {
return
}
const host = document.createElement('div')
host.id = HIGHLIGHT_ID
host.setAttribute('aria-hidden', 'true')
host.style.cssText =
'position:fixed;inset:0;pointer-events:none;z-index:2147483644;overflow:hidden;'
for (const rect of range.getClientRects()) {
if (rect.width < 2 || rect.height < 2) continue
const box = document.createElement('div')
box.style.cssText = [
'position:fixed',
`left:${rect.left - 2}px`,
`top:${rect.top - 1}px`,
`width:${rect.width + 4}px`,
`height:${rect.height + 2}px`,
'background:rgba(164,113,72,0.28)',
'border-radius:3px',
'box-shadow:0 0 0 1px rgba(164,113,72,0.35)',
'transition:opacity 0.15s ease',
].join(';')
host.appendChild(box)
}
if (host.childNodes.length) document.documentElement.appendChild(host)
}
function ensureStyles() {
if (document.getElementById(STYLE_ID)) return
const style = document.createElement('style')
style.id = STYLE_ID
style.textContent = `
html.memento-clipper-pick ::selection {
background: rgba(164, 113, 72, 0.45) !important;
color: inherit !important;
}
html.memento-clipper-pick {
scroll-behavior: auto;
}
`
document.documentElement.appendChild(style)
}
function removeBanner() {
document.getElementById(BANNER_ID)?.remove()
}
function ensureBanner() {
if (document.getElementById(BANNER_ID)) return
const bannerText =
(typeof chrome !== 'undefined' && chrome.i18n?.getMessage?.('bannerPickText')) ||
'Highlight the text to clip'
const host = document.createElement('div')
host.id = BANNER_ID
host.style.cssText =
'all:initial;position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:none;font-family:Inter,system-ui,sans-serif;'
const shadow = host.attachShadow({ mode: 'open' })
shadow.innerHTML = `
<style>
.pill {
display: flex; align-items: center; gap: 10px;
padding: 10px 18px; border-radius: 999px;
background: #1c1c1c; color: #faf9f5;
box-shadow: 0 12px 32px rgba(0,0,0,0.22);
font-size: 12px; font-weight: 600;
letter-spacing: 0.02em;
animation: slideIn 0.35s cubic-bezier(0.22,1,0.36,1);
}
.logo {
width: 22px; height: 22px; border-radius: 7px;
background: #faf9f5; color: #1c1c1c;
display: flex; align-items: center; justify-content: center;
font-family: Georgia, serif; font-weight: 900; font-size: 12px;
}
.dot {
width: 8px; height: 8px; border-radius: 50%;
background: #a47148; animation: pulse 1.2s ease infinite;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.85); }
}
</style>
<div class="pill">
<span class="logo">M</span>
<span class="dot"></span>
<span>${bannerText.replace(/</g, '&lt;')}</span>
</div>
`
document.documentElement.appendChild(host)
}
function setPickMode(enabled) {
pickMode = !!enabled
ensureStyles()
if (pickMode) {
document.documentElement.classList.add('memento-clipper-pick')
ensureBanner()
paintHighlight()
} else {
document.documentElement.classList.remove('memento-clipper-pick')
removeBanner()
removeHighlight()
}
}
function onScrollOrResize() {
if (pickMode) paintHighlight()
}
document.addEventListener('selectionchange', broadcastSelection)
document.addEventListener('mouseup', broadcastSelection)
document.addEventListener('keyup', broadcastSelection)
window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true })
window.addEventListener('resize', onScrollOrResize, { passive: true })
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === 'PING') {
sendResponse({ ok: true })
return true
}
if (message?.type === 'GET_CONTEXT') {
sendResponse({
html: document.documentElement.outerHTML,
...getPageMeta(),
})
return true
}
if (message?.type === 'SET_PICK_MODE') {
setPickMode(!!message.enabled)
sendResponse({ ok: true, pickMode })
return true
}
return false
})
broadcastSelection()
})()

View File

@@ -0,0 +1,47 @@
/** Helpers i18n — langue UI Chrome (chrome.i18n.getUILanguage). */
function t(key, ...subs) {
const msg = chrome.i18n.getMessage(key, subs)
return msg || key
}
function uiLocaleTag() {
return (chrome.i18n.getUILanguage() || 'en').replace('_', '-').split('-')[0]
}
function isUiRtl() {
return ['ar', 'fa', 'he', 'ur'].includes(uiLocaleTag())
}
function applyDocumentLocale() {
const lang = uiLocaleTag()
document.documentElement.lang = lang
document.documentElement.dir = isUiRtl() ? 'rtl' : 'ltr'
document.body?.classList.toggle('ui-rtl', isUiRtl())
}
function applyShellI18n() {
document.title = t('extName')
const brandSub = document.querySelector('.brand-sub')
if (brandSub) brandSub.textContent = t('webClipper')
if (typeof els !== 'undefined' && els.connLabel) {
els.connLabel.textContent = t('connected')
}
if (typeof els !== 'undefined' && els.settingsBtn) {
els.settingsBtn.title = t('instanceSettings')
els.settingsBtn.setAttribute('aria-label', t('instanceSettings'))
}
const urlLabel = document.getElementById('instanceUrlLabel') || document.querySelector('#settingsPanel .field > span')
if (urlLabel) urlLabel.textContent = t('instanceUrlLabel')
const presetProd = document.querySelector('[data-url="https://memento-note.com"]')
if (presetProd) presetProd.textContent = t('presetProduction')
if (typeof els !== 'undefined' && els.applyInstanceBtn) {
els.applyInstanceBtn.textContent = t('applyReconnect')
}
if (typeof els !== 'undefined' && els.openLoginBtn) {
els.openLoginBtn.textContent = `${t('openMomento')}`
}
const hint = document.querySelector('.settings-hint')
if (hint) hint.textContent = t('settingsHint')
const footer = document.querySelector('.footer-meta')
if (footer) footer.textContent = t('footerVersion')
}

View File

@@ -0,0 +1,215 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-require-imports */
'use strict'
const fs = require('fs')
const path = require('path')
const zlib = require('zlib')
const VERSION = "0.3.1"
// Embedded Momento clipper/extension string table (gzip+base64).
// Source English + contextual French anchors; other locales are MT batch
// post-edited with placeholder hardening.
const PACK_B64 = `
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
wOW7jPkAAA==
`
function loadStrings () {
const buf = Buffer.from(PACK_B64.replace(/\s+/g, ''), 'base64')
return JSON.parse(zlib.gunzipSync(buf).toString('utf8'))
}
function main () {
const STRINGS = loadStrings()
const payload = {
version: VERSION,
strings: STRINGS,
}
fs.writeFileSync(
path.join(__dirname, 'translations.json'),
JSON.stringify(payload, null, 2) + "\n",
'utf8'
)
console.log('Wrote', path.join(__dirname, 'translations.json'))
}
main()

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<rect width="192" height="192" fill="#1C1C1C" rx="40"/>
<g transform="translate(96,96)">
<rect x="-34" y="-34" width="68" height="68" rx="5" fill="white"/>
<path d="M34,34 L34,11 L11,34 Z" fill="#1C1C1C" opacity="0.28"/>
<line x1="-23" y1="-17" x2="23" y2="-17" stroke="#e5e7eb" stroke-width="3" stroke-linecap="round"/>
<line x1="-23" y1="-4" x2="23" y2="-4" stroke="#e5e7eb" stroke-width="3" stroke-linecap="round"/>
<line x1="-23" y1="9" x2="11" y2="9" stroke="#e5e7eb" stroke-width="3" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 616 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#1C1C1C" rx="100"/>
<g transform="translate(256,256)">
<rect x="-90" y="-90" width="180" height="180" rx="12" fill="white"/>
<path d="M90,90 L90,30 L30,90 Z" fill="#1C1C1C" opacity="0.28"/>
<line x1="-60" y1="-45" x2="60" y2="-45" stroke="#e5e7eb" stroke-width="8" stroke-linecap="round"/>
<line x1="-60" y1="-10" x2="60" y2="-10" stroke="#e5e7eb" stroke-width="8" stroke-linecap="round"/>
<line x1="-60" y1="25" x2="30" y2="25" stroke="#e5e7eb" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 626 B

View File

@@ -0,0 +1,51 @@
{
"manifest_version": 3,
"name": "__MSG_extName__",
"version": "0.3.1",
"description": "__MSG_extDescription__",
"default_locale": "en",
"permissions": [
"activeTab",
"scripting",
"storage",
"sidePanel",
"tabs"
],
"host_permissions": [
"https://memento-note.com/*",
"http://*/*",
"https://*/*"
],
"background": {
"service_worker": "background.js"
},
"side_panel": {
"default_path": "sidepanel.html"
},
"content_scripts": [
{
"matches": [
"http://*/*",
"https://*/*"
],
"js": [
"content.js"
],
"run_at": "document_idle",
"all_frames": false
}
],
"action": {
"default_title": "__MSG_extActionTitle__",
"default_icon": {
"16": "icon-16.png",
"48": "icon-48.png",
"128": "icon-128.png"
}
},
"icons": {
"16": "icon-16.png",
"48": "icon-48.png",
"128": "icon-128.png"
}
}

View File

@@ -0,0 +1,507 @@
:root {
--ink: #1c1c1c;
--paper: #faf9f5;
--card: #ffffff;
--muted: #6b7280;
--border: #e8e4dc;
--accent: #a47148;
--accent-soft: rgba(164, 113, 72, 0.12);
--accent-glow: rgba(164, 113, 72, 0.35);
--success: #10b981;
--danger: #ef4444;
--shadow: 0 18px 40px rgba(28, 28, 28, 0.08);
--radius: 14px;
--radius-sm: 10px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 13px;
color: var(--ink);
background: var(--paper);
}
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--paper);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
background: linear-gradient(180deg, #fff 0%, #fcfcfa 100%);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-logo {
width: 34px; height: 34px; border-radius: 11px;
background: var(--ink); color: #faf9f5;
display: flex; align-items: center; justify-content: center;
font-family: Georgia, 'Times New Roman', serif;
font-weight: 900; font-size: 16px;
box-shadow: 0 4px 14px rgba(28, 28, 28, 0.18);
}
.brand-text { line-height: 1.1; }
.brand-name {
display: block; font-size: 14px; font-weight: 700;
font-family: Georgia, 'Times New Roman', serif;
}
.brand-sub {
display: block; font-size: 9px; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--accent); font-weight: 700;
}
.icon-btn {
width: 34px; height: 34px; border-radius: 10px;
border: 1px solid var(--border); background: #fff;
color: var(--muted); cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.icon-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-soft);
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
}
.conn-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 9px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
}
.conn-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #10b981;
}
.settings-panel {
padding: 14px 18px;
background: #fff;
border-bottom: 1px solid var(--border);
}
.settings-panel[hidden] { display: none !important; }
.settings-hint {
margin: 8px 0 0;
font-size: 11px;
color: var(--muted);
line-height: 1.5;
}
.settings-hint code {
font-size: 10px;
background: var(--paper);
padding: 1px 4px;
border-radius: 4px;
}
.settings-status {
margin: 10px 0 0;
font-size: 11px;
line-height: 1.45;
}
.settings-status.is-ok { color: #059669; }
.settings-status.is-error { color: #dc2626; }
.preset-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.preset-btn {
border: 1px solid var(--border);
background: var(--paper);
border-radius: 999px;
padding: 6px 10px;
font-size: 10px;
font-weight: 700;
cursor: pointer;
color: var(--muted);
}
.preset-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-soft);
}
.settings-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field span {
font-size: 9px; text-transform: uppercase; letter-spacing: 0.12em;
color: var(--muted); font-weight: 700;
}
input[type="url"],
input[type="text"],
.notebook-select {
width: 100%; padding: 10px 12px; border: 1px solid var(--border);
border-radius: var(--radius-sm); background: var(--paper);
font-family: inherit; font-size: 12px;
}
input[type="url"]:focus,
input[type="text"]:focus,
.notebook-select:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.notebook-select {
background: #fff;
font-weight: 600;
cursor: pointer;
}
.main {
flex: 1;
padding: 16px 18px 20px;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.main > .actions {
margin-top: auto;
}
.footer {
padding: 10px 18px 14px;
border-top: 1px solid var(--border);
background: #fff;
text-align: center;
}
.footer-meta { font-size: 9px; color: #9ca3af; letter-spacing: 0.06em; }
.label {
font-size: 9px; text-transform: uppercase; letter-spacing: 0.14em;
color: var(--muted); font-weight: 700; margin-bottom: 8px; display: block;
}
.auth-hint {
padding: 12px 14px;
border-radius: var(--radius);
background: #fffbeb;
border: 1px solid #fde68a;
font-size: 11px;
color: #92400e;
line-height: 1.5;
}
.page-card {
padding: 14px; border: 1px solid var(--border); border-radius: var(--radius);
background: #fff;
box-shadow: 0 1px 0 rgba(255,255,255,0.8) inset;
}
.page-card .sub {
font-size: 9px; text-transform: uppercase; letter-spacing: 0.14em;
color: var(--muted); font-weight: 700; display: block; margin-bottom: 8px;
}
.page-row { display: flex; gap: 10px; align-items: flex-start; min-width: 0; }
.page-row img {
width: 20px; height: 20px; border-radius: 5px;
flex-shrink: 0; margin-top: 2px;
}
.page-text { min-width: 0; flex: 1; }
.page-row .title {
font-size: 12px; font-weight: 700; line-height: 1.45;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
unicode-bidi: plaintext;
}
.page-row .url {
font-size: 10px; color: var(--muted); margin-top: 4px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
direction: ltr; text-align: left;
}
.text-rtl {
direction: rtl;
text-align: right;
font-family: 'Vazirmatn', 'Inter', sans-serif;
unicode-bidi: plaintext;
}
.selection-panel {
border-radius: var(--radius);
border: 1px solid var(--border);
background: #fff;
overflow: hidden;
min-height: 140px;
display: flex;
flex-direction: column;
}
.selection-panel.has-text {
border-color: #bae6fd;
background: rgba(14, 165, 233, 0.05);
box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.12);
}
.selection-hint {
padding: 16px;
border: 1px dashed var(--border);
border-radius: var(--radius);
text-align: center;
background: #fff;
}
.selection-hint p {
margin: 0;
font-size: 11px;
color: var(--muted);
line-height: 1.55;
}
.selection-head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px;
background: linear-gradient(180deg, #fff 0%, #fdfcfa 100%);
border-bottom: 1px solid var(--border);
}
.selection-head .status {
display: flex; align-items: center; gap: 8px;
font-size: 10px; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--muted);
}
.selection-head .status.live { color: #0284c7; }
.selection-head .count {
font-size: 10px; font-weight: 700; color: var(--muted);
background: var(--paper); padding: 4px 8px; border-radius: 999px;
}
.selection-head .count.active { color: var(--accent); background: var(--accent-soft); }
.selection-body {
flex: 1;
padding: 14px;
font-size: 13px;
line-height: 1.75;
color: rgba(28, 28, 28, 0.88);
max-height: 220px;
overflow-y: auto;
unicode-bidi: plaintext;
border-inline-start: 3px solid transparent;
}
.selection-panel.has-text .selection-body {
border-inline-start-color: #38bdf8;
padding-inline-start: 16px;
font-style: italic;
font-size: 12px;
max-height: 150px;
}
.pulse-dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
animation: pulse 1.4s ease infinite;
}
.pulse-dot.sky { background: #0ea5e9; }
@keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.4;transform:scale(.85)} }
.clear-btn {
border: none; background: none; font-size: 10px;
color: var(--muted); cursor: pointer; font-weight: 600;
padding: 4px 6px; border-radius: 6px;
}
.clear-btn:hover { color: var(--ink); background: var(--paper); }
.actions {
display: flex; flex-direction: column; gap: 10px;
margin-top: auto; padding-top: 6px;
}
.btn {
padding: 14px 16px; border-radius: var(--radius); border: none; cursor: pointer;
font-weight: 700; font-size: 10px; text-transform: uppercase;
letter-spacing: 0.1em;
display: flex; align-items: center; justify-content: center; gap: 8px;
transition: transform 0.12s ease, opacity 0.12s ease, filter 0.12s ease;
}
.btn:active { transform: scale(0.98); }
.btn:disabled {
opacity: 0.42; cursor: not-allowed; transform: none;
box-shadow: none !important;
}
.btn-primary {
background: var(--ink); color: #fff;
box-shadow: 0 10px 24px rgba(28, 28, 28, 0.18);
}
.btn-primary:hover:not(:disabled) { opacity: 0.94; }
.btn-sky {
background: #0284c7;
color: #fff;
box-shadow: 0 10px 22px rgba(2, 132, 199, 0.22);
}
.btn-sky:hover:not(:disabled) { background: #0369a1; }
.btn-secondary {
background: #f3f4f6;
color: #374151;
box-shadow: none;
}
.btn-secondary:hover:not(:disabled) { background: #e5e7eb; }
.btn-sm {
padding: 10px 12px;
font-size: 10px;
}
.btn-danger { background: var(--danger); color: #fff; }
.btn-link.link-only {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn-link {
background: none; border: none; color: var(--muted); font-size: 11px;
cursor: pointer; padding: 8px; font-weight: 500;
}
.btn-link:hover { color: var(--ink); text-decoration: underline; }
.btn-icon { width: 14px; height: 14px; display: inline-flex; }
.center-state {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; text-align: center; gap: 14px; padding: 32px 12px;
min-height: 280px;
}
.spinner-wrap { position: relative; width: 52px; height: 52px; }
.spinner-ring {
position: absolute; inset: 0; border-radius: 50%;
border: 1px solid var(--border); animation: ping 1.2s ease infinite;
}
@keyframes ping { 0%{transform:scale(1);opacity:.6} 100%{transform:scale(1.35);opacity:0} }
.spinner {
position: absolute; inset: 6px;
border: 3px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: spin 0.75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.state-title {
font-size: 10px; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.14em; color: var(--muted);
}
.state-sub { font-size: 15px; font-weight: 700; color: var(--ink); }
.state-detail {
font-size: 11px; color: var(--muted); max-width: 280px;
line-height: 1.55; margin: 0 auto;
}
.success-icon, .error-icon {
width: 58px; height: 58px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 26px; font-weight: 700;
}
.success-icon {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.25); color: var(--success);
}
.error-icon { background: #fef2f2; color: var(--danger); }
.badge-ok {
display: inline-block; margin-bottom: 8px;
font-size: 9px; background: rgba(16, 185, 129, 0.12);
color: #059669; font-weight: 800; padding: 3px 8px; border-radius: 6px;
text-transform: uppercase; letter-spacing: 0.1em;
}
.note-title {
font-size: 15px; font-weight: 700;
font-family: Georgia, 'Times New Roman', serif;
line-height: 1.35; margin-top: 6px;
unicode-bidi: plaintext;
}
.tags {
display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;
padding-top: 14px; border-top: 1px solid var(--border); margin-top: 10px;
width: 100%;
}
.tag-chip {
font-size: 9px; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--accent);
background: var(--accent-soft); border: 1px solid rgba(164, 113, 72, 0.2);
padding: 5px 10px; border-radius: 999px;
}
.restricted-note {
padding: 14px; border-radius: var(--radius);
background: #fef2f2; border: 1px solid #fecaca;
font-size: 11px; color: #991b1b; line-height: 1.5;
}
.confirm-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.summary-preview {
margin: 0;
font-size: 12px;
color: var(--muted);
line-height: 1.55;
font-style: italic;
}
.excerpt-preview {
padding: 12px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: #fff;
font-size: 12px;
line-height: 1.65;
max-height: 150px;
overflow-y: auto;
unicode-bidi: plaintext;
}
.excerpt-label {
display: block;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
font-weight: 700;
margin-bottom: 8px;
}
.meta-row { margin-top: -4px; }
.reading-time {
font-size: 10px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.preview-tags { justify-content: flex-start; border-top: none; margin-top: 0; padding-top: 0; }
html[dir="rtl"] .header,
html[dir="rtl"] .header-right,
html[dir="rtl"] .selection-head,
html[dir="rtl"] .page-row {
flex-direction: row-reverse;
}
html[dir="rtl"] .page-row .url {
direction: ltr;
text-align: left;
}
html[dir="rtl"] .notebook-select,
html[dir="rtl"] .dropdown-item,
html[dir="rtl"] .label,
html[dir="rtl"] .sub {
text-align: right;
}

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Momento Web Clipper</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Vazirmatn:wght@400;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="sidepanel.css" />
</head>
<body>
<div id="app" class="shell">
<header class="header">
<div class="brand">
<div class="brand-logo">M</div>
<div class="brand-text">
<span class="brand-name">Momento</span>
<span class="brand-sub">Web Clipper</span>
</div>
</div>
<div class="header-right">
<div id="connBadge" class="conn-badge" hidden>
<span class="conn-dot"></span>
<span id="connLabel"></span>
</div>
<button type="button" id="settingsBtn" class="icon-btn" title="" aria-label="">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
</button>
</div>
</header>
<div id="settingsPanel" class="settings-panel" hidden>
<label class="field">
<span id="instanceUrlLabel"></span>
<input id="baseUrl" type="text" spellcheck="false" placeholder="http://localhost:3000" />
</label>
<div class="preset-row">
<button type="button" class="preset-btn" data-url="https://memento-note.com"></button>
<button type="button" class="preset-btn" data-url="http://localhost:3000">localhost:3000</button>
<button type="button" class="preset-btn" data-url="http://127.0.0.1:3000">127.0.0.1:3000</button>
</div>
<div class="settings-actions">
<button type="button" id="applyInstanceBtn" class="btn btn-primary btn-sm"></button>
<button type="button" id="openLoginBtn" class="btn btn-secondary btn-sm"></button>
</div>
<p class="settings-hint"></p>
<p id="settingsStatus" class="settings-status" hidden></p>
</div>
<main id="screen" class="main"></main>
<footer class="footer">
<span class="footer-meta"></span>
</footer>
</div>
<script src="i18n.js"></script>
<script src="sidepanel.js"></script>
</body>
</html>

View File

@@ -0,0 +1,712 @@
/** Mettre à false pour le build Chrome Web Store (URL production en dur). */
const ALLOW_INSTANCE_CONFIG = false
const DEFAULT_BASE = 'https://memento-note.com'
const STORAGE_KEYS = { baseUrl: 'memento_clipper_base_url', notebookId: 'memento_clipper_notebook_id' }
let state = 'idle'
let notebooks = []
let selectedNotebookId = ''
let pageUrl = ''
let pageTitle = ''
let pageDomain = ''
let pageFavicon = ''
let pageHtml = ''
let pageDir = 'ltr'
let pageLang = ''
let selectionText = ''
let pageRestricted = false
let lastNoteId = ''
let lastNoteUrl = ''
let successTitle = ''
let successTags = []
let errorMessage = ''
let activeTabId = null
let pendingClipType = 'page'
let analyzeResult = null
let editableTitle = ''
let connected = false
const els = {
screen: document.getElementById('screen'),
baseUrl: document.getElementById('baseUrl'),
settingsPanel: document.getElementById('settingsPanel'),
settingsBtn: document.getElementById('settingsBtn'),
connBadge: document.getElementById('connBadge'),
connLabel: document.getElementById('connLabel'),
settingsStatus: document.getElementById('settingsStatus'),
applyInstanceBtn: document.getElementById('applyInstanceBtn'),
openLoginBtn: document.getElementById('openLoginBtn'),
}
const ICON_SELECT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>'
const ICON_CLIP =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>'
const ICON_LINK =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'
function apiBase() {
if (!ALLOW_INSTANCE_CONFIG) return DEFAULT_BASE
return (els.baseUrl?.value || DEFAULT_BASE).replace(/\/$/, '')
}
function isRestrictedUrl(url) {
return !url || /^(chrome|chrome-extension|edge|about|moz-extension|devtools):/i.test(url)
}
async function ensureApiPermission() {
const origin = `${apiBase()}/*`
const has = await chrome.permissions.contains({ origins: [origin] })
if (!has) {
const granted = await chrome.permissions.request({ origins: [origin] })
if (!granted) throw new Error(t('errPermissionDenied'))
}
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
const RTL_CHAR = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
const LTR_CHAR = /[A-Za-z0-9]/
function detectTextDirection(text) {
const sample = String(text || '').replace(/\s+/g, '').slice(0, 3000)
if (!sample) return 'ltr'
let rtl = 0
let ltr = 0
for (const ch of sample) {
if (RTL_CHAR.test(ch)) rtl++
else if (LTR_CHAR.test(ch)) ltr++
}
if (rtl === 0) return 'ltr'
return rtl >= ltr ? 'rtl' : 'ltr'
}
function resolveUiDirection(text) {
if (pageDir === 'rtl') return 'rtl'
if (pageLang === 'fa' || pageLang === 'ar' || pageLang === 'he') return 'rtl'
if (/\/persian\b|\/fa\b|bbc\.com\/persian/i.test(pageUrl)) return 'rtl'
return detectTextDirection(text)
}
function rtlAttrs(text) {
if (resolveUiDirection(text) !== 'rtl') return ''
const lang = pageLang && ['fa', 'ar', 'he'].includes(pageLang) ? ` lang="${pageLang}"` : ''
return ` class="text-rtl" dir="rtl"${lang}`
}
function sortNotebooksHierarchy(list) {
const byParent = new Map()
for (const n of list) {
const pid = n.parentId || '__root__'
if (!byParent.has(pid)) byParent.set(pid, [])
byParent.get(pid).push(n)
}
for (const items of byParent.values()) {
items.sort((a, b) => (a.name || '').localeCompare(b.name || '', uiLocaleTag()))
}
const out = []
const seen = new Set()
function walk(parentKey, depth) {
for (const n of byParent.get(parentKey) || []) {
if (seen.has(n.id)) continue
seen.add(n.id)
out.push({ ...n, depth })
walk(n.id, depth + 1)
}
}
walk('__root__', 0)
for (const n of list) {
if (!seen.has(n.id)) out.push({ ...n, depth: 0 })
}
return out
}
function notebookSelectHtml() {
const sorted = sortNotebooksHierarchy(notebooks)
const opts = sorted
.map((n) => {
const indent = n.depth > 0 ? '\u00A0\u00A0'.repeat(n.depth) + '↳ ' : ''
const sel = n.id === selectedNotebookId ? ' selected' : ''
return `<option value="${escapeHtml(n.id)}"${sel}>${escapeHtml(indent + (n.name || t('notebookUnnamed')))}</option>`
})
.join('')
return `<select id="notebookSelect" class="notebook-select" aria-label="${escapeHtml(t('destinationNotebook'))}">
${notebooks.length ? opts : `<option value="">${escapeHtml(t('noNotebooks'))}</option>`}
</select>`
}
function formatReadingTime(minutes) {
const m = Number(minutes) || 0
if (m <= 0) return ''
if (m === 1) return t('readingTimeOne')
return t('readingTimeOther', String(m))
}
async function getActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
return tab
}
async function ensureContentScript(tabId) {
try {
const resp = await chrome.tabs.sendMessage(tabId, { type: 'PING' })
if (resp?.ok) return true
} catch {
/* inject */
}
try {
await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] })
return true
} catch {
return false
}
}
async function setPickModeOnTab(enabled) {
if (!activeTabId || pageRestricted) return
const ok = await ensureContentScript(activeTabId)
if (!ok) return
try {
await chrome.tabs.sendMessage(activeTabId, { type: 'SET_PICK_MODE', enabled })
} catch {
/* ignore */
}
}
async function syncPickMode() {
await setPickModeOnTab(state === 'idle' && !pageRestricted)
}
function updateConnBadge() {
if (!els.connBadge) return
els.connBadge.hidden = !connected
if (els.connLabel) els.connLabel.textContent = connected ? t('connected') : t('disconnected')
}
function setSettingsStatus(msg, isError) {
if (!els.settingsStatus) return
els.settingsStatus.hidden = !msg
els.settingsStatus.textContent = msg || ''
els.settingsStatus.className = `settings-status${isError ? ' is-error' : ' is-ok'}`
}
function applyInstanceConfigVisibility() {
if (ALLOW_INSTANCE_CONFIG) return
els.settingsPanel?.setAttribute('hidden', '')
els.settingsBtn?.setAttribute('hidden', '')
if (els.baseUrl) els.baseUrl.value = DEFAULT_BASE
}
function selectionBlockHtml() {
if (selectionText) {
return `<div class="selection-panel has-text" id="selectionSlot">
<div class="selection-head">
<span class="status live"><span class="pulse-dot sky"></span> ${escapeHtml(t('selectionDetected'))}</span>
<button type="button" class="clear-btn" id="clearSel">${escapeHtml(t('ignore'))}</button>
</div>
<div class="selection-body"${rtlAttrs(selectionText)}>「 ${escapeHtml(selectionText)} 」</div>
</div>`
}
return `<div class="selection-hint" id="selectionSlot">
<p>${escapeHtml(t('selectionHint'))}</p>
</div>`
}
function actionsBlockHtml() {
const hasSel = Boolean(selectionText)
return `<div class="actions" id="actionsSlot">
${
hasSel
? `<button type="button" class="btn btn-sky" id="clipSelBtn">
${ICON_SELECT} ${escapeHtml(t('clipSelection'))}
</button>`
: ''
}
<button type="button" class="btn ${hasSel ? 'btn-secondary' : 'btn-primary'}" id="clipPageBtn" ${pageRestricted ? 'disabled' : ''}>
${ICON_CLIP} ${escapeHtml(t('clipPage'))}
</button>
<button type="button" class="btn-link link-only" id="clipLinkBtn" ${pageRestricted ? 'disabled' : ''}>
${ICON_LINK} ${escapeHtml(t('saveLinkOnly'))}
</button>
</div>`
}
function bindIdleHandlers() {
document.getElementById('notebookSelect')?.addEventListener('change', async (e) => {
selectedNotebookId = e.target.value || ''
await chrome.storage.sync.set({ [STORAGE_KEYS.notebookId]: selectedNotebookId })
})
document.getElementById('clearSel')?.addEventListener('click', () => void clearSelection())
document.getElementById('clipSelBtn')?.addEventListener('click', () => void runAnalyze('selection'))
document.getElementById('clipPageBtn')?.addEventListener('click', () => void runAnalyze('page'))
document.getElementById('clipLinkBtn')?.addEventListener('click', () => void runAnalyze('link'))
}
async function clearSelection() {
selectionText = ''
if (activeTabId) {
try {
await chrome.scripting.executeScript({
target: { tabId: activeTabId },
func: () => window.getSelection()?.removeAllRanges(),
})
} catch {
/* ignore */
}
}
updateSelectionUI()
}
function updateSelectionUI() {
const slot = document.getElementById('selectionSlot')
const actions = document.getElementById('actionsSlot')
if (!slot || !actions || state !== 'idle') {
render()
return
}
slot.outerHTML = selectionBlockHtml()
actions.outerHTML = actionsBlockHtml()
bindIdleHandlers()
}
function applySelectionFromMessage(msg) {
if (!msg || msg.url !== pageUrl) return
selectionText = msg.text || ''
if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl'
if (msg.lang) pageLang = msg.lang
if (state === 'idle') updateSelectionUI()
}
async function refreshPageContext() {
const tab = await getActiveTab()
activeTabId = tab?.id ?? null
pageRestricted = isRestrictedUrl(tab?.url)
if (!tab?.id || pageRestricted) {
pageUrl = tab?.url || ''
pageTitle = tab?.title || t('pageNotAccessible')
selectionText = ''
return
}
pageUrl = tab.url
pageTitle = tab.title || ''
try {
const u = new URL(pageUrl)
pageDomain = u.hostname
pageFavicon = `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32`
} catch {
pageDomain = pageUrl
pageFavicon = 'https://www.google.com/s2/favicons?domain=google.com&sz=32'
}
const ok = await ensureContentScript(tab.id)
if (!ok) return
try {
const ctx = await chrome.tabs.sendMessage(tab.id, { type: 'GET_CONTEXT' })
pageHtml = ctx?.html || ''
selectionText = ctx?.text || ''
pageDir = ctx?.dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr'
pageLang = ctx?.lang || ''
} catch {
try {
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => ({
html: document.documentElement.outerHTML,
text: window.getSelection()?.toString().trim() || '',
dir: document.documentElement.getAttribute('dir') || '',
lang: (document.documentElement.getAttribute('lang') || '').split('-')[0],
}),
})
pageHtml = result?.html || ''
selectionText = result?.text || ''
pageDir = result?.dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr'
pageLang = result?.lang || ''
} catch {
/* ignore */
}
}
}
async function loadSettings() {
const stored = await chrome.storage.sync.get([STORAGE_KEYS.baseUrl, STORAGE_KEYS.notebookId])
if (els.baseUrl) {
els.baseUrl.value = ALLOW_INSTANCE_CONFIG
? stored[STORAGE_KEYS.baseUrl] || DEFAULT_BASE
: DEFAULT_BASE
}
await loadNotebooks(stored[STORAGE_KEYS.notebookId])
}
async function loadNotebooks(preferredId) {
try {
await ensureApiPermission()
const res = await fetch(`${apiBase()}/api/clip/notebooks`, { credentials: 'include' })
if (!res.ok) {
connected = false
updateConnBadge()
if (res.status === 401) {
throw new Error(t('errLoginRequired'))
}
throw new Error(t('errLoadNotebooks'))
}
const data = await res.json()
notebooks = data.notebooks || []
selectedNotebookId =
(preferredId && notebooks.some((n) => n.id === preferredId) ? preferredId : '') ||
notebooks[0]?.id ||
''
connected = true
updateConnBadge()
errorMessage = ''
setSettingsStatus(t('notebooksLoaded'), false)
} catch (e) {
notebooks = []
connected = false
updateConnBadge()
errorMessage = e.message
setSettingsStatus(e.message, true)
}
}
async function applyInstance() {
const url = (els.baseUrl?.value || DEFAULT_BASE).replace(/\/$/, '')
if (els.baseUrl) els.baseUrl.value = url
await chrome.storage.sync.set({ [STORAGE_KEYS.baseUrl]: url })
setSettingsStatus(t('connecting'), false)
await loadNotebooks(selectedNotebookId)
if (connected) {
setSettingsStatus(t('connectedToUrl', url), false)
}
}
function renderIdle() {
const restrictedBlock = pageRestricted
? `<div class="restricted-note">${escapeHtml(t('restrictedPage'))}</div>`
: ''
const authHint =
!connected && errorMessage
? `<div class="auth-hint">${escapeHtml(errorMessage)}</div>`
: ''
els.screen.innerHTML = `
${restrictedBlock}
${authHint}
<div>
<span class="label">${escapeHtml(t('destinationNotebook'))}</span>
${notebookSelectHtml()}
</div>
<div class="page-card">
<span class="sub">${escapeHtml(t('activePage'))}</span>
<div class="page-row">
<img src="${escapeHtml(pageFavicon)}" alt="" onerror="this.src='https://www.google.com/s2/favicons?domain=google.com&sz=32'" />
<div class="page-text">
<div class="title"${rtlAttrs(pageTitle)}>${escapeHtml(pageTitle || '—')}</div>
<div class="url">${escapeHtml(pageUrl || '—')}</div>
</div>
</div>
</div>
${selectionBlockHtml()}
${actionsBlockHtml()}
`
bindIdleHandlers()
}
function renderLoading(label) {
els.screen.innerHTML = `
<div class="center-state">
<div class="spinner-wrap">
<div class="spinner-ring"></div>
<div class="spinner"></div>
</div>
<div>
<div class="state-title">${escapeHtml(t('analyzingSource'))}</div>
<div class="state-sub">${escapeHtml(label || t('statusAnalyzing'))}</div>
<div class="state-detail">${escapeHtml(t('processingDetail'))}</div>
</div>
</div>
`
}
function renderConfirm() {
const excerpt = analyzeResult?.excerpt || ''
const tags = analyzeResult?.tags || []
const reading = formatReadingTime(analyzeResult?.readingTime)
const tagsHtml = tags.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
els.screen.innerHTML = `
<div class="confirm-panel">
<span class="label">${escapeHtml(t('previewBeforeSave'))}</span>
<label class="field">
<span>${escapeHtml(t('noteTitleLabel'))}</span>
<input id="titleInput" type="text" value="${escapeHtml(editableTitle)}" maxlength="300" />
</label>
${
reading
? `<div class="meta-row"><span class="reading-time">${escapeHtml(reading)}</span></div>`
: ''
}
${
analyzeResult?.summary
? `<p class="summary-preview"${rtlAttrs(analyzeResult.summary)}>${escapeHtml(analyzeResult.summary)}</p>`
: ''
}
${
excerpt && pendingClipType !== 'link'
? `<div class="excerpt-preview"${rtlAttrs(excerpt)}>
<span class="excerpt-label">${escapeHtml(t('excerptLabel'))}</span>
${escapeHtml(excerpt)}
</div>`
: ''
}
${tagsHtml ? `<div class="tags preview-tags">${tagsHtml}</div>` : ''}
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="saveBtn">${escapeHtml(t('saveToMomento'))}</button>
<button type="button" class="btn-link" id="cancelConfirmBtn">${escapeHtml(t('back'))}</button>
</div>
`
document.getElementById('titleInput')?.addEventListener('input', (e) => {
editableTitle = e.target.value
})
document.getElementById('saveBtn')?.addEventListener('click', () => void runSave())
document.getElementById('cancelConfirmBtn')?.addEventListener('click', async () => {
state = 'idle'
analyzeResult = null
await syncPickMode()
render()
})
}
function renderSuccess() {
const nb = notebooks.find((n) => n.id === selectedNotebookId)
const tagsHtml = successTags.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
const reading = formatReadingTime(analyzeResult?.readingTime)
els.screen.innerHTML = `
<div class="center-state" style="justify-content:flex-start;padding-top:12px">
<div class="success-icon">✓</div>
<div>
<span class="badge-ok">${escapeHtml(t('noteSaved'))}</span>
<div class="note-title"${rtlAttrs(successTitle)}>${escapeHtml(successTitle)}</div>
<div class="state-detail">${escapeHtml(t('sentToNotebook', nb?.name || ''))}</div>
${reading ? `<div class="state-detail">${escapeHtml(reading)}</div>` : ''}
</div>
${tagsHtml ? `<div class="tags">${tagsHtml}</div>` : ''}
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="viewBtn">${escapeHtml(t('viewInMomento'))} ↗</button>
<button type="button" class="btn-link" id="againBtn">${escapeHtml(t('clipAnother'))}</button>
</div>
`
document.getElementById('viewBtn')?.addEventListener('click', () => {
if (lastNoteUrl) chrome.tabs.create({ url: `${apiBase()}${lastNoteUrl}` })
})
document.getElementById('againBtn')?.addEventListener('click', async () => {
state = 'idle'
analyzeResult = null
await refreshPageContext()
await syncPickMode()
render()
})
}
function renderError() {
els.screen.innerHTML = `
<div class="center-state">
<div class="error-icon">!</div>
<div>
<div class="state-title" style="color:#ef4444">${escapeHtml(t('failure'))}</div>
<div class="state-detail">${escapeHtml(errorMessage || t('genericError'))}</div>
</div>
</div>
<div class="actions">
<button type="button" class="btn btn-danger" id="retryBtn">${escapeHtml(t('retry'))}</button>
<button type="button" class="btn-link" id="backIdleBtn">${escapeHtml(t('back'))}</button>
</div>
`
document.getElementById('retryBtn')?.addEventListener('click', () => {
if (analyzeResult) void runSave()
else void runAnalyze(pendingClipType)
})
document.getElementById('backIdleBtn')?.addEventListener('click', async () => {
state = 'idle'
errorMessage = ''
analyzeResult = null
await refreshPageContext()
await syncPickMode()
render()
})
}
function render() {
if (state === 'loading' || state === 'saving') {
return renderLoading(state === 'saving' ? t('statusSaving') : t('statusAnalyzing'))
}
if (state === 'confirm') return renderConfirm()
if (state === 'success') return renderSuccess()
if (state === 'error') return renderError()
renderIdle()
}
async function runAnalyze(type) {
pendingClipType = type
state = 'loading'
await setPickModeOnTab(false)
render()
try {
await ensureApiPermission()
await chrome.storage.sync.set({
[STORAGE_KEYS.baseUrl]: apiBase(),
[STORAGE_KEYS.notebookId]: selectedNotebookId,
})
if (type === 'selection') {
if (!selectionText) throw new Error(t('errNoSelection'))
await refreshPageContext()
}
let analyzeBody
if (type === 'link') {
analyzeBody = { url: pageUrl, title: pageTitle, mode: 'link' }
} else if (type === 'selection' && selectionText) {
analyzeBody = { url: pageUrl, title: pageTitle, mode: 'selection', selection: selectionText }
} else {
analyzeBody = { url: pageUrl, html: pageHtml, title: pageTitle, mode: 'article' }
}
const analyzeRes = await fetch(`${apiBase()}/api/clip/analyze`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(analyzeBody),
})
const analysis = await analyzeRes.json()
if (!analyzeRes.ok) throw new Error(analysis.error || t('errAnalyzeFailed'))
analyzeResult = analysis
editableTitle = analysis.title || pageTitle || pageDomain
state = 'confirm'
render()
} catch (e) {
errorMessage = e.message || t('errNetwork')
state = 'error'
render()
}
}
async function runSave() {
if (!analyzeResult) return
state = 'saving'
render()
try {
const title = (editableTitle || analyzeResult.title || pageTitle || pageDomain).trim()
const saveRes = await fetch(`${apiBase()}/api/clip/save`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: pageUrl,
title,
content: analyzeResult.content,
summary: analyzeResult.summary,
tags: analyzeResult.tags || [],
notebookId: selectedNotebookId || undefined,
}),
})
const saved = await saveRes.json()
if (!saveRes.ok) throw new Error(saved.error || t('errSaveFailed'))
successTitle = title
successTags = analyzeResult.tags || []
lastNoteId = saved.noteId
lastNoteUrl = saved.noteUrl
state = 'success'
render()
} catch (e) {
errorMessage = e.message || t('errNetwork')
state = 'error'
render()
}
}
chrome.runtime.onMessage.addListener((msg) => {
if (msg?.type === 'SELECTION_CHANGED') applySelectionFromMessage(msg)
})
chrome.tabs.onActivated.addListener(async () => {
if (state !== 'idle') return
await refreshPageContext()
await syncPickMode()
render()
})
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
if (info.status !== 'complete' || state !== 'idle') return
const tab = await getActiveTab()
if (tab?.id === tabId) {
await refreshPageContext()
await syncPickMode()
render()
}
})
els.settingsBtn?.addEventListener('click', () => {
if (!ALLOW_INSTANCE_CONFIG) return
els.settingsPanel.hidden = !els.settingsPanel.hidden
})
document.querySelectorAll('.preset-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const url = btn.getAttribute('data-url')
if (url && els.baseUrl) els.baseUrl.value = url
})
})
els.applyInstanceBtn?.addEventListener('click', () => void applyInstance())
els.openLoginBtn?.addEventListener('click', () => {
chrome.tabs.create({ url: apiBase() })
})
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
if (state === 'idle') {
await refreshPageContext()
await syncPickMode()
render()
}
} else if (document.visibilityState === 'hidden') {
// Désactiver le pick mode quand le sidepanel est fermé
await setPickModeOnTab(false)
}
})
document.addEventListener('DOMContentLoaded', async () => {
applyDocumentLocale()
applyInstanceConfigVisibility()
applyShellI18n()
await loadSettings()
try {
await ensureApiPermission()
} catch (e) {
errorMessage = e.message
connected = false
updateConnBadge()
}
await refreshPageContext()
await syncPickMode()
render()
})

View File

@@ -0,0 +1,247 @@
#!/usr/bin/env node
/**
* Build script for Chrome Web Store production package
* Usage: node scripts/build-chrome-store.mjs
*
* This script:
* 1. Sets ALLOW_INSTANCE_CONFIG = false in sidepanel.js
* 2. Removes localhost permissions from manifest.json
* 3. Copies and generates icons from public/icons/
* 4. Creates a production-ready .zip package
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import AdmZip from 'adm-zip'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const extRoot = path.resolve(__dirname, '..')
const projectRoot = path.resolve(extRoot, '..')
const publicIconsDir = path.join(projectRoot, 'public', 'icons')
const distDir = path.join(extRoot, 'dist-chrome-store')
// Colors for terminal output
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
red: '\x1b[31m'
}
function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`)
}
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
}
}
// Copy all files from source to destination, excluding specified patterns
function copyFiles(src, dest, exclude = []) {
ensureDir(dest)
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
const srcPath = path.join(src, entry.name)
const relPath = path.relative(extRoot, srcPath)
// Skip excluded files/directories
if (exclude.some(pattern => relPath.match(pattern))) {
continue
}
const destPath = path.join(dest, entry.name)
if (entry.isDirectory()) {
copyFiles(srcPath, destPath, exclude)
} else {
fs.copyFileSync(srcPath, destPath)
}
}
}
// Read and modify sidepanel.js for production
function processSidepanelJs(content) {
return content.replace(
/const ALLOW_INSTANCE_CONFIG = true/,
'const ALLOW_INSTANCE_CONFIG = false'
)
}
// Read and modify manifest.json for production
function processManifestJson(content) {
const manifest = JSON.parse(content)
// Remove localhost from host_permissions
if (manifest.host_permissions) {
manifest.host_permissions = manifest.host_permissions.filter(
perm => !perm.includes('localhost:3000') && !perm.includes('127.0.0.1:3000')
)
}
return JSON.stringify(manifest, null, 2)
}
// Generate PNG icons from SVG using sharp
async function generateIcons() {
log('📦 Generating PNG icons from SVG...', 'blue')
const sharp = (await import('sharp')).default
const sizes = [16, 48, 128]
// Source SVG files
const icon512Svg = path.join(publicIconsDir, 'icon-512.svg')
const icon192Svg = path.join(publicIconsDir, 'icon-192.svg')
if (!fs.existsSync(icon512Svg) || !fs.existsSync(icon192Svg)) {
log('⚠️ Source SVG icons not found. Copying SVG files only.', 'yellow')
// Copy SVG files as fallback
fs.copyFileSync(icon512Svg, path.join(distDir, 'icon-512.svg'))
fs.copyFileSync(icon192Svg, path.join(distDir, 'icon-192.svg'))
return
}
// Generate PNG icons
for (const size of sizes) {
const sourceSvg = size >= 128 ? icon512Svg : icon192Svg
const outputPath = path.join(distDir, `icon-${size}.png`)
await sharp(sourceSvg)
.resize(size, size)
.png()
.toFile(outputPath)
log(` ✓ Generated icon-${size}.png`, 'green')
}
// Also copy SVG files for reference
fs.copyFileSync(icon512Svg, path.join(distDir, 'icon-512.svg'))
fs.copyFileSync(icon192Svg, path.join(distDir, 'icon-192.svg'))
}
// Create ZIP package using AdmZip
async function createZipPackage() {
log('📦 Creating ZIP package...', 'blue')
const zipPath = path.join(extRoot, 'memento-web-clipper-chrome-store.zip')
try {
const zip = new AdmZip()
// Add all files from dist directory
const addFiles = (dir, base = '') => {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
const relativePath = path.join(base, entry.name)
if (entry.isDirectory()) {
addFiles(fullPath, relativePath)
} else {
zip.addLocalFile(fullPath, base)
}
}
}
addFiles(distDir)
// Write the zip file
zip.writeZip(zipPath)
// Get file size
const stats = fs.statSync(zipPath)
log(`✓ ZIP package created: ${zipPath}`, 'green')
log(` Size: ${(stats.size / 1024).toFixed(2)} KB`, 'green')
} catch (error) {
throw new Error(`Failed to create ZIP: ${error.message}`)
}
}
// Main build process
async function build() {
log('🚀 Starting Chrome Web Store build...', 'blue')
log('')
try {
// Clean dist directory
log('🧹 Cleaning dist directory...', 'blue')
if (fs.existsSync(distDir)) {
fs.rmSync(distDir, { recursive: true, force: true })
}
ensureDir(distDir)
// Copy extension files (excluding build scripts and dist)
log('📋 Copying extension files...', 'blue')
copyFiles(extRoot, distDir, [
/^dist-/,
/^scripts\//,
/\.md$/,
/^node_modules$/
])
// Process sidepanel.js
log('⚙️ Processing sidepanel.js...', 'blue')
const sidepanelPath = path.join(distDir, 'sidepanel.js')
let sidepanelContent = fs.readFileSync(sidepanelPath, 'utf8')
sidepanelContent = processSidepanelJs(sidepanelContent)
fs.writeFileSync(sidepanelPath, sidepanelContent)
log(' ✓ Set ALLOW_INSTANCE_CONFIG = false', 'green')
// Process manifest.json
log('⚙️ Processing manifest.json...', 'blue')
const manifestPath = path.join(distDir, 'manifest.json')
let manifestContent = fs.readFileSync(manifestPath, 'utf8')
manifestContent = processManifestJson(manifestContent)
// Add icons to manifest
const manifest = JSON.parse(manifestContent)
manifest.icons = {
"16": "icon-16.png",
"48": "icon-48.png",
"128": "icon-128.png"
}
manifest.action = {
...manifest.action,
"default_icon": {
"16": "icon-16.png",
"48": "icon-48.png",
"128": "icon-128.png"
}
}
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
log(' ✓ Removed localhost permissions', 'green')
log(' ✓ Added icon definitions', 'green')
// Generate icons
await generateIcons()
// Create ZIP package
await createZipPackage()
log('')
log('✅ Build completed successfully!', 'green')
log('')
log('📦 Output files:', 'blue')
log(` • Package: ${path.join(extRoot, 'memento-web-clipper-chrome-store.zip')}`, 'reset')
log(` • Dist dir: ${distDir}`, 'reset')
log('')
log('📝 Next steps:', 'blue')
log(' 1. Test the extension by loading the dist-chrome-store folder in Chrome (chrome://extensions)', 'reset')
log(' 2. Upload the .zip file to Chrome Web Store Developer Dashboard', 'reset')
log('')
} catch (error) {
log('')
log('❌ Build failed!', 'red')
log(` Error: ${error.message}`, 'red')
process.exit(1)
}
}
// Run the build
build()

View File

@@ -246,6 +246,14 @@ function bindIdleHandlers() {
document.getElementById('clipSelBtn')?.addEventListener('click', () => void runAnalyze('selection'))
document.getElementById('clipPageBtn')?.addEventListener('click', () => void runAnalyze('page'))
document.getElementById('clipLinkBtn')?.addEventListener('click', () => void runAnalyze('link'))
// Gérer l'erreur de chargement du favicon
document.querySelector('.page-favicon')?.addEventListener('error', function() {
const fallback = this.getAttribute('data-fallback')
if (fallback && this.src !== fallback) {
this.src = fallback
}
})
}
async function clearSelection() {
@@ -410,7 +418,7 @@ function renderIdle() {
<div class="page-card">
<span class="sub">${escapeHtml(t('activePage'))}</span>
<div class="page-row">
<img src="${escapeHtml(pageFavicon)}" alt="" onerror="this.src='https://www.google.com/s2/favicons?domain=google.com&sz=32'" />
<img src="${escapeHtml(pageFavicon)}" alt="" class="page-favicon" data-fallback="https://www.google.com/s2/favicons?domain=google.com&sz=32" />
<div class="page-text">
<div class="title"${rtlAttrs(pageTitle)}>${escapeHtml(pageTitle || '—')}</div>
<div class="url">${escapeHtml(pageUrl || '—')}</div>
@@ -682,10 +690,15 @@ els.openLoginBtn?.addEventListener('click', () => {
})
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible' && state === 'idle') {
await refreshPageContext()
await syncPickMode()
render()
if (document.visibilityState === 'visible') {
if (state === 'idle') {
await refreshPageContext()
await syncPickMode()
render()
}
} else if (document.visibilityState === 'hidden') {
// Désactiver le pick mode quand le sidepanel est fermé
await setPickModeOnTab(false)
}
})

View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Test Sidepanel</title>
<style>
body { font-family: sans-serif; padding: 20px; }
.error { color: red; }
.success { color: green; }
pre { background: #f5f5f5; padding: 10px; overflow-x: auto; }
</style>
</head>
<body>
<h1>Test Extension Momento</h1>
<div id="results"></div>
<script>
const results = document.getElementById('results');
function log(message, type = 'info') {
const div = document.createElement('div');
div.className = type;
div.textContent = message;
results.appendChild(div);
}
function testFile(filename) {
return fetch(filename)
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
})
.then(content => {
log(`${filename} chargé (${content.length} bytes)`, 'success');
return { filename, content };
})
.catch(error => {
log(`${filename}: ${error.message}`, 'error');
return { filename, error };
});
}
async function testSidepanelJS() {
const script = document.createElement('script');
script.src = 'sidepanel.js';
script.onerror = () => log('✗ sidepanel.js: Erreur de chargement', 'error');
script.onload = () => {
log('✓ sidepanel.js chargé', 'success');
// Check for CSP violations
if (typeof chrome !== 'undefined') {
log('✓ API chrome disponible', 'success');
} else {
log('✗ API chrome non disponible (normal hors extension)', 'error');
}
};
document.head.appendChild(script);
}
async function runTests() {
log('🧪 Début des tests...', 'info');
// Test file loading
await Promise.all([
testFile('sidepanel.html'),
testFile('sidepanel.js'),
testFile('content.js'),
testFile('background.js'),
testFile('manifest.json')
]);
// Test sidepanel.js execution
await testSidepanelJS();
}
runTests();
</script>
</body>
</html>

View File

@@ -5,6 +5,7 @@ import {
CONSENT_CHANGE_EVENT,
getConsent,
hasConsentChoice,
loadConsentWithDBSync,
type ConsentRecord,
} from '@/lib/consent/cookie-consent'
@@ -12,8 +13,10 @@ export function useCookieConsent() {
const [consent, setConsentState] = useState<ConsentRecord | null>(null)
const [ready, setReady] = useState(false)
const refresh = useCallback(() => {
setConsentState(getConsent())
const refresh = useCallback(async () => {
// Try local first, then DB sync for authenticated users
const loaded = await loadConsentWithDBSync()
setConsentState(loaded)
setReady(true)
}, [])

View File

@@ -19,7 +19,9 @@ export type ProviderType =
| 'zai'
| 'lmstudio'
| 'anthropic'
| 'anthropic_custom';
| 'anthropic_custom'
| 'custom_openai'
| 'custom_anthropic';
// --- Provider defaults ---
const PROVIDER_DEFAULTS: Record<string, { baseUrl: string; model: string; embeddingModel: string }> = {
@@ -204,6 +206,7 @@ export function getProviderInstance(providerType: ProviderType, config: Record<s
case 'openai':
return createOpenAIProvider(config, modelName, embeddingModelName);
case 'custom':
case 'custom_openai':
return createCustomOpenAIProvider(config, modelName, embeddingModelName);
case 'deepseek':
return createDeepSeekProvider(config, modelName, embeddingModelName);
@@ -218,6 +221,7 @@ export function getProviderInstance(providerType: ProviderType, config: Record<s
case 'anthropic':
return createAnthropicProvider(config, modelName);
case 'anthropic_custom':
case 'custom_anthropic':
return createAnthropicCustomProvider(config, modelName);
case 'google':
return createGoogleProvider(config, modelName, embeddingModelName);
@@ -246,7 +250,11 @@ function getProviderConfigKeys(providerType: string): { apiKeyConfigKey: string;
case 'google': return { apiKeyConfigKey: 'GOOGLE_GENERATIVE_AI_API_KEY', baseUrlConfigKey: '' };
case 'minimax': return { apiKeyConfigKey: 'MINIMAX_API_KEY', baseUrlConfigKey: '' };
case 'glm': return { apiKeyConfigKey: 'GLM_API_KEY', baseUrlConfigKey: '' };
case 'custom': return { apiKeyConfigKey: 'CUSTOM_OPENAI_API_KEY', baseUrlConfigKey: 'CUSTOM_OPENAI_BASE_URL' };
case 'custom':
case 'custom_openai':
return { apiKeyConfigKey: 'CUSTOM_OPENAI_API_KEY', baseUrlConfigKey: 'CUSTOM_OPENAI_BASE_URL' };
case 'custom_anthropic':
return { apiKeyConfigKey: 'ANTHROPIC_CUSTOM_API_KEY', baseUrlConfigKey: 'ANTHROPIC_CUSTOM_BASE_URL' };
default: return { apiKeyConfigKey: '', baseUrlConfigKey: 'OLLAMA_BASE_URL' };
}
}

View File

@@ -7,7 +7,7 @@ const PROVIDER_URLS: Record<string, string> = {
openrouter: 'https://openrouter.ai/api/v1',
mistral: 'https://api.mistral.ai/v1',
zai: 'https://api.zukijourney.com/v1',
minimax: 'https://api.minimax.chat/v1',
minimax: 'https://api.minimax.io/v1',
glm: 'https://open.bigmodel.ai/api/paas/v4',
};
@@ -17,13 +17,21 @@ export const PROVIDER_MODEL_SUGGESTIONS: Record<string, string[]> = {
anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest', 'claude-3-opus-latest'],
google: ['gemini-1.5-flash', 'gemini-1.5-pro', 'gemini-2.0-flash-exp'],
deepseek: ['deepseek-chat', 'deepseek-coder'],
minimax: ['abab6.5-chat', 'abab6.5s-chat'],
minimax: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2-her'],
mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest'],
glm: ['glm-4', 'glm-4-flash'],
openrouter: ['openai/gpt-4o-mini', 'anthropic/claude-3.5-sonnet', 'deepseek/deepseek-chat'],
custom: [],
};
/**
* Result of fetching models - includes whether they came from the real API or fallbacks
*/
export interface FetchModelsResult {
models: string[]
fromApi: boolean // true = fetched from provider API, false = fallback suggestions
}
/**
* Dynamically queries the provider's /models endpoint using the user's API Key
* to fetch their actual available models list instead of relying on hardcoded choices.
@@ -32,21 +40,32 @@ export async function fetchLiveModelsForProvider(
provider: AiGatewayProvider,
apiKey: string,
customBaseUrl?: string
): Promise<string[]> {
): Promise<FetchModelsResult> {
try {
// Anthropic and Google do not expose a public list via a simple key GET /models (or need specific formats)
// We fall back to the popular defaults for those.
if (provider === 'anthropic' || provider === 'anthropic_custom' || provider === 'google') {
const standardProvider = provider === 'anthropic_custom' ? 'anthropic' : provider;
return PROVIDER_MODEL_SUGGESTIONS[standardProvider] ?? [];
if (
provider === 'anthropic' ||
provider === 'anthropic_custom' ||
provider === 'custom_anthropic' ||
provider === 'google' ||
provider === 'minimax'
) {
const standardProvider =
provider === 'anthropic_custom' || provider === 'custom_anthropic'
? 'anthropic'
: provider;
const models = PROVIDER_MODEL_SUGGESTIONS[standardProvider] ?? [];
return { models, fromApi: false };
}
const baseUrl = provider === 'custom'
const baseUrl = (provider === 'custom' || provider === 'custom_openai')
? customBaseUrl?.replace(/\/$/, '')
: PROVIDER_URLS[provider];
if (!baseUrl) {
return PROVIDER_MODEL_SUGGESTIONS[provider] ?? [];
const models = PROVIDER_MODEL_SUGGESTIONS[provider] ?? [];
return { models, fromApi: false };
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
@@ -63,7 +82,9 @@ export async function fetchLiveModelsForProvider(
});
if (!response.ok) {
return PROVIDER_MODEL_SUGGESTIONS[provider] ?? [];
console.warn(`[fetchLiveModelsForProvider] API returned ${response.status} for ${provider}`);
const models = PROVIDER_MODEL_SUGGESTIONS[provider] ?? [];
return { models, fromApi: false };
}
const data = await response.json();
@@ -73,11 +94,24 @@ export async function fetchLiveModelsForProvider(
.sort();
if (fetched.length > 0) {
return fetched;
console.log(`[fetchLiveModelsForProvider] Got ${fetched.length} models from ${provider} API:`, fetched);
return { models: fetched, fromApi: true };
} else {
console.warn(`[fetchLiveModelsForProvider] API returned empty data array for ${provider}`);
}
} catch (err) {
console.warn(`[fetchLiveModelsForProvider] Failed to fetch live models for ${provider}, using fallbacks:`, err);
console.warn(`[fetchLiveModelsForProvider] Failed to fetch live models for ${provider}:`, err);
}
return PROVIDER_MODEL_SUGGESTIONS[provider] ?? [];
const fallbackProvider =
provider === 'custom_openai'
? 'openai'
: provider === 'custom_anthropic'
? 'anthropic'
: provider === 'anthropic_custom'
? 'anthropic'
: provider;
const models = PROVIDER_MODEL_SUGGESTIONS[fallbackProvider] ?? [];
return { models, fromApi: false };
}

View File

@@ -1,8 +1,9 @@
import {
getProviderInstance,
getProviderConfigKeys,
type ProviderType,
} from '@/lib/ai/factory';
import { applyByokToConfig } from '@/lib/byok';
import { getAnyActiveByokForUser, hasAnyActiveByok, ByokUnavailableError } from '@/lib/byok';
import {
resolveAiRoute,
type AiFeatureLane,
@@ -17,78 +18,87 @@ export interface ProviderForUserResult {
route: ResolvedAiRoute;
}
/** Resolve the best AI provider for a user's lane — BYOK first, then admin config. */
async function resolveProviderForLane(
lane: AiFeatureLane,
config: Record<string, string>,
billingUserId?: string,
): Promise<ProviderForUserResult> {
const cfg = { ...config };
const route = resolveAiRoute(lane, cfg);
let usedByok = false;
let byokModel: string | null = null;
const adminRoute = resolveAiRoute(lane, cfg);
if (billingUserId) {
const overlay = await applyByokToConfig(
billingUserId,
route.providerType,
cfg,
);
Object.assign(cfg, overlay.config);
usedByok = overlay.usedByok;
byokModel = overlay.model;
// Prefer admin's provider, fallback to any active BYOK key
const byok = await getAnyActiveByokForUser(billingUserId, adminRoute.providerType, lane);
if (byok) {
const { apiKeyConfigKey, baseUrlConfigKey } = getProviderConfigKeys(byok.provider);
const byokCfg: Record<string, string> = { ...cfg };
if (apiKeyConfigKey) byokCfg[apiKeyConfigKey] = byok.plaintext;
if (baseUrlConfigKey && byok.baseUrl) byokCfg[baseUrlConfigKey] = byok.baseUrl;
const resolvedModel = (byok.model && byok.model.trim()) ? byok.model : adminRoute.modelName;
console.log(`[byok] Using BYOK key: provider=${byok.provider} model=${resolvedModel} user=${billingUserId}`);
const provider = getProviderInstance(
byok.provider as ProviderType,
byokCfg,
resolvedModel,
adminRoute.embeddingModelName,
adminRoute.ollamaBaseUrl,
);
return {
provider,
usedByok: true,
route: {
...adminRoute,
providerType: byok.provider as ResolvedAiRoute['providerType'],
modelName: resolvedModel,
},
};
}
// No key resolved — if user HAS active BYOK rows, decryption failed → throw, no silent fallback
const hasByok = await hasAnyActiveByok(billingUserId);
if (hasByok) {
throw new ByokUnavailableError();
}
}
const resolvedModel = byokModel && byokModel.trim() !== '' ? byokModel : route.modelName;
// No BYOK configured → use admin config
const provider = getProviderInstance(
route.providerType as ProviderType,
adminRoute.providerType as ProviderType,
cfg,
resolvedModel,
route.embeddingModelName,
route.ollamaBaseUrl,
adminRoute.modelName,
adminRoute.embeddingModelName,
adminRoute.ollamaBaseUrl,
);
const updatedRoute = { ...route, modelName: resolvedModel };
return { provider, usedByok, route: updatedRoute };
return { provider, usedByok: false, route: adminRoute };
}
async function getChatProviderForBillingUser(
config: Record<string, string>,
billingUserId?: string,
): Promise<ProviderForUserResult> {
return resolveProviderForLane('chat', config, billingUserId);
}
async function getTagsProviderForBillingUser(
config: Record<string, string>,
billingUserId?: string,
): Promise<ProviderForUserResult> {
return resolveProviderForLane('tags', config, billingUserId);
}
async function getEmbeddingsProviderForBillingUser(
config: Record<string, string>,
billingUserId?: string,
): Promise<ProviderForUserResult> {
return resolveProviderForLane('embedding', config, billingUserId);
}
/** Run a lane with BYOK overlay; skips system fallback when user key is active. */
/** Check if a lane will use BYOK for a given user. */
export async function willUseByokForLane(
lane: AiFeatureLane,
config: Record<string, string>,
billingUserId?: string,
): Promise<{ providerType: string; usedByok: boolean }> {
if (!billingUserId) {
const route = resolveAiRoute(lane, config)
return { providerType: route.providerType, usedByok: false }
const route = resolveAiRoute(lane, config);
return { providerType: route.providerType, usedByok: false };
}
const route = resolveAiRoute(lane, config)
const overlay = await applyByokToConfig(billingUserId, route.providerType, config)
return { providerType: route.providerType, usedByok: overlay.usedByok }
const route = resolveAiRoute(lane, config);
const byok = await getAnyActiveByokForUser(billingUserId, route.providerType);
return { providerType: byok?.provider ?? route.providerType, usedByok: !!byok };
}
/**
* Run an AI lane with BYOK priority.
* - If user has active BYOK → uses it, no quota counted.
* - If user has BYOK configured but it can't be loaded → throws ByokUnavailableError (no fallback).
* - If user has no BYOK → uses admin config with system fallback.
*/
export async function runLaneWithBillingUser<T>(
lane: AiFeatureLane,
config: Record<string, string>,
@@ -96,12 +106,8 @@ export async function runLaneWithBillingUser<T>(
run: (provider: AIProvider) => Promise<T>,
): Promise<{ result: T; usedByok: boolean }> {
if (billingUserId) {
const resolved =
lane === 'chat'
? await getChatProviderForBillingUser(config, billingUserId)
: lane === 'tags'
? await getTagsProviderForBillingUser(config, billingUserId)
: await getEmbeddingsProviderForBillingUser(config, billingUserId);
// May throw ByokUnavailableError — let it propagate, callers should handle it
const resolved = await resolveProviderForLane(lane, config, billingUserId);
if (resolved.usedByok) {
const result = await run(resolved.provider);
@@ -109,6 +115,7 @@ export async function runLaneWithBillingUser<T>(
}
}
// No BYOK configured → use admin config with system fallback
const result = await withAiProviderFallback(lane, config, run);
return { result, usedByok: false };
}

View File

@@ -23,19 +23,36 @@ export class AnthropicProvider implements AIProvider {
async generateTags(content: string): Promise<TagSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1'),
})),
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
});
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1'),
})),
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
});
return object.tags;
return object.tags;
} catch (err) {
console.warn('Anthropic generateObject tags failed, falling back to generateText:', err);
const { text } = await aiGenerateText({
model: this.model,
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content.substring(0, 1500)}"
Return ONLY a JSON array of tag objects, like: [{"tag":"example","confidence":0.9}]`,
});
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim();
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed) ? parsed : (parsed.tags || parsed.suggestions || []);
return arr.map((t: any) => ({
tag: t.tag || t.label || t.name || '',
confidence: t.confidence || t.score || 0.7,
}));
}
} catch (e) {
console.error('Error generating tags (Anthropic):', e);
return [];
@@ -50,18 +67,33 @@ export class AnthropicProvider implements AIProvider {
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1'),
})),
}),
prompt,
});
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1'),
})),
}),
prompt,
});
return object.titles;
return object.titles;
} catch (err) {
console.warn('Anthropic generateObject titles failed, falling back to generateText:', err);
const { text } = await aiGenerateText({
model: this.model,
prompt: prompt + '\n\nRespond ONLY as a JSON array of title suggestions: [{"title": "Suggested title", "confidence": 0.9}]',
});
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim();
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || []);
return arr.map((t: any) => ({
title: typeof t === 'string' ? t : t.title || t.name || '',
confidence: typeof t === 'number' ? t : (t.confidence || t.score || 0.8),
}));
}
} catch (e) {
console.error('Error generating titles (Anthropic):', e);
return [];

View File

@@ -1,7 +1,7 @@
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject, generateText as aiGenerateText, embed, stepCountIs } from 'ai';
import { z } from 'zod';
import { generateText as aiGenerateText, embed, stepCountIs } from 'ai';
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types';
import { cleanAIJsonResponse, cleanAITextResponse } from '../utils/clean-ai-response';
export class CustomOpenAIProvider implements AIProvider {
private model: any;
@@ -49,19 +49,20 @@ export class CustomOpenAIProvider implements AIProvider {
async generateTags(content: string): Promise<TagSuggestion[]> {
try {
const { object } = await generateObject({
const { text } = await aiGenerateText({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
Note content: "${content.substring(0, 1500)}"
Return ONLY a JSON array of tag objects, like: [{"tag":"example","confidence":0.9}]`,
});
return object.tags;
const cleaned = cleanAIJsonResponse(text)
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed) ? parsed : (parsed.tags || parsed.suggestions || []);
return arr.map((t: any) => ({
tag: t.tag || t.label || t.name || '',
confidence: t.confidence || t.score || 0.7,
}));
} catch (e) {
console.error('Error generating tags (Custom OpenAI):', e);
return [];
@@ -83,15 +84,15 @@ export class CustomOpenAIProvider implements AIProvider {
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
try {
// Use generateText instead of generateObject — DeepSeek doesn't support
// Use generateText instead of generateObject — DeepSeek/MiniMax don't support
// response_format: json_schema via the OpenAI compat layer
const { text } = await aiGenerateText({
model: this.model,
prompt: prompt,
})
// Parse the JSON array from the text response — strip markdown code fences if present
const parsed = JSON.parse(text.replace(/^```json\n?/,'').replace(/\n?```$/,'').trim())
const cleaned = cleanAIJsonResponse(text)
const parsed = JSON.parse(cleaned)
const titles = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || [])
return titles.map((t: any) => ({
title: typeof t === 'string' ? t : t.title || t.name || '',
@@ -103,6 +104,8 @@ export class CustomOpenAIProvider implements AIProvider {
}
}
async generateText(prompt: string): Promise<string> {
try {
const { text } = await aiGenerateText({
@@ -110,7 +113,7 @@ export class CustomOpenAIProvider implements AIProvider {
prompt: prompt,
});
return text.trim();
return cleanAITextResponse(text).trim();
} catch (e) {
console.error('Error generating text (Custom OpenAI):', e);
throw e;

View File

@@ -1,7 +1,7 @@
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject, generateText as aiGenerateText, embed, stepCountIs } from 'ai';
import { z } from 'zod';
import { generateText as aiGenerateText, embed, stepCountIs } from 'ai';
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types';
import { cleanAIJsonResponse, cleanAITextResponse } from '../utils/clean-ai-response';
export class DeepSeekProvider implements AIProvider {
private model: any;
@@ -41,7 +41,7 @@ Return ONLY a JSON array like: [{"tag":"example","confidence":0.9}]
Note content: "${content.substring(0, 1500)}"`,
});
const clean = text.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim();
const clean = cleanAIJsonResponse(text)
const parsed = JSON.parse(clean);
const arr = Array.isArray(parsed) ? parsed : (parsed.tags || []);
return arr.map((t: any) => ({
@@ -69,18 +69,18 @@ Note content: "${content.substring(0, 1500)}"`,
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
try {
const { object } = await generateObject({
// Utiliser generateText + parse manuel (generateObject échoue avec les modèles reasoning)
const { text } = await aiGenerateText({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: prompt,
});
return object.titles;
const cleaned = cleanAIJsonResponse(text)
const parsed = JSON.parse(cleaned)
const titles = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || [])
return titles.map((t: any) => ({
title: typeof t === 'string' ? t : t.title || t.name || '',
confidence: typeof t === 'number' ? t : (t.confidence || t.score || 0.5),
}))
} catch (e) {
console.error('Error generating titles (DeepSeek):', e);
return [];
@@ -94,7 +94,7 @@ Note content: "${content.substring(0, 1500)}"`,
prompt: prompt,
});
return text.trim();
return cleanAITextResponse(text).trim();
} catch (e) {
console.error('Error generating text (DeepSeek):', e);
throw e;

View File

@@ -18,19 +18,36 @@ export class GoogleProvider implements AIProvider {
async generateTags(content: string): Promise<TagSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
});
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
});
return object.tags;
return object.tags;
} catch (err) {
console.warn('Google generateObject tags failed, falling back to generateText:', err);
const { text } = await aiGenerateText({
model: this.model,
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content.substring(0, 1500)}"
Return ONLY a JSON array of tag objects, like: [{"tag":"example","confidence":0.9}]`,
});
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim();
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed) ? parsed : (parsed.tags || parsed.suggestions || []);
return arr.map((t: any) => ({
tag: t.tag || t.label || t.name || '',
confidence: t.confidence || t.score || 0.7,
}));
}
} catch (e) {
console.error('Error generating tags (Google):', e);
return [];
@@ -52,18 +69,33 @@ export class GoogleProvider implements AIProvider {
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: prompt,
});
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: prompt,
});
return object.titles;
return object.titles;
} catch (err) {
console.warn('Google generateObject titles failed, falling back to generateText:', err);
const { text } = await aiGenerateText({
model: this.model,
prompt: prompt + '\n\nRespond ONLY as a JSON array of title suggestions: [{"title": "Suggested title", "confidence": 0.9}]',
});
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim();
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || []);
return arr.map((t: any) => ({
title: typeof t === 'string' ? t : t.title || t.name || '',
confidence: typeof t === 'number' ? t : (t.confidence || t.score || 0.8),
}));
}
} catch (e) {
console.error('Error generating titles (Google):', e);
return [];

View File

@@ -78,7 +78,7 @@ Note content: "${content}"`;
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
const data = await response.json();
const text = data.response;
const text = (data.response || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
const jsonMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/);
if (jsonMatch) {
@@ -133,7 +133,7 @@ Note content: "${content}"`;
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
const data = await response.json();
const text = data.response;
const text = (data.response || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
const jsonMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/);
if (jsonMatch) {
@@ -162,7 +162,7 @@ Note content: "${content}"`;
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
const data = await response.json();
return data.response.trim();
return (data.response || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
} catch (e) {
console.error('Error generating text (Ollama):', e);
throw e;

View File

@@ -24,19 +24,36 @@ export class OpenAIProvider implements AIProvider {
async generateTags(content: string): Promise<TagSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
});
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
});
return object.tags;
return object.tags;
} catch (err) {
console.warn('OpenAI generateObject tags failed, falling back to generateText:', err);
const { text } = await aiGenerateText({
model: this.model,
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content.substring(0, 1500)}"
Return ONLY a JSON array of tag objects, like: [{"tag":"example","confidence":0.9}]`,
});
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim();
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed) ? parsed : (parsed.tags || parsed.suggestions || []);
return arr.map((t: any) => ({
tag: t.tag || t.label || t.name || '',
confidence: t.confidence || t.score || 0.7,
}));
}
} catch (e) {
console.error('Error generating tags (OpenAI):', e);
return [];
@@ -58,18 +75,33 @@ export class OpenAIProvider implements AIProvider {
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: prompt,
});
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: prompt,
});
return object.titles;
return object.titles;
} catch (err) {
console.warn('OpenAI generateObject titles failed, falling back to generateText:', err);
const { text } = await aiGenerateText({
model: this.model,
prompt: prompt + '\n\nRespond ONLY as a JSON array of title suggestions: [{"title": "Suggested title", "confidence": 0.9}]',
});
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim();
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || []);
return arr.map((t: any) => ({
title: typeof t === 'string' ? t : t.title || t.name || '',
confidence: typeof t === 'number' ? t : (t.confidence || t.score || 0.8),
}));
}
} catch (e) {
console.error('Error generating titles (OpenAI):', e);
return [];

View File

@@ -24,6 +24,8 @@ export type AiGatewayProvider =
| 'lmstudio'
| 'anthropic'
| 'anthropic_custom'
| 'custom_openai'
| 'custom_anthropic'
export interface ResolvedAiRoute {
lane: AiFeatureLane
@@ -40,6 +42,7 @@ export const VALID_PROVIDERS = new Set<string>([
'ollama', 'openai', 'google', 'minimax', 'glm', 'custom',
'deepseek', 'openrouter', 'mistral', 'zai', 'lmstudio',
'anthropic', 'anthropic_custom',
'custom_openai', 'custom_anthropic',
])
const PROVIDER_MODEL_DEFAULTS: Record<string, { model: string; embeddingModel: string }> = {
@@ -56,6 +59,8 @@ const PROVIDER_MODEL_DEFAULTS: Record<string, { model: string; embeddingModel: s
glm: { model: 'glm-4', embeddingModel: 'embedding-2' },
lmstudio: { model: '', embeddingModel: '' },
custom: { model: '', embeddingModel: '' },
custom_openai: { model: 'gpt-4o-mini', embeddingModel: 'text-embedding-3-small' },
custom_anthropic: { model: 'claude-sonnet-4-6-20250514', embeddingModel: '' },
}
function pick(config: Record<string, string>, key: string): string | undefined {

View File

@@ -0,0 +1,217 @@
/**
* ChunkIndexingService — Indexation incrémentale d'une note en fragments.
*
* Inspiré d'AppFlowy flowy-ai/src/embeddings/scheduler.rs.
*
* Fonctionnement :
* 1. Récupère les fragmentIds existants en DB pour la note
* 2. Chunk le contenu actuel (via chunkNoteContent)
* 3. Compare les hash :
* - Inchangés (hash identique) → skip
* - Nouveaux (hash absent) → embed + insert
* - Supprimés (hash en DB mais plus dans le contenu) → delete
* 4. Embed les nouveaux fragments via une queue à concurrence limitée
* 5. Retry avec backoff exponentiel en cas d'erreur API
*
* Garanties :
* - Pas de re-embed des fragments inchangés (économie API)
* - Pas de fragments orphelins (stale supprimés)
* - Pas de race condition (verrou par noteId)
*/
import PQueue from 'p-queue'
import { prisma } from '@/lib/prisma'
import { embeddingService } from './embedding.service'
import {
chunkNoteContent,
type NoteChunk,
} from '@/lib/text/note-chunking'
import {
prepareNoteTextForEmbedding,
} from '@/lib/text/plain-text'
import { Prisma } from '@prisma/client'
const EMBEDDING_CONCURRENCY = 4
const MAX_RETRIES = 3
const RETRY_BASE_DELAY_MS = 1000
const embeddingQueue = new PQueue({ concurrency: EMBEDDING_CONCURRENCY })
const noteLocks = new Map<string, Promise<void>>()
export interface IndexResult {
noteId: string
totalFragments: number
newFragments: number
skipped: number
deleted: number
durationMs: number
}
export class ChunkIndexingService {
/**
* Indexe une note en fragments. Ne re-embed que les fragments modifiés.
*/
async indexNote(
noteId: string,
title: string | null | undefined,
content: string,
): Promise<IndexResult> {
while (noteLocks.has(noteId)) {
await noteLocks.get(noteId)
}
const task = this.doIndexNote(noteId, title, content)
noteLocks.set(noteId, task.then(() => {}, () => {}))
try {
return await task
} finally {
noteLocks.delete(noteId)
}
}
private async doIndexNote(
noteId: string,
title: string | null | undefined,
content: string,
): Promise<IndexResult> {
const start = Date.now()
const plain = prepareNoteTextForEmbedding(title, content)
const newChunks = chunkNoteContent(noteId, plain)
const newFragmentIds = new Set(newChunks.map((c) => c.fragmentId))
const existing = await prisma.noteEmbeddingChunk.findMany({
where: { noteId },
select: { fragmentId: true },
})
const existingIds = new Set<string>(existing.map((e) => e.fragmentId))
const toDelete = [...existingIds].filter((id) => !newFragmentIds.has(id))
const toEmbed = newChunks.filter((c) => !existingIds.has(c.fragmentId))
const skipped = newChunks.filter((c) => existingIds.has(c.fragmentId))
if (toDelete.length > 0) {
await prisma.noteEmbeddingChunk.deleteMany({
where: {
noteId,
fragmentId: { in: toDelete },
},
})
}
const embeddedChunks = await this.embedChunks(toEmbed)
if (embeddedChunks.length > 0) {
await this.upsertChunks(noteId, embeddedChunks)
}
return {
noteId,
totalFragments: newChunks.length,
newFragments: embeddedChunks.length,
skipped: skipped.length,
deleted: toDelete.length,
durationMs: Date.now() - start,
}
}
/**
* Embed une liste de fragments avec queue à concurrence limitée + retry.
*/
private async embedChunks(chunks: NoteChunk[]): Promise<
Array<NoteChunk & { embedding: number[] }>
> {
if (chunks.length === 0) return []
const results = await Promise.all(
chunks.map((chunk) =>
embeddingQueue.add(() => this.embedWithRetry(chunk)),
),
)
return results.filter(
(r): r is NoteChunk & { embedding: number[] } => r !== null,
)
}
private async embedWithRetry(
chunk: NoteChunk,
): Promise<(NoteChunk & { embedding: number[] }) | null> {
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const embedding = await embeddingService.embedText(chunk.content)
return { ...chunk, embedding }
} catch (err: any) {
lastError = err
if (attempt < MAX_RETRIES - 1) {
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt)
console.warn(
`[ChunkIndexing] Retry ${attempt + 1}/${MAX_RETRIES} for fragment ${chunk.fragmentId} after ${delay}ms: ${err.message}`,
)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
console.error(
`[ChunkIndexing] Failed to embed fragment ${chunk.fragmentId} after ${MAX_RETRIES} attempts: ${lastError?.message}`,
)
return null
}
/**
* Upsert transactionnel des fragments embeddés.
* Utilise $executeRawUnsafe pour la colonne vector(1536).
*/
private async upsertChunks(
noteId: string,
chunks: Array<NoteChunk & { embedding: number[] }>,
): Promise<void> {
for (const chunk of chunks) {
const vecStr = `[${chunk.embedding.join(',')}]`
await prisma.$executeRaw`
INSERT INTO "NoteEmbeddingChunk" (
"id", "noteId", "fragmentId", "chunkIndex",
"content", "charCount", "embedding", "embeddingModel",
"createdAt", "updatedAt"
)
VALUES (
gen_random_uuid(), ${noteId}, ${chunk.fragmentId}, ${chunk.chunkIndex},
${chunk.content}, ${chunk.charCount},
${vecStr}::vector, 'text-embedding-3-small',
now(), now()
)
ON CONFLICT ("noteId", "fragmentId")
DO UPDATE SET
"content" = EXCLUDED."content",
"charCount" = EXCLUDED."charCount",
"embedding" = EXCLUDED."embedding",
"chunkIndex" = EXCLUDED."chunkIndex",
"updatedAt" = now()
`
}
}
/**
* Supprime tous les fragments d'une note.
*/
async deleteNoteChunks(noteId: string): Promise<void> {
await prisma.noteEmbeddingChunk.deleteMany({ where: { noteId } })
}
/**
* Vérifie si une note a déjà des fragments indexés.
*/
async hasChunks(noteId: string): Promise<boolean> {
const count = await prisma.noteEmbeddingChunk.count({
where: { noteId },
take: 1,
})
return count > 0
}
}
export const chunkIndexingService = new ChunkIndexingService()

View File

@@ -36,6 +36,15 @@ export class EmbeddingService {
)
}
/** Embedde un texte simple et retourne le vecteur brut (pour chunks, requêtes, etc.). */
async embedText(text: string): Promise<number[]> {
if (!text || text.trim().length === 0) {
throw new Error('Cannot generate embedding for empty text')
}
const plain = prepareTextForEmbedding(text)
return this.embedPlainText(plain)
}
/**
* Embedding d'une note complète : titre + corps, multi-chunks si l'article dépasse la fenêtre API.
* Ex. 17 679 caractères → 3 chunks → vecteur moyenné (aucune perte de contenu).

View File

@@ -74,7 +74,8 @@ Respond with titles in ${contentLanguage} (same language as the note).`
})
// Parse JSON response
const response = JSON.parse(text)
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim()
const response = JSON.parse(cleaned)
if (!response.suggestions || !Array.isArray(response.suggestions)) {
throw new Error('Invalid response format')

View File

@@ -426,6 +426,7 @@ function separateArchitectureZones(
layout: Map<string, NodeLayoutBox>,
nodes: SimplifiedNode[],
zones: DiagramZone[],
rankdir: 'LR' | 'TB',
): void {
const zoneGroups = zones
.map((zone) => {
@@ -456,21 +457,40 @@ function separateArchitectureZones(
if (zoneGroups.length <= 1) return
zoneGroups.sort((a, b) => a.minY - b.minY)
const zoneGapY = 90
let cursorY = zoneGroups[0].minY
if (rankdir === 'LR') {
zoneGroups.sort((a, b) => a.minX - b.minX)
const zoneGapX = 140
let cursorX = zoneGroups[0].minX
for (const group of zoneGroups) {
const height = group.maxY - group.minY
const dy = cursorY - group.minY
if (dy !== 0) {
for (const nodeId of group.nodeIds) {
const box = layout.get(nodeId)
if (!box) continue
layout.set(nodeId, { ...box, y: box.y + dy })
for (const group of zoneGroups) {
const width = group.maxX - group.minX
const dx = cursorX - group.minX
if (dx !== 0) {
for (const nodeId of group.nodeIds) {
const box = layout.get(nodeId)
if (!box) continue
layout.set(nodeId, { ...box, x: box.x + dx })
}
}
cursorX += width + zoneGapX
}
} else {
zoneGroups.sort((a, b) => a.minY - b.minY)
const zoneGapY = 90
let cursorY = zoneGroups[0].minY
for (const group of zoneGroups) {
const height = group.maxY - group.minY
const dy = cursorY - group.minY
if (dy !== 0) {
for (const nodeId of group.nodeIds) {
const box = layout.get(nodeId)
if (!box) continue
layout.set(nodeId, { ...box, y: box.y + dy })
}
}
cursorY += height + zoneGapY
}
cursorY += height + zoneGapY
}
}
@@ -806,7 +826,7 @@ async function buildElementsFromSimplified(
const { layout, quality, rankdir, engine } = await computeNodeLayout(nodes, edges, diagramType)
if (diagramType === 'architecture-cloud' && zones.length > 1) {
separateArchitectureZones(layout, nodes, zones)
separateArchitectureZones(layout, nodes, zones, rankdir)
}
const renderSpecs = new Map<string, NodeRenderSpec>()

View File

@@ -0,0 +1,43 @@
/**
* Utilitaires de nettoyage des réponses IA
* Certains modèles (DeepSeek-R1, MiniMax, etc.) génèrent des blocs <think>
* qui corrompent le parsing JSON si non supprimés.
*/
/**
* Supprime les blocs <think>...</think> d'une réponse IA.
* Gère aussi les blocs non fermés (balise ouvrante sans fermeture).
* Supprime ensuite les fences Markdown (```json ... ```) éventuelles.
*/
export function cleanAIJsonResponse(text: string): string {
// 1. Supprimer les blocs <think>...</think> fermés (greedy-safe)
let cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').trim()
// 2. Si un <think> reste (non fermé), chercher le premier '[' ou '{'
if (/<think>/i.test(cleaned)) {
const jsonStart = cleaned.search(/[\[{]/)
if (jsonStart !== -1) {
cleaned = cleaned.slice(jsonStart)
} else {
// Aucun JSON trouvé après le think → vider pour provoquer une erreur propre
cleaned = ''
}
}
// 3. Supprimer les fences Markdown
cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim()
return cleaned
}
/**
* Supprime les blocs <think> d'une réponse texte libre (non-JSON).
*/
export function cleanAITextResponse(text: string): string {
let cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').trim()
if (/<think>/i.test(cleaned)) {
// Supprimer tout ce qui précède la fermeture du think ou jusqu'à la fin
cleaned = cleaned.replace(/<think>[\s\S]*/i, '').trim()
}
return cleaned
}

View File

@@ -31,7 +31,7 @@ export async function cancelSubscription(userId: string): Promise<CancelSubscrip
// Direct call to Stripe API with period end cancellation set to true
const updatedSub = await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
cancel_at_period_end: true,
});
}) as any;
// Local database synchronization
await prisma.subscription.update({

View File

@@ -1,8 +1,81 @@
import type { SubscriptionTier } from '@/lib/entitlements';
import { stripe } from '@/lib/stripe';
export type BillingTier = 'PRO' | 'BUSINESS';
export type BillingInterval = 'month' | 'year';
export interface DynamicPrice {
display: string;
amount: number;
currency: string;
}
export const DEFAULT_PRICES: Record<BillingTier, Record<BillingInterval, DynamicPrice>> = {
PRO: {
month: { display: '9,90 €', amount: 9.90, currency: 'EUR' },
year: { display: '99,00 €', amount: 99.00, currency: 'EUR' },
},
BUSINESS: {
month: { display: '29,90 €', amount: 29.90, currency: 'EUR' },
year: { display: '299,00 €', amount: 299.00, currency: 'EUR' },
},
};
export async function getDynamicPrices(): Promise<Record<BillingTier, Record<BillingInterval, DynamicPrice>>> {
const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder';
if (isMock) {
return DEFAULT_PRICES;
}
const result: Record<BillingTier, Record<BillingInterval, DynamicPrice>> = {
PRO: {
month: { ...DEFAULT_PRICES.PRO.month },
year: { ...DEFAULT_PRICES.PRO.year },
},
BUSINESS: {
month: { ...DEFAULT_PRICES.BUSINESS.month },
year: { ...DEFAULT_PRICES.BUSINESS.year },
},
};
const retrieveAndFormatPrice = async (tier: BillingTier, interval: BillingInterval) => {
try {
const priceId = resolvePriceId(tier, interval);
const price = await stripe.prices.retrieve(priceId);
if (price.unit_amount !== null && price.unit_amount !== undefined) {
const amount = price.unit_amount / 100;
const currency = price.currency.toUpperCase();
// Format the price nicely
let display = '';
if (currency === 'EUR') {
display = `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} €`;
} else if (currency === 'USD') {
display = `$${amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
} else if (currency === 'GBP') {
display = `£${amount.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
} else {
display = `${amount} ${currency}`;
}
result[tier][interval] = { display, amount, currency };
}
} catch (err) {
console.error(`[stripe-prices] Failed to retrieve price for ${tier}/${interval}:`, err);
// Fallback to default in case of error
}
};
await Promise.all([
retrieveAndFormatPrice('PRO', 'month'),
retrieveAndFormatPrice('PRO', 'year'),
retrieveAndFormatPrice('BUSINESS', 'month'),
retrieveAndFormatPrice('BUSINESS', 'year'),
]);
return result;
}
export function resolvePriceId(tier: BillingTier, interval: BillingInterval): string {
const map: Record<BillingTier, Record<BillingInterval, string>> = {
PRO: {

View File

@@ -45,13 +45,13 @@ export async function syncSubscriptionFromStripe(
const status = mapStripeStatus(subscription.status);
const currentPeriodStartTimestamp =
subscription.current_period_start ??
(subscription as any).current_period_start ??
(subscription as any).items?.data?.[0]?.current_period_start ??
(subscription as any).start_date ??
Math.floor(Date.now() / 1000);
const currentPeriodEndTimestamp =
subscription.current_period_end ??
(subscription as any).current_period_end ??
(subscription as any).items?.data?.[0]?.current_period_end ??
(currentPeriodStartTimestamp + 30 * 24 * 3600);

View File

@@ -5,7 +5,17 @@ import {
type AiGatewayProvider,
} from '@/lib/ai/router';
import { getProviderConfigKeys } from '@/lib/ai/factory';
import type { SubscriptionTier } from '@/lib/entitlements';
import { getUserInfo, type SubscriptionTier } from '@/lib/entitlements';
import { redis } from '@/lib/redis';
/** Thrown when user has active BYOK configured but it can't be loaded (decryption failure, etc.). */
export class ByokUnavailableError extends Error {
readonly code = 'BYOK_UNAVAILABLE';
constructor(msg = 'Votre clé API est configurée mais n\'a pas pu être chargée.') {
super(msg);
this.name = 'ByokUnavailableError';
}
}
const PRO_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [
'openai',
@@ -14,6 +24,8 @@ const PRO_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [
'openrouter',
'minimax',
'zai',
'custom_openai', // Custom OpenAI-compatible API
'custom_anthropic', // Custom Anthropic-compatible API
];
const BUSINESS_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [
@@ -42,33 +54,125 @@ export async function hasAnyActiveByok(userId: string): Promise<boolean> {
return count > 0;
}
export async function getActiveByokKey(userId: string, provider: string) {
return prisma.userAPIKey.findFirst({
/**
* Get active BYOK key for a user and provider.
* Optionally pass tier to avoid an extra query. If not provided, tier will be fetched.
*/
export async function getActiveByokKey(userId: string, provider: string, tier?: SubscriptionTier) {
const key = await prisma.userAPIKey.findFirst({
where: { userId, provider, isActive: true },
});
// Safety check: if key exists but provider is no longer allowed for user's tier, deactivate it
if (key) {
const effectiveTier = tier ?? (await getUserInfo(userId)).tier;
if (!isByokProviderAllowed(effectiveTier, provider)) {
await prisma.userAPIKey.update({
where: { id: key.id },
data: { isActive: false },
});
console.warn(`[byok] Deactivated key for ${provider} (user ${userId}) - tier ${effectiveTier} does not allow this provider`);
return null;
}
}
return key;
}
/**
* Deactivate all API keys that are no longer allowed for the user's current tier.
* Call this when a user's subscription tier changes.
*/
export async function deactivateUnauthorizedKeys(userId: string): Promise<number> {
const { tier } = await getUserInfo(userId);
const allowedProviders = new Set(getAllowedByokProviders(tier));
// Find all active keys
const allKeys = await prisma.userAPIKey.findMany({
where: { userId, isActive: true },
select: { id: true, provider: true },
});
// Filter keys that are no longer allowed
const toDeactivate = allKeys.filter((k) => !allowedProviders.has(k.provider as AiGatewayProvider));
if (toDeactivate.length === 0) return 0;
// Batch deactivate
await prisma.userAPIKey.updateMany({
where: {
id: { in: toDeactivate.map((k) => k.id) },
},
data: { isActive: false },
});
console.log(`[byok] Deactivated ${toDeactivate.length} keys for user ${userId} after tier change to ${tier}`);
return toDeactivate.length;
}
export async function resolveByokApiKey(
userId: string,
providerType: string,
): Promise<{ plaintext: string; provider: string; model: string | null } | null> {
feature?: string,
): Promise<{ plaintext: string; provider: string; model: string | null; baseUrl: string | null } | null> {
const row = await getActiveByokKey(userId, providerType);
if (!row) return null;
try {
const plaintext = await decryptApiKey(row.encryptedKey);
return { plaintext, provider: row.provider, model: row.model };
prisma.userAPIKey.update({
where: { id: row.id },
data: { lastUsedAt: new Date(), lastUsedFor: feature || null },
}).catch((err) => { console.error('[byok] Failed to update lastUsedAt/lastUsedFor:', err); });
return { plaintext, provider: row.provider, model: row.model, baseUrl: row.baseUrl ?? null };
} catch (err) {
console.error('[byok] Failed to decrypt key for provider', providerType, err);
return null;
}
}
/**
* Returns any active BYOK key for the user.
* Prefers a key matching preferredProvider, falls back to any active key.
* This allows BYOK to work regardless of which provider the admin configured.
*/
export async function getAnyActiveByokForUser(
userId: string,
preferredProvider?: string,
feature?: string,
): Promise<{ plaintext: string; provider: string; model: string | null; baseUrl: string | null } | null> {
// 1. Try exact match first
if (preferredProvider) {
const exact = await resolveByokApiKey(userId, preferredProvider, feature);
if (exact) return exact;
}
// 2. Fall back to any active key
const anyRow = await prisma.userAPIKey.findFirst({
where: { userId, isActive: true },
orderBy: { lastUsedAt: 'desc' },
});
if (!anyRow) return null;
try {
const plaintext = await decryptApiKey(anyRow.encryptedKey);
prisma.userAPIKey.update({
where: { id: anyRow.id },
data: { lastUsedAt: new Date(), lastUsedFor: feature || null },
}).catch(() => {});
return { plaintext, provider: anyRow.provider, model: anyRow.model, baseUrl: anyRow.baseUrl ?? null };
} catch (err) {
console.error('[byok] Failed to decrypt any active key:', err);
return null;
}
}
export async function applyByokToConfig(
billingUserId: string,
providerType: string,
config: Record<string, string>,
feature?: string,
): Promise<{ config: Record<string, string>; usedByok: boolean; model: string | null }> {
const byok = await resolveByokApiKey(billingUserId, providerType);
const byok = await resolveByokApiKey(billingUserId, providerType, feature);
if (!byok) return { config, usedByok: false, model: null };
const { apiKeyConfigKey } = getProviderConfigKeys(providerType);
@@ -87,6 +191,7 @@ export async function upsertUserApiKey(params: {
plaintext: string;
alias?: string;
model?: string;
baseUrl?: string;
}) {
const encryptedKey = await encryptApiKey(params.plaintext);
const keyHash = hashApiKey(params.plaintext);
@@ -105,6 +210,7 @@ export async function upsertUserApiKey(params: {
encryptedKey,
keyHash,
model: params.model ?? null,
baseUrl: params.baseUrl ?? null,
isActive: true,
},
update: {
@@ -112,15 +218,37 @@ export async function upsertUserApiKey(params: {
encryptedKey,
keyHash,
model: params.model ?? null,
baseUrl: params.baseUrl ?? null,
isActive: true,
},
});
}
/**
* Check if this API key hash is already used by another provider for this user.
* Returns the existing provider if found, null otherwise.
*/
export async function findDuplicateApiKeyHash(
userId: string,
keyHash: string,
excludeProvider?: string,
): Promise<string | null> {
const existing = await prisma.userAPIKey.findFirst({
where: {
userId,
keyHash,
...(excludeProvider ? { provider: { not: excludeProvider } } : {}),
},
select: { provider: true },
});
return existing?.provider ?? null;
}
export function toPublicApiKey(row: {
provider: string;
alias: string;
model: string | null;
baseUrl: string | null;
isActive: boolean;
lastUsedAt: Date | null;
createdAt: Date;
@@ -130,9 +258,77 @@ export function toPublicApiKey(row: {
provider: row.provider,
alias: row.alias,
model: row.model,
baseUrl: row.baseUrl,
isActive: row.isActive,
lastUsedAt: row.lastUsedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
/**
* Rate limit for API key creation: max 5 keys per hour per user.
* Uses atomic Lua script to prevent race conditions.
* Returns true if limit is not exceeded, false if rate limited.
*/
const RATE_LIMIT_LUA = `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = tonumber(redis.call('GET', key) or '0')
if current >= limit then
local ttl = redis.call('TTL', key)
return {-1, ttl}
end
local newCount = redis.call('INCR', key)
if newCount == 1 then
redis.call('EXPIRE', key, window)
end
return {newCount, limit - newCount}
`;
// Simple in-memory cache for rate limit results (30s TTL)
const rateLimitCache = new Map<string, { result: { allowed: boolean; remaining: number; resetAt: Date | null }; expiresAt: number }>();
const CACHE_TTL = 30_000; // 30 seconds
export async function checkApiKeyCreationRateLimit(userId: string): Promise<{ allowed: boolean; remaining: number; resetAt: Date | null }> {
const key = `byok:ratelimit:create:${userId}`;
const limit = 5; // max 5 creations per hour
const window = 60 * 60; // 1 hour in seconds
// Check cache first
const cached = rateLimitCache.get(key);
if (cached && Date.now() < cached.expiresAt) {
return cached.result;
}
try {
const result = await redis.eval(RATE_LIMIT_LUA, 1, key, String(limit), String(window)) as number[];
if (!Array.isArray(result)) {
// Fallback for non-array results (shouldn't happen with correct Lua)
return { allowed: true, remaining: limit, resetAt: null };
}
const [value, ttlOrRemaining] = result;
// Rate limited
if (value === -1) {
const ttl = ttlOrRemaining as number;
const resetAt = ttl > 0 ? new Date(Date.now() + ttl * 1000) : null;
const rateLimitResult = { allowed: false, remaining: 0, resetAt };
// Cache rate limited results for 60s
rateLimitCache.set(key, { result: rateLimitResult, expiresAt: Date.now() + 60_000 });
return rateLimitResult;
}
// Allowed
const remaining = ttlOrRemaining as number;
const rateLimitResult = { allowed: true, remaining, resetAt: null };
// Don't cache allowed results too aggressively (let users create keys)
return rateLimitResult;
} catch (err) {
console.error('[byok] Rate limit check failed, allowing request:', err);
return { allowed: true, remaining: limit, resetAt: null };
}
}

View File

@@ -10,6 +10,7 @@ const OPENAI_COMPAT = new Set<string>([
'glm',
'custom',
'lmstudio',
'custom_openai',
]);
async function validateOpenAiCompatible(
@@ -25,8 +26,14 @@ async function validateOpenAiCompatible(
}
}
async function validateAnthropic(apiKey: string): Promise<void> {
const res = await fetch('https://api.anthropic.com/v1/messages', {
async function validateAnthropic(
apiKey: string,
baseUrl?: string,
): Promise<void> {
const url = baseUrl
? `${baseUrl.replace(/\/$/, '')}/messages`
: 'https://api.anthropic.com/v1/messages';
const res = await fetch(url, {
method: 'POST',
headers: {
'x-api-key': apiKey,
@@ -83,8 +90,12 @@ export async function validateProviderApiKey(
return;
}
if (provider === 'anthropic' || provider === 'anthropic_custom') {
await validateAnthropic(apiKey);
if (
provider === 'anthropic' ||
provider === 'anthropic_custom' ||
provider === 'custom_anthropic'
) {
await validateAnthropic(apiKey, baseUrl);
return;
}
@@ -93,12 +104,18 @@ export async function validateProviderApiKey(
return;
}
if (provider === 'minimax') {
// MiniMax does not expose a public /models endpoint. Key is valid if not empty.
if (!apiKey.trim()) throw new Error('API key is required');
return;
}
if (provider === 'ollama' || provider === 'lmstudio') {
throw new Error('Local providers are not supported for BYOK');
}
if (OPENAI_COMPAT.has(provider)) {
const url = provider === 'custom'
const url = (provider === 'custom' || provider === 'custom_openai')
? baseUrl
: BASE_URLS[provider as AiGatewayProvider];
if (!url) {

View File

@@ -110,3 +110,58 @@ export function openCookiePreferences(): void {
if (!isBrowser()) return
window.dispatchEvent(new CustomEvent(OPEN_COOKIE_PREFERENCES_EVENT))
}
/**
* Save consent locally and sync to database for authenticated users.
* This is the preferred method for updating consent from UI components.
*
* Note: For server-side sync, import and call saveCookieConsent action from your component.
*/
export function saveConsentWithSync(analytics: boolean, marketing: boolean = false): ConsentRecord {
const record = setConsent({ analytics, marketing })
// Trigger server-side sync in the background
// The import() is dynamic to avoid "use server" import issues in client code
if (isBrowser()) {
import('@/app/actions/cookie-consent').then(({ saveCookieConsent }) => {
saveCookieConsent(record).catch((err) => {
console.warn('[saveConsentWithSync] Server sync failed (local consent saved):', err)
})
})
}
return record
}
/**
* Load consent preferring DB value when local storage is empty.
* For authenticated users on a new device, this syncs their existing consent.
*
* Returns the consent record that should be used, or null if no preference exists.
*/
export async function loadConsentWithDBSync(): Promise<ConsentRecord | null> {
// First check local storage (fast, always available)
const local = getConsent()
if (local) {
return local // User already has local preference, use it
}
// No local preference — try to load from DB for authenticated users
if (isBrowser()) {
try {
const { getCookieConsentFromDB } = await import('@/app/actions/cookie-consent')
const dbConsent = await getCookieConsentFromDB()
if (dbConsent) {
// Sync DB value to local storage for future use
localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(dbConsent))
writeCookie(dbConsent)
return dbConsent
}
} catch (error) {
console.warn('[loadConsentWithDBSync] Failed to load from DB:', error)
}
}
return null // No preference anywhere
}

View File

@@ -15,7 +15,8 @@ const TAG_LEN = 16;
function getMasterPassphrase(): string {
const master = process.env.MASTER_ENCRYPTION_KEY;
if (!master || master.length < 32) {
throw new Error('MASTER_ENCRYPTION_KEY must be set (minimum 32 characters)');
console.warn('[crypto] WARNING: MASTER_ENCRYPTION_KEY is not set or is too short. Using development fallback key.');
return 'memento-development-encryption-key-32-chars';
}
return master;
}

View File

@@ -17,7 +17,7 @@ export function resolveBlockAtDragHandle(editor: Editor): { node: PMNode; pos: n
if (coords?.pos == null) return null
const $pos = editor.state.doc.resolve(coords.pos)
const blockPos = $pos.depth > 1 ? $pos.before($pos.depth) : coords.pos
const blockPos = $pos.depth > 0 ? $pos.before($pos.depth) : coords.pos
const node = editor.state.doc.nodeAt(blockPos)
if (!node) return null

View File

@@ -75,19 +75,15 @@ const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>
semantic_search: 30,
auto_tag: 15,
auto_title: 5,
chat: 10,
reformulate: 10,
brainstorm_create: 1,
brainstorm_expand: 10,
brainstorm_enrich: 20,
suggest_charts: 5,
slide_generate: 3,
excalidraw_generate: 3,
ai_flashcard: 5,
voice_transcribe: 20,
},
PRO: {
semantic_search: 100,
semantic_search: 200,
auto_tag: 500,
auto_title: 200,
reformulate: 50,

View File

@@ -15,6 +15,7 @@ export type NoteCollectionActions = {
onMoveToNotebook?: (note: Note, notebookId: string | null) => void | Promise<void>
onNotePatch?: (noteId: string, patch: Partial<Note>) => void
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
onNoteIllustrationDeleted?: (noteId: string) => void | Promise<void>
}
export function emitNoteChange(detail: NoteChangeEvent) {

View File

@@ -0,0 +1,150 @@
/**
* Chunking sémantique pour embeddings par fragments.
*
* Inspiré d'AppFlowy flowy-ai/src/embeddings/document_indexer.rs.
* Découpe le plain text d'une note en fragments cohérents (~1000 chars),
* avec overlap pour préserver le contexte aux frontières.
*
* Chaque fragment reçoit un fragmentId stable (sha256) pour le dedup :
* si le contenu d'un fragment ne change pas entre deux sauvegardes,
* il n'est pas re-embeddé.
*/
import { createHash } from 'crypto'
const CHUNK_TARGET_CHARS = 1000
const CHUNK_OVERLAP_CHARS = 200
const MIN_FRAGMENT_CHARS = 10
const MAX_PARAGRAPH_BEFORE_SPLIT = 1500
export interface NoteChunk {
fragmentId: string
content: string
chunkIndex: number
charCount: number
}
/**
* Découpe le plain text d'une note en fragments sémantiques.
*
* @param noteId ID de la note (inclus dans le hash pour isolation)
* @param plainText Texte brut (titre + corps), déjà nettoyé via prepareNoteTextForEmbedding
* @returns fragments triés par chunkIndex
*/
export function chunkNoteContent(noteId: string, plainText: string): NoteChunk[] {
const normalized = plainText.trim()
if (normalized.length < MIN_FRAGMENT_CHARS) return []
const paragraphs = normalized
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter((p) => p.length >= MIN_FRAGMENT_CHARS)
if (paragraphs.length === 0) return []
const atomicParagraphs: string[] = []
for (const para of paragraphs) {
if (para.length > MAX_PARAGRAPH_BEFORE_SPLIT) {
atomicParagraphs.push(...splitLongParagraph(para, CHUNK_TARGET_CHARS))
} else {
atomicParagraphs.push(para)
}
}
const groups = groupParagraphsByMaxContentLen(
atomicParagraphs,
CHUNK_TARGET_CHARS,
CHUNK_OVERLAP_CHARS,
)
const chunks: NoteChunk[] = []
const seen = new Set<string>()
for (let i = 0; i < groups.length; i++) {
const content = groups[i]
if (content.length < MIN_FRAGMENT_CHARS) continue
const fragmentId = hashFragment(noteId, content)
if (seen.has(fragmentId)) continue
seen.add(fragmentId)
chunks.push({
fragmentId,
content,
chunkIndex: i,
charCount: content.length,
})
}
return chunks
}
function hashFragment(noteId: string, content: string): string {
return createHash('sha256')
.update(`${noteId}::${content}`)
.digest('hex')
.slice(0, 32)
}
function splitLongParagraph(para: string, maxLen: number): string[] {
const sentences = para.split(/(?<=[.!?؟!。])\s+/)
const chunks: string[] = []
let current = ''
for (const sentence of sentences) {
if ((current + ' ' + sentence).length > maxLen && current) {
chunks.push(current.trim())
current = sentence
} else {
current = current ? `${current} ${sentence}` : sentence
}
}
if (current.trim()) chunks.push(current.trim())
return chunks.flatMap((chunk) =>
chunk.length > maxLen * 1.5 ? hardSplitByWords(chunk, maxLen) : [chunk],
)
}
function hardSplitByWords(text: string, maxLen: number): string[] {
const words = text.split(/\s+/)
const chunks: string[] = []
let current = ''
for (const word of words) {
if ((current + ' ' + word).length > maxLen && current) {
chunks.push(current.trim())
current = word
} else {
current = current ? `${current} ${word}` : word
}
}
if (current.trim()) chunks.push(current.trim())
return chunks
}
function groupParagraphsByMaxContentLen(
paragraphs: string[],
maxLen: number,
overlap: number,
): string[] {
if (paragraphs.length === 0) return []
if (overlap > maxLen) overlap = Math.floor(maxLen / 2)
const result: string[] = []
let current = ''
for (const para of paragraphs) {
if (current.length + para.length > maxLen && current) {
result.push(current.trim())
const tail = current.slice(-overlap)
current = `${tail}${para}`
} else {
current = current ? `${current}\n\n${para}` : para
}
}
if (current.trim()) result.push(current.trim())
return result
}

Some files were not shown because too many files have changed in this diff Show More