perf: memo GridCard, fuse save fns, fix slash tab active color
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
71
memento-note/app/actions/cookie-consent.ts
Normal 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 }
|
||||
}
|
||||
118
memento-note/app/actions/diagram.ts
Normal 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.' }
|
||||
}
|
||||
}
|
||||
@@ -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 n’a 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
|
||||
@@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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}]`
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
39
memento-note/app/api/user/ai-status/route.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
|
||||
167
memento-note/app/api/user/api-keys/test-model/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
82
memento-note/app/api/user/api-keys/verify/route.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 282 KiB |
160
memento-note/extension/diagnose.js
Normal 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')
|
||||
@@ -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": "قم بتمييز النص الموجود على الصفحة، أو قم بقص الصفحة بأكملها."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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": "متن را در صفحه برجسته کنید یا کل صفحه را برش دهید."
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "पृष्ठ पर टेक्स्ट को हाइलाइट करें, या पूरे पृष्ठ को क्लिप करें।"
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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": "ページ上のテキストを強調表示するか、ページ全体をクリップします。"
|
||||
}
|
||||
}
|
||||
@@ -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": "페이지의 텍스트를 강조 표시하거나 전체 페이지를 자릅니다."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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ę."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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": "Выделите текст на странице или вырежьте всю страницу."
|
||||
}
|
||||
}
|
||||
@@ -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(或 LAN)URL。此浏览器中的 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": "突出显示页面上的文本,或剪辑整个页面。"
|
||||
}
|
||||
}
|
||||
8
memento-note/extension/dist-chrome-store/background.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Service worker — ouvre le panneau latéral au clic sur l’icône. */
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
||||
})
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
||||
})
|
||||
211
memento-note/extension/dist-chrome-store/content.js
Normal 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 l’onglet é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, '<')}</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()
|
||||
})()
|
||||
47
memento-note/extension/dist-chrome-store/i18n.js
Normal 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')
|
||||
}
|
||||
@@ -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()
|
||||
2735
memento-note/extension/dist-chrome-store/i18n/translations.json
Normal file
BIN
memento-note/extension/dist-chrome-store/icon-128.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
memento-note/extension/dist-chrome-store/icon-16.png
Normal file
|
After Width: | Height: | Size: 236 B |
10
memento-note/extension/dist-chrome-store/icon-192.svg
Normal 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 |
BIN
memento-note/extension/dist-chrome-store/icon-48.png
Normal file
|
After Width: | Height: | Size: 587 B |
10
memento-note/extension/dist-chrome-store/icon-512.svg
Normal 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 |
51
memento-note/extension/dist-chrome-store/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
507
memento-note/extension/dist-chrome-store/sidepanel.css
Normal 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;
|
||||
}
|
||||
60
memento-note/extension/dist-chrome-store/sidepanel.html
Normal 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>
|
||||
712
memento-note/extension/dist-chrome-store/sidepanel.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
BIN
memento-note/extension/memento-web-clipper-chrome-store.zip
Normal file
247
memento-note/extension/scripts/build-chrome-store.mjs
Executable 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()
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
78
memento-note/extension/test-sidepanel.html
Normal 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>
|
||||
@@ -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)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
217
memento-note/lib/ai/services/chunk-indexing.service.ts
Normal 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()
|
||||
@@ -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).
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>()
|
||||
|
||||
43
memento-note/lib/ai/utils/clean-ai-response.ts
Normal 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
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
150
memento-note/lib/text/note-chunking.ts
Normal 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
|
||||
}
|
||||