feat: rename keep-notes to memento-note, migrate to PostgreSQL, fix MCP bugs

- Rename directory keep-notes -> memento-note with all code references
- Prisma: SQLite -> PostgreSQL (both app and MCP server schemas)
- Sync MCP schema with main app (add missing fields, relations, indexes)
- Delete 17 SQLite migrations (clean slate for PostgreSQL)
- Remove SQLite dependencies (@libsql/client, better-sqlite3, etc.)
- Fix MCP server: hardcoded Windows DB paths -> DATABASE_URL env var
- Fix MCP server: .dockerignore excluded index-sse.js (SSE mode broken)
- MCP Dockerfile: node:20 -> node:22
- Docker Compose: add postgres service, remove SQLite volume
- Generate favicon.ico, icon-192.png, icon-512.png, apple-icon.png
- Update layout.tsx icons and manifest.json for PNG icons
- Update all .env files for PostgreSQL
- Rewrite README.md with updated sections
- Remove mcp-server/node_modules and prisma/client-generated from git tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-04-20 20:58:04 +02:00
parent e2ddfa53d4
commit aa6a214f37
3548 changed files with 440 additions and 516800 deletions

View File

@@ -0,0 +1,107 @@
import { OpenAIProvider } from './providers/openai';
import { OllamaProvider } from './providers/ollama';
import { CustomOpenAIProvider } from './providers/custom-openai';
import { AIProvider } from './types';
type ProviderType = 'ollama' | 'openai' | 'custom';
function createOllamaProvider(config: Record<string, string>, modelName: string, embeddingModelName: string): OllamaProvider {
let baseUrl = config?.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL
// Only use localhost as fallback for local development (not in Docker)
if (!baseUrl && process.env.NODE_ENV !== 'production') {
baseUrl = 'http://localhost:11434'
}
if (!baseUrl) {
throw new Error('OLLAMA_BASE_URL is required when using Ollama provider')
}
// Ensure baseUrl doesn't end with /api, we'll add it in OllamaProvider
if (baseUrl.endsWith('/api')) {
baseUrl = baseUrl.slice(0, -4); // Remove /api
}
return new OllamaProvider(baseUrl, modelName, embeddingModelName);
}
function createOpenAIProvider(config: Record<string, string>, modelName: string, embeddingModelName: string): OpenAIProvider {
const apiKey = config?.OPENAI_API_KEY || process.env.OPENAI_API_KEY || '';
if (!apiKey) {
throw new Error('OPENAI_API_KEY is required when using OpenAI provider');
}
return new OpenAIProvider(apiKey, modelName, embeddingModelName);
}
function createCustomOpenAIProvider(config: Record<string, string>, modelName: string, embeddingModelName: string): CustomOpenAIProvider {
const apiKey = config?.CUSTOM_OPENAI_API_KEY || process.env.CUSTOM_OPENAI_API_KEY || '';
const baseUrl = config?.CUSTOM_OPENAI_BASE_URL || process.env.CUSTOM_OPENAI_BASE_URL || '';
if (!apiKey) {
throw new Error('CUSTOM_OPENAI_API_KEY is required when using Custom OpenAI provider');
}
if (!baseUrl) {
throw new Error('CUSTOM_OPENAI_BASE_URL is required when using Custom OpenAI provider');
}
return new CustomOpenAIProvider(apiKey, baseUrl, modelName, embeddingModelName);
}
function getProviderInstance(providerType: ProviderType, config: Record<string, string>, modelName: string, embeddingModelName: string): AIProvider {
switch (providerType) {
case 'ollama':
return createOllamaProvider(config, modelName, embeddingModelName);
case 'openai':
return createOpenAIProvider(config, modelName, embeddingModelName);
case 'custom':
return createCustomOpenAIProvider(config, modelName, embeddingModelName);
default:
return createOllamaProvider(config, modelName, embeddingModelName);
}
}
export function getTagsProvider(config?: Record<string, string>): AIProvider {
// Check database config first, then environment variables
const providerType = (config?.AI_PROVIDER_TAGS || process.env.AI_PROVIDER_TAGS);
// If no provider is configured, throw a clear error
if (!providerType) {
throw new Error(
'AI_PROVIDER_TAGS is not configured. Please set it in the admin settings or environment variables. ' +
'Options: ollama, openai, custom'
);
}
const provider = providerType.toLowerCase() as ProviderType;
const modelName = config?.AI_MODEL_TAGS || process.env.AI_MODEL_TAGS || 'granite4:latest';
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
return getProviderInstance(provider, config || {}, modelName, embeddingModelName);
}
export function getEmbeddingsProvider(config?: Record<string, string>): AIProvider {
// Check database config first, then environment variables
const providerType = (config?.AI_PROVIDER_EMBEDDING || process.env.AI_PROVIDER_EMBEDDING);
// If no provider is configured, throw a clear error
if (!providerType) {
throw new Error(
'AI_PROVIDER_EMBEDDING is not configured. Please set it in the admin settings or environment variables. ' +
'Options: ollama, openai, custom'
);
}
const provider = providerType.toLowerCase() as ProviderType;
const modelName = config?.AI_MODEL_TAGS || process.env.AI_MODEL_TAGS || 'granite4:latest';
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
return getProviderInstance(provider, config || {}, modelName, embeddingModelName);
}
// Legacy function for backward compatibility
export function getAIProvider(config?: Record<string, string>): AIProvider {
return getTagsProvider(config);
}

View File

@@ -0,0 +1,93 @@
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject, generateText, embed } from 'ai';
import { z } from 'zod';
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
export class CustomOpenAIProvider implements AIProvider {
private model: any;
private embeddingModel: any;
constructor(
apiKey: string,
baseUrl: string,
modelName: string = 'gpt-4o-mini',
embeddingModelName: string = 'text-embedding-3-small'
) {
// Create OpenAI-compatible client with custom base URL
const customClient = createOpenAI({
baseURL: baseUrl,
apiKey: apiKey,
});
this.model = customClient(modelName);
this.embeddingModel = customClient.embedding(embeddingModelName);
}
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('Le nom du tag, court et en minuscules'),
confidence: z.number().min(0).max(1).describe('Le niveau de confiance entre 0 et 1')
}))
}),
prompt: `Analyse la note suivante et suggère entre 1 et 5 tags pertinents.
Contenu de la note: "${content}"`,
});
return object.tags;
} catch (e) {
console.error('Erreur génération tags Custom OpenAI:', e);
return [];
}
}
async getEmbeddings(text: string): Promise<number[]> {
try {
const { embedding } = await embed({
model: this.embeddingModel,
value: text,
});
return embedding;
} catch (e) {
console.error('Erreur embeddings Custom OpenAI:', e);
return [];
}
}
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('Le titre suggéré'),
confidence: z.number().min(0).max(1).describe('Le niveau de confiance entre 0 et 1')
}))
}),
prompt: prompt,
});
return object.titles;
} catch (e) {
console.error('Erreur génération titres Custom OpenAI:', e);
return [];
}
}
async generateText(prompt: string): Promise<string> {
try {
const { text } = await generateText({
model: this.model,
prompt: prompt,
});
return text.trim();
} catch (e) {
console.error('Erreur génération texte Custom OpenAI:', e);
throw e;
}
}
}

View File

@@ -0,0 +1,88 @@
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject, generateText, embed } from 'ai';
import { z } from 'zod';
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
export class DeepSeekProvider implements AIProvider {
private model: any;
private embeddingModel: any;
constructor(apiKey: string, modelName: string = 'deepseek-chat', embeddingModelName: string = 'deepseek-embedding') {
// Create OpenAI-compatible client for DeepSeek
const deepseek = createOpenAI({
baseURL: 'https://api.deepseek.com/v1',
apiKey: apiKey,
});
this.model = deepseek(modelName);
this.embeddingModel = deepseek.embedding(embeddingModelName);
}
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('Le nom du tag, court et en minuscules'),
confidence: z.number().min(0).max(1).describe('Le niveau de confiance entre 0 et 1')
}))
}),
prompt: `Analyse la note suivante et suggère entre 1 et 5 tags pertinents.
Contenu de la note: "${content}"`,
});
return object.tags;
} catch (e) {
console.error('Erreur génération tags DeepSeek:', e);
return [];
}
}
async getEmbeddings(text: string): Promise<number[]> {
try {
const { embedding } = await embed({
model: this.embeddingModel,
value: text,
});
return embedding;
} catch (e) {
console.error('Erreur embeddings DeepSeek:', e);
return [];
}
}
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('Le titre suggéré'),
confidence: z.number().min(0).max(1).describe('Le niveau de confiance entre 0 et 1')
}))
}),
prompt: prompt,
});
return object.titles;
} catch (e) {
console.error('Erreur génération titres DeepSeek:', e);
return [];
}
}
async generateText(prompt: string): Promise<string> {
try {
const { text } = await generateText({
model: this.model,
prompt: prompt,
});
return text.trim();
} catch (e) {
console.error('Erreur génération texte DeepSeek:', e);
throw e;
}
}
}

View File

@@ -0,0 +1,137 @@
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
export class OllamaProvider implements AIProvider {
private baseUrl: string;
private modelName: string;
private embeddingModelName: string;
constructor(baseUrl: string, modelName: string = 'llama3', embeddingModelName?: string) {
if (!baseUrl) {
throw new Error('baseUrl is required for OllamaProvider')
}
// Ensure baseUrl ends with /api for Ollama API
this.baseUrl = baseUrl.endsWith('/api') ? baseUrl : `${baseUrl}/api`;
this.modelName = modelName;
this.embeddingModelName = embeddingModelName || modelName;
}
async generateTags(content: string): Promise<TagSuggestion[]> {
try {
const response = await fetch(`${this.baseUrl}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
prompt: `Analyse la note suivante et extrais les concepts clés sous forme de tags courts (1-3 mots max).
Règles:
- Pas de mots de liaison (le, la, pour, et...).
- Garde les expressions composées ensemble (ex: "semaine prochaine", "New York").
- Normalise en minuscules sauf noms propres.
- Maximum 5 tags.
Réponds UNIQUEMENT sous forme de liste JSON d'objets : [{"tag": "string", "confidence": number}].
Contenu de la note: "${content}"`,
stream: false,
}),
});
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
const data = await response.json();
const text = data.response;
const jsonMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
// Support pour le format { "tags": [...] }
const objectMatch = text.match(/\{\s*"tags"\s*:\s*(\[[\s\S]*\])\s*\}/);
if (objectMatch && objectMatch[1]) {
return JSON.parse(objectMatch[1]);
}
return [];
} catch (e) {
console.error('Erreur API directe Ollama:', e);
return [];
}
}
async getEmbeddings(text: string): Promise<number[]> {
try {
const response = await fetch(`${this.baseUrl}/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.embeddingModelName,
prompt: text,
}),
});
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
const data = await response.json();
return data.embedding;
} catch (e) {
console.error('Erreur embeddings directs Ollama:', e);
return [];
}
}
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
try {
const response = await fetch(`${this.baseUrl}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
prompt: `${prompt}
Réponds UNIQUEMENT sous forme de tableau JSON : [{"title": "string", "confidence": number}]`,
stream: false,
}),
});
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
const data = await response.json();
const text = data.response;
// Extraire le JSON de la réponse
const jsonMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
return [];
} catch (e) {
console.error('Erreur génération titres Ollama:', e);
return [];
}
}
async generateText(prompt: string): Promise<string> {
try {
const response = await fetch(`${this.baseUrl}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
prompt: prompt,
stream: false,
}),
});
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
const data = await response.json();
return data.response.trim();
} catch (e) {
console.error('Erreur génération texte Ollama:', e);
throw e;
}
}
}

View File

@@ -0,0 +1,87 @@
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject, generateText, embed } from 'ai';
import { z } from 'zod';
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
export class OpenAIProvider implements AIProvider {
private model: any;
private embeddingModel: any;
constructor(apiKey: string, modelName: string = 'gpt-4o-mini', embeddingModelName: string = 'text-embedding-3-small') {
// Create OpenAI client with API key
const openaiClient = createOpenAI({
apiKey: apiKey,
});
this.model = openaiClient(modelName);
this.embeddingModel = openaiClient.embedding(embeddingModelName);
}
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('Le nom du tag, court et en minuscules'),
confidence: z.number().min(0).max(1).describe('Le niveau de confiance entre 0 et 1')
}))
}),
prompt: `Analyse la note suivante et suggère entre 1 et 5 tags pertinents.
Contenu de la note: "${content}"`,
});
return object.tags;
} catch (e) {
console.error('Erreur génération tags OpenAI:', e);
return [];
}
}
async getEmbeddings(text: string): Promise<number[]> {
try {
const { embedding } = await embed({
model: this.embeddingModel,
value: text,
});
return embedding;
} catch (e) {
console.error('Erreur embeddings OpenAI:', e);
return [];
}
}
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('Le titre suggéré'),
confidence: z.number().min(0).max(1).describe('Le niveau de confiance entre 0 et 1')
}))
}),
prompt: prompt,
});
return object.titles;
} catch (e) {
console.error('Erreur génération titres OpenAI:', e);
return [];
}
}
async generateText(prompt: string): Promise<string> {
try {
const { text } = await generateText({
model: this.model,
prompt: prompt,
});
return text.trim();
} catch (e) {
console.error('Erreur génération texte OpenAI:', e);
throw e;
}
}
}

View File

@@ -0,0 +1,88 @@
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject, generateText, embed } from 'ai';
import { z } from 'zod';
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
export class OpenRouterProvider implements AIProvider {
private model: any;
private embeddingModel: any;
constructor(apiKey: string, modelName: string = 'anthropic/claude-3-haiku', embeddingModelName: string = 'openai/text-embedding-3-small') {
// Create OpenAI-compatible client for OpenRouter
const openrouter = createOpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: apiKey,
});
this.model = openrouter(modelName);
this.embeddingModel = openrouter.embedding(embeddingModelName);
}
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('Le nom du tag, court et en minuscules'),
confidence: z.number().min(0).max(1).describe('Le niveau de confiance entre 0 et 1')
}))
}),
prompt: `Analyse la note suivante et suggère entre 1 et 5 tags pertinents.
Contenu de la note: "${content}"`,
});
return object.tags;
} catch (e) {
console.error('Erreur génération tags OpenRouter:', e);
return [];
}
}
async getEmbeddings(text: string): Promise<number[]> {
try {
const { embedding } = await embed({
model: this.embeddingModel,
value: text,
});
return embedding;
} catch (e) {
console.error('Erreur embeddings OpenRouter:', e);
return [];
}
}
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('Le titre suggéré'),
confidence: z.number().min(0).max(1).describe('Le niveau de confiance entre 0 et 1')
}))
}),
prompt: prompt,
});
return object.titles;
} catch (e) {
console.error('Erreur génération titres OpenRouter:', e);
return [];
}
}
async generateText(prompt: string): Promise<string> {
try {
const { text } = await generateText({
model: this.model,
prompt: prompt,
});
return text.trim();
} catch (e) {
console.error('Erreur génération texte OpenRouter:', e);
throw e;
}
}
}

View File

@@ -0,0 +1,468 @@
import { prisma } from '@/lib/prisma'
import { getAIProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
export interface SuggestedLabel {
name: string
count: number
confidence: number
noteIds: string[]
}
export interface AutoLabelSuggestion {
notebookId: string
notebookName: string
notebookIcon: string | null
suggestedLabels: SuggestedLabel[]
totalNotes: number
}
/**
* Service for automatically suggesting new labels based on recurring themes
* (Story 5.4 - IA4)
*/
export class AutoLabelCreationService {
/**
* Analyze a notebook and suggest new labels based on recurring themes
* @param notebookId - Notebook ID to analyze
* @param userId - User ID (for authorization)
* @returns Suggested labels or null if not enough notes/no patterns found
*/
async suggestLabels(notebookId: string, userId: string, language: string = 'en'): Promise<AutoLabelSuggestion | null> {
// 1. Get notebook with existing labels
const notebook = await prisma.notebook.findFirst({
where: {
id: notebookId,
userId,
},
include: {
labels: {
select: {
id: true,
name: true,
},
},
_count: {
select: { notes: true },
},
},
})
if (!notebook) {
throw new Error('Notebook not found')
}
// Only trigger if notebook has 15+ notes (PRD requirement)
if (notebook._count.notes < 15) {
return null
}
// Get all notes in this notebook
const notes = await prisma.note.findMany({
where: {
notebookId,
userId,
},
select: {
id: true,
title: true,
content: true,
labelRelations: {
select: {
name: true,
},
},
},
orderBy: {
updatedAt: 'desc',
},
take: 100, // Limit to 100 most recent notes
})
if (notes.length === 0) {
return null
}
// 2. Use AI to detect recurring themes
const suggestions = await this.detectRecurringThemes(notes, notebook, language)
return suggestions
}
/**
* Use AI to detect recurring themes and suggest labels
*/
private async detectRecurringThemes(
notes: any[],
notebook: any,
language: string
): Promise<AutoLabelSuggestion | null> {
const existingLabelNames = new Set<string>(
notebook.labels.map((l: any) => l.name.toLowerCase())
)
const prompt = this.buildPrompt(notes, existingLabelNames, language)
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
const response = await provider.generateText(prompt)
// Parse AI response
const suggestions = this.parseAIResponse(response, notes)
if (!suggestions || suggestions.suggestedLabels.length === 0) {
return null
}
return {
notebookId: notebook.id,
notebookName: notebook.name,
notebookIcon: notebook.icon,
suggestedLabels: suggestions.suggestedLabels,
totalNotes: notebook._count.notes,
}
} catch (error) {
console.error('Failed to detect recurring themes:', error)
return null
}
}
/**
* Build prompt for AI (localized)
*/
private buildPrompt(notes: any[], existingLabelNames: Set<string>, language: string = 'en'): string {
const notesSummary = notes
.map((note, index) => {
const title = note.title || 'Sans titre'
const content = note.content.substring(0, 150)
return `[${index}] "${title}": ${content}`
})
.join('\n')
const existingLabels = Array.from(existingLabelNames).join(', ')
const instructions: Record<string, string> = {
fr: `
Tu es un assistant qui détecte les thèmes récurrents dans des notes pour suggérer de nouvelles étiquettes.
CARNET ANALYSÉ :
${notes.length} notes
ÉTIQUETTES EXISTANTES (ne pas suggérer celles-ci) :
${existingLabels || 'Aucune'}
NOTES DU CARNET :
${notesSummary}
TÂCHE :
Analyse les notes et détecte les thèmes récurrents (mots-clés, sujets, lieux, personnes).
Un thème doit apparaître dans au moins 5 notes différentes pour être suggéré.
FORMAT DE RÉPONSE (JSON) :
{
"labels": [
{
"nom": "nom_du_label",
"note_indices": [0, 5, 12, 23, 45],
"confiance": 0.85
}
]
}
RÈGLES :
- Le nom du label doit être court (1-2 mots max)
- Un thème doit apparaître dans 5+ notes pour être suggéré
- La confiance doit être > 0.60
- Ne pas suggérer des étiquettes qui existent déjà
- Priorise les lieux, personnes, catégories claires
- Maximum 5 suggestions
Exemples de bonnes étiquettes :
- "tokyo", "kyoto", "osaka" (lieux)
- "hôtels", "restos", "vols" (catégories)
- "marie", "jean", "équipe" (personnes)
Ta réponse (JSON seulement) :
`.trim(),
en: `
You are an assistant that detects recurring themes in notes to suggest new labels.
ANALYZED NOTEBOOK:
${notes.length} notes
EXISTING LABELS (do not suggest these):
${existingLabels || 'None'}
NOTEBOOK NOTES:
${notesSummary}
TASK:
Analyze the notes and detect recurring themes (keywords, subjects, places, people).
A theme must appear in at least 5 different notes to be suggested.
RESPONSE FORMAT (JSON):
{
"labels": [
{
"nom": "label_name",
"note_indices": [0, 5, 12, 23, 45],
"confiance": 0.85
}
]
}
RULES:
- Label name must be short (max 1-2 words)
- A theme must appear in 5+ notes to be suggested
- Confidence must be > 0.60
- Do not suggest labels that already exist
- Prioritize places, people, clear categories
- Maximum 5 suggestions
Examples of good labels:
- "tokyo", "kyoto", "osaka" (places)
- "hotels", "restaurants", "flights" (categories)
- "mary", "john", "team" (people)
Your response (JSON only):
`.trim(),
fa: `
شما یک دستیار هستید که تم‌های تکرارشونده در یادداشت‌ها را برای پیشنهاد برچسب‌های جدید شناسایی می‌کنید.
دفترچه‌ تحلیل شده:
${notes.length} یادداشت
برچسب‌های موجود (این‌ها را پیشنهاد ندهید):
${existingLabels || 'هیچ'}
یادداشت‌های دفترچه:
${notesSummary}
وظیفه:
یادداشت‌ها را تحلیل کنید و تم‌های تکرارشونده (کلمات کلیدی، موضوعات، مکان‌ها، افراد) را شناسایی کنید.
یک تم باید حداقل در ۵ یادداشت مختلف ظاهر شود تا پیشنهاد داده شود.
فرمت پاسخ (JSON):
{
"labels": [
{
"nom": "نام_برچسب",
"note_indices": [0, 5, 12, 23, 45],
"confiance": 0.85
}
]
}
قوانین:
- نام برچسب باید کوتاه باشد (حداکثر ۱-۲ کلمه)
- یک تم باید در ۵+ یادداشت ظاهر شود تا پیشنهاد داده شود
- اطمینان باید > 0.60 باشد
- برچسب‌هایی که قبلاً وجود دارند را پیشنهاد ندهید
- اولویت با مکان‌ها، افراد، دسته‌بندی‌های واضح است
- حداکثر ۵ پیشنهاد
مثال‌های برچسب خوب:
- "توکیو"، "کیوتو"، "اوزاکا" (مکان‌ها)
- "هتل‌ها"، "رستوران‌ها"، "پروازها" (دسته‌بندی‌ها)
- "مریم"، "علی"، "تیم" (افراد)
پاسخ شما (فقط JSON):
`.trim(),
es: `
Eres un asistente que detecta temas recurrentes en notas para sugerir nuevas etiquetas.
CUADERNO ANALIZADO:
${notes.length} notas
ETIQUETAS EXISTENTES (no sugerir estas):
${existingLabels || 'Ninguna'}
NOTAS DEL CUADERNO:
${notesSummary}
TAREA:
Analiza las notas y detecta temas recurrentes (palabras clave, temas, lugares, personas).
Un tema debe aparecer en al menos 5 notas diferentes para ser sugerido.
FORMATO DE RESPUESTA (JSON):
{
"labels": [
{
"nom": "nombre_etiqueta",
"note_indices": [0, 5, 12, 23, 45],
"confiance": 0.85
}
]
}
REGLAS:
- El nombre de la etiqueta debe ser corto (máx 1-2 palabras)
- Un tema debe aparecer en 5+ notas para ser sugerido
- La confianza debe ser > 0.60
- No sugieras etiquetas que ya existen
- Prioriza lugares, personas, categorías claras
- Máximo 5 sugerencias
Ejemplos de buenas etiquetas:
- "tokio", "kyoto", "osaka" (lugares)
- "hoteles", "restaurantes", "vuelos" (categorías)
- "maría", "juan", "equipo" (personas)
Tu respuesta (solo JSON):
`.trim(),
de: `
Du bist ein Assistent, der wiederkehrende Themen in Notizen erkennt, um neue Labels vorzuschlagen.
ANALYSIERTES NOTIZBUCH:
${notes.length} Notizen
VORHANDENE LABELS (schlage diese nicht vor):
${existingLabels || 'Keine'}
NOTIZBUCH-NOTIZEN:
${notesSummary}
AUFGABE:
Analysiere die Notizen und erkenne wiederkehrende Themen (Schlüsselwörter, Themen, Orte, Personen).
Ein Thema muss in mindestens 5 verschiedenen Notizen erscheinen, um vorgeschlagen zu werden.
ANTWORTFORMAT (JSON):
{
"labels": [
{
"nom": "label_name",
"note_indices": [0, 5, 12, 23, 45],
"confiance": 0.85
}
]
}
REGELN:
- Der Labelname muss kurz sein (max 1-2 Wörter)
- Ein Thema muss in 5+ Notizen erscheinen, um vorgeschlagen zu werden
- Konfidenz muss > 0.60 sein
- Schlage keine Labels vor, die bereits existieren
- Priorisiere Orte, Personen, klare Kategorien
- Maximal 5 Vorschläge
Beispiele für gute Labels:
- "tokio", "kyoto", "osaka" (Orte)
- "hotels", "restaurants", "flüge" (Kategorien)
- "maria", "johannes", "team" (Personen)
Deine Antwort (nur JSON):
`.trim()
}
return instructions[language] || instructions['en'] || instructions['fr']
}
/**
* Parse AI response into suggested labels
*/
private parseAIResponse(response: string, notes: any[]): { suggestedLabels: SuggestedLabel[] } | null {
try {
const jsonMatch = response.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
throw new Error('No JSON found in response')
}
const aiData = JSON.parse(jsonMatch[0])
const suggestedLabels: SuggestedLabel[] = (aiData.labels || [])
.map((label: any) => {
// Filter by confidence threshold
if (label.confiance <= 0.60) return null
// Get note IDs from indices
const noteIds = label.note_indices
.map((idx: number) => notes[idx]?.id)
.filter(Boolean)
// Must have at least 5 notes
if (noteIds.length < 5) return null
return {
name: label.nom,
count: noteIds.length,
confidence: label.confiance,
noteIds,
}
})
.filter(Boolean)
if (suggestedLabels.length === 0) {
return null
}
// Sort by count (descending) and confidence
suggestedLabels.sort((a, b) => {
if (b.count !== a.count) {
return b.count - a.count // More notes first
}
return b.confidence - a.confidence // Then higher confidence
})
// Limit to top 5
return {
suggestedLabels: suggestedLabels.slice(0, 5),
}
} catch (error) {
console.error('Failed to parse AI response:', error)
return null
}
}
/**
* Create suggested labels and assign them to notes
* @param notebookId - Notebook ID
* @param userId - User ID
* @param suggestions - Suggested labels to create
* @param selectedLabels - Labels user selected to create
* @returns Number of labels created
*/
async createLabels(
notebookId: string,
userId: string,
suggestions: AutoLabelSuggestion,
selectedLabels: string[]
): Promise<number> {
let createdCount = 0
for (const suggestedLabel of suggestions.suggestedLabels) {
if (!selectedLabels.includes(suggestedLabel.name)) continue
// Create the label
const label = await prisma.label.create({
data: {
name: suggestedLabel.name,
color: 'gray', // Default color, user can change later
notebookId,
userId,
},
})
// Assign label to all suggested notes (updateMany doesn't support relations)
for (const noteId of suggestedLabel.noteIds) {
await prisma.note.update({
where: { id: noteId },
data: {
labelRelations: {
connect: {
id: label.id,
},
},
},
})
}
createdCount++
}
return createdCount
}
}
// Export singleton instance
export const autoLabelCreationService = new AutoLabelCreationService()

View File

@@ -0,0 +1,986 @@
import { prisma } from '@/lib/prisma'
import { getAIProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
export interface NoteForOrganization {
id: string
title: string | null
content: string
}
export interface NotebookOrganization {
notebookId: string
notebookName: string
notebookIcon: string | null
notebookColor: string | null
notes: Array<{
noteId: string
title: string | null
content: string
confidence: number
reason: string
}>
}
export interface OrganizationPlan {
notebooks: NotebookOrganization[]
totalNotes: number
unorganizedNotes: number // Notes that couldn't be categorized
}
/**
* Service for batch organizing notes from "Notes générales" into notebooks
* (Story 5.3 - IA3)
*/
export class BatchOrganizationService {
/**
* Analyze all notes in "Notes générales" and create an organization plan
* @param userId - User ID
* @param language - User's preferred language (default: 'en')
* @returns Organization plan with notebook assignments
*/
async createOrganizationPlan(userId: string, language: string = 'en'): Promise<OrganizationPlan> {
// 1. Get all notes without notebook (Inbox/Notes générales)
const notesWithoutNotebook = await prisma.note.findMany({
where: {
userId,
notebookId: null,
},
select: {
id: true,
title: true,
content: true,
},
orderBy: {
updatedAt: 'desc',
},
take: 50, // Limit to 50 notes for AI processing
})
if (notesWithoutNotebook.length === 0) {
return {
notebooks: [],
totalNotes: 0,
unorganizedNotes: 0,
}
}
// 2. Get all user's notebooks
const notebooks = await prisma.notebook.findMany({
where: { userId },
include: {
labels: true,
_count: {
select: { notes: true },
},
},
orderBy: { order: 'asc' },
})
if (notebooks.length === 0) {
// No notebooks to organize into
return {
notebooks: [],
totalNotes: notesWithoutNotebook.length,
unorganizedNotes: notesWithoutNotebook.length,
}
}
// 3. Call AI to create organization plan
const plan = await this.aiOrganizeNotes(notesWithoutNotebook, notebooks, language)
return plan
}
/**
* Use AI to analyze notes and create organization plan
*/
private async aiOrganizeNotes(
notes: NoteForOrganization[],
notebooks: any[],
language: string
): Promise<OrganizationPlan> {
const prompt = this.buildPrompt(notes, notebooks, language)
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
const response = await provider.generateText(prompt)
// Parse AI response
const plan = this.parseAIResponse(response, notes, notebooks)
return plan
} catch (error) {
console.error('Failed to create organization plan:', error)
// Return empty plan on error
return {
notebooks: [],
totalNotes: notes.length,
unorganizedNotes: notes.length,
}
}
}
/**
* Build prompt for AI (localized)
*/
private buildPrompt(notes: NoteForOrganization[], notebooks: any[], language: string = 'en'): string {
const notebookList = notebooks
.map(nb => {
const labels = nb.labels.map((l: any) => l.name).join(', ')
const count = nb._count?.notes || 0
return `- ${nb.name} (${count} notes)${labels ? ` [labels: ${labels}]` : ''}`
})
.join('\n')
const notesList = notes
.map((note, index) => {
const title = note.title || 'Sans titre'
const content = note.content.substring(0, 200)
return `[${index}] "${title}": ${content}`
})
.join('\n')
// System instructions based on language
const instructions: Record<string, string> = {
fr: `
Tu es un assistant qui organise des notes en les regroupant par thématique dans des carnets.
CARNETS DISPONIBLES :
${notebookList}
NOTES À ORGANISER (Notes générales) :
${notesList}
TÂCHE :
Analyse chaque note et propose le carnet le PLUS approprié.
Considère :
1. Le sujet/thème de la note (LE PLUS IMPORTANT)
2. Les labels existants dans chaque carnet
3. La cohérence thématique entre notes du même carnet
GUIDES DE CLASSIFICATION :
- SPORT/EXERCICE/ACHATS/COURSSES → Carnet Personnel
- LOISIRS/PASSIONS/SORTIES → Carnet Personnel
- SANTÉ/FITNESS/MÉDECIN → Carnet Personnel ou Santé
- FAMILLE/AMIS → Carnet Personnel
- TRAVAIL/RÉUNIONS/PROJETS/CLIENTS → Carnet Travail
- CODING/TECH/DÉVELOPPEMENT → Carnet Travail ou Code
- FINANCES/FACTURES/BANQUE → Carnet Personnel ou Finances
FORMAT DE RÉPONSE (JSON) :
Pour chaque carnet, liste les notes qui lui appartiennent :
{
"carnets": [
{
"nom": "Nom du carnet",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Courte explication en français"
}
]
}
]
}
RÈGLES :
- Seules les notes avec confiance > 0.60 doivent être assignées
- Si une note est trop générique, ne l'assigne pas
- Sois précis dans tes regroupements thématiques
- Ta réponse doit être uniquement un JSON valide
`.trim(),
en: `
You are an assistant that organizes notes by grouping them into notebooks based on their theme.
AVAILABLE NOTEBOOKS:
${notebookList}
NOTES TO ORGANIZE (Inbox):
${notesList}
TASK:
Analyze each note and suggest the MOST appropriate notebook.
Consider:
1. The subject/theme of the note (MOST IMPORTANT)
2. Existing labels in each notebook
3. Thematic consistency between notes in the same notebook
CLASSIFICATION GUIDE:
- SPORT/EXERCISE/SHOPPING/GROCERIES → Personal Notebook
- HOBBIES/PASSIONS/OUTINGS → Personal Notebook
- HEALTH/FITNESS/DOCTOR → Personal Notebook or Health
- FAMILY/FRIENDS → Personal Notebook
- WORK/MEETINGS/PROJECTS/CLIENTS → Work Notebook
- CODING/TECH/DEVELOPMENT → Work Notebook or Code
- FINANCE/BILLS/BANKING → Personal Notebook or Finance
RESPONSE FORMAT (JSON):
For each notebook, list the notes that belong to it:
{
"carnets": [
{
"nom": "Notebook Name",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Short explanation in English"
}
]
}
]
}
RULES:
- Only assign notes with confidence > 0.60
- If a note is too generic, do not assign it
- Be precise in your thematic groupings
- Your response must be valid JSON only
`.trim(),
es: `
Eres un asistente que organiza notas agrupándolas por temática en cuadernos.
CUADERNOS DISPONIBLES:
${notebookList}
NOTAS A ORGANIZAR (Bandeja de entrada):
${notesList}
TAREA:
Analiza cada nota y sugiere el cuaderno MÁS apropiado.
Considera:
1. El tema/asunto de la nota (LO MÁS IMPORTANTE)
2. Etiquetas existentes en cada cuaderno
3. Coherencia temática entre notas del mismo cuaderno
GUÍA DE CLASIFICACIÓN:
- DEPORTE/EJERCICIO/COMPRAS → Cuaderno Personal
- HOBBIES/PASIONES/SALIDAS → Cuaderno Personal
- SALUD/FITNESS/DOCTOR → Cuaderno Personal o Salud
- FAMILIA/AMIGOS → Cuaderno Personal
- TRABAJO/REUNIONES/PROYECTOS → Cuaderno Trabajo
- CODING/TECH/DESARROLLO → Cuaderno Trabajo o Código
- FINANZAS/FACTURAS/BANCO → Cuaderno Personal o Finanzas
FORMATO DE RESPUESTA (JSON):
Para cada cuaderno, lista las notas que le pertenecen:
{
"carnets": [
{
"nom": "Nombre del cuaderno",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Breve explicación en español"
}
]
}
]
}
REGLAS:
- Solo asigna notas con confianza > 0.60
- Si una nota es demasiado genérica, no la asignes
- Sé preciso en tus agrupaciones temáticas
- Tu respuesta debe ser únicamente un JSON válido
`.trim(),
de: `
Du bist ein Assistent, der Notizen organisiert, indem er sie thematisch in Notizbücher gruppiert.
VERFÜGBARE NOTIZBÜCHER:
${notebookList}
ZU ORGANISIERENDE NOTIZEN (Eingang):
${notesList}
AUFGABE:
Analysiere jede Notiz und schlage das AM BESTEN geeignete Notizbuch vor.
Berücksichtige:
1. Das Thema/den Inhalt der Notiz (AM WICHTIGSTEN)
2. Vorhandene Labels in jedem Notizbuch
3. Thematische Konsistenz zwischen Notizen im selben Notizbuch
KLASSIFIZIERUNGSLEITFADEN:
- SPORT/ÜBUNG/EINKAUFEN → Persönliches Notizbuch
- HOBBYS/LEIDENSCHAFTEN → Persönliches Notizbuch
- GESUNDHEIT/FITNESS/ARZT → Persönliches Notizbuch oder Gesundheit
- FAMILIE/FREUNDE → Persönliches Notizbuch
- ARBEIT/MEETINGS/PROJEKTE → Arbeitsnotizbuch
- CODING/TECH/ENTWICKLUNG → Arbeitsnotizbuch oder Code
- FINANZEN/RECHNUNGEN/BANK → Persönliches Notizbuch oder Finanzen
ANTWORTFORMAT (JSON):
Für jedes Notizbuch, liste die zugehörigen Notizen auf:
{
"carnets": [
{
"nom": "Name des Notizbuchs",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Kurze Erklärung auf Deutsch"
}
]
}
]
}
REGELN:
- Ordne nur Notizen mit Konfidenz > 0.60
- Wenn eine Notiz zu allgemein ist, ordne sie nicht zu
- Sei präzise in deinen thematischen Gruppierungen
- Deine Antwort muss ein gültiges JSON sein
`.trim(),
it: `
Sei un assistente che organizza le note raggruppandole per tema nei taccuini.
TACCUINI DISPONIBILI:
${notebookList}
NOTE DA ORGANIZZARE (In arrivo):
${notesList}
COMPITO:
Analizza ogni nota e suggerisci il taccuino PIÙ appropriato.
Considera:
1. L'argomento/tema della nota (PIÙ IMPORTANTE)
2. Etichette esistenti in ogni taccuino
3. Coerenza tematica tra le note dello stesso taccuino
GUIDA ALLA CLASSIFICAZIONE:
- SPORT/ESERCIZIO/SHOPPING → Taccuino Personale
- HOBBY/PASSIONI/USCITE → Taccuino Personale
- SALUTE/FITNESS/DOTTORE → Taccuino Personale o Salute
- FAMIGLIA/AMIGOS → Taccuino Personale
- LAVORO/RIUNIONI/PROGETTI → Taccuino Lavoro
- CODING/TECH/SVILUPPO → Taccuino Lavoro o Codice
- FINANZA/BILLS/BANCA → Taccuino Personale o Finanza
FORMATO RISPOSTA (JSON):
Per ogni taccuino, elenca le note che appartengono ad esso:
{
"carnets": [
{
"nom": "Nome del taccuino",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Breve spiegazione in italiano"
}
]
}
]
}
REGOLE:
- Assegna solo note con confidenza > 0.60
- Se una nota è troppo generica, non assegnarla
- Sii preciso nei tuoi raggruppamenti tematici
- La tua risposta deve essere solo un JSON valido
`.trim(),
pt: `
Você é um assistente que organiza notas agrupando-as por tema em cadernos.
CADERNOS DISPONÍVEIS:
${notebookList}
NOTAS A ORGANIZAR (Caixa de entrada):
${notesList}
TAREFA:
Analise cada nota e sugira o caderno MAIS apropriado.
Considere:
1. O assunto/tema da nota (MAIS IMPORTANTE)
2. Etiquetas existentes em cada caderno
3. Coerência temática entre notas do mesmo caderno
GUIA DE CLASSIFICAÇÃO:
- ESPORTE/EXERCÍCIO/COMPRAS → Caderno Pessoal
- HOBBIES/PAIXÕES/SAÍDAS → Caderno Pessoal
- SAÚDE/FITNESS/MÉDICO → Caderno Pessoal ou Saúde
- FAMÍLIA/AMIGOS → Caderno Pessoal
- TRABALHO/REUNIÕES/PROJETOS → Caderno Trabalho
- CODING/TECH/DESENVOLVIMENTO → Caderno Trabalho ou Código
- FINANÇAS/CONTAS/BANCO → Caderno Pessoal ou Finanças
FORMATO DE RESPOSTA (JSON):
Para cada caderno, liste as notas que pertencem a ele:
{
"carnets": [
{
"nom": "Nome do caderno",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Breve explicação em português"
}
]
}
]
}
REGRAS:
- Apenas atribua notas com confiança > 0.60
- Se uma nota for muito genérica, não a atribua
- Seja preciso em seus agrupamentos temáticos
- Sua resposta deve ser apenas um JSON válido
`.trim(),
nl: `
Je bent een assistent die notities organiseert door ze thematisch in notitieboekjes te groeperen.
BESCHIKBARE NOTITIEBOEKJES:
${notebookList}
TE ORGANISEREN NOTITIES (Inbox):
${notesList}
TAAK:
Analyseer elke notitie en stel het MEEST geschikte notitieboek voor.
Overweeg:
1. Het onderwerp/thema van de notitie (BELANGRIJKST)
2. Bestaande labels in elk notitieboekje
3. Thematische consistentie tussen notities in hetzelfde notitieboekje
CLASSIFICATIEGIDS:
- SPORT/OEFENING/WINKELEN → Persoonlijk Notitieboek
- HOBBIES/PASSIES/UITJES → Persoonlijk Notitieboek
- GEZONDHEID/FITNESS/DOKTER → Persoonlijk Notitieboek of Gezondheid
- FAMILIE/VRIENDEN → Persoonlijk Notitieboek
- WERK/VERGADERINGEN/PROJECTEN → Werk Notitieboek
- CODING/TECH/ONTWIKKELING → Werk Notitieboek of Code
- FINANCIËN/REKENINGEN/BANK → Persoonlijk Notitieboek of Financiën
ANTWOORDFORMAAT (JSON):
Voor elk notitieboekje, lijst de notities op die erbij horen:
{
"carnets": [
{
"nom": "Naam van het notitieboekje",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Korte uitleg in het Nederlands"
}
]
}
]
}
REGELS:
- Wijs alleen notities toe met vertrouwen > 0.60
- Als een notitie te generiek is, wijs deze dan niet toe
- Wees nauwkeurig in je thematische groeperingen
- Je antwoord moet alleen een geldige JSON zijn
`.trim(),
pl: `
Jesteś asystentem, który organizuje notatki, grupując je tematycznie w notatnikach.
DOSTĘPNE NOTATNIKI:
${notebookList}
NOTATKI DO ZORGANIZOWANIA (Skrzynka odbiorcza):
${notesList}
ZADANIE:
Przeanalizuj każdą notatkę i zasugeruj NAJBARDZIEJ odpowiedni notatnik.
Rozważ:
1. Temat/treść notatki (NAJWAŻNIEJSZE)
2. Istniejące etykiety w każdym notatniku
3. Spójność tematyczna między notatkami w tym samym notatniku
PRZEWODNIK KLASYFIKACJI:
- SPORT/ĆWICZENIA/ZAKUPY → Notatnik Osobisty
- HOBBY/PASJE/WYJŚCIA → Notatnik Osobisty
- ZDROWIE/FITNESS/LEKARZ → Notatnik Osobisty lub Zdrowie
- RODZINA/PRZYJACIELE → Notatnik Osobisty
- PRACA/SPOTKANIA/PROJEKTY → Notatnik Praca
- KODOWANIE/TECH/ROZWÓJ → Notatnik Praca lub Kod
- FINANSE/RACHUNKI/BANK → Notatnik Osobisty lub Finanse
FORMAT ODPOWIEDZI (JSON):
Dla każdego notatnika wymień należące do niego notatki:
{
"carnets": [
{
"nom": "Nazwa notatnika",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Krótkie wyjaśnienie po polsku"
}
]
}
]
}
ZASADY:
- Przypisuj tylko notatki z pewnością > 0.60
- Jeśli notatka jest zbyt ogólna, nie przypisuj jej
- Bądź precyzyjny w swoich grupach tematycznych
- Twoja odpowiedź musi być tylko prawidłowym JSON
`.trim(),
ru: `
Вы помощник, который организует заметки, группируя их по темам в блокноты.
ДОСТУПНЫЕ БЛОКНОТЫ:
${notebookList}
ЗАМЕТКИ ДЛЯ ОРГАНИЗАЦИИ (Входящие):
${notesList}
ЗАДАЧА:
Проанализируйте каждую заметку и предложите САМЫЙ подходящий блокнот.
Учитывайте:
1. Тему/предмет заметки (САМОЕ ВАЖНОЕ)
2. Существующие метки в каждом блокноте
3. Тематическую согласованность между заметками в одном блокноте
РУКОВОДСТВО ПО КЛАССИФИКАЦИИ:
- СПОРТ/УПРАЖНЕНИЯ/ПОКУПКИ → Личный блокнот
- ХОББИ/УВЛЕЧЕНИЯ/ВЫХОДЫ → Личный блокнот
- ЗДОРОВЬЕ/ФИТНЕС/ВРАЧ → Личный блокнот или Здоровье
- СЕМЬЯ/ДРУЗЬЯ → Личный блокнот
- РАБОТА/СОВЕЩАНИЯ/ПРОЕКТЫ → Рабочий блокнот
- КОДИНГ/ТЕХНОЛОГИИ/РАЗРАБОТКА → Рабочий блокнот или Код
- ФИНАНСЫ/СЧЕТА/БАНК → Личный блокнот или Финансы
ФОРМАТ ОТВЕТА (JSON):
Для каждого блокнота перечислите заметки, которые к нему относятся:
{
"carnets": [
{
"nom": "Название блокнота",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "Краткое объяснение на русском"
}
]
}
]
}
ПРАВИЛА:
- Назначайте только заметки с уверенностью > 0.60
- Если заметка слишком общая, не назначайте ее
- Будьте точны в своих тематических группировках
- Ваш ответ должен быть только валидным JSON
`.trim(),
ja: `
あなたは、テーマごとにノートを分類してノートブックに整理するアシスタントです。
利用可能なノートブック:
${notebookList}
整理するノート (受信トレイ):
${notesList}
タスク:
各ノートを分析し、最も適切なノートブックを提案してください。
考慮事項:
1. ノートの主題/テーマ (最も重要)
2. 各ノートブックの既存のラベル
3. 同じノートブック内のノート間のテーマの一貫性
分類ガイド:
- スポーツ/運動/買い物 → 個人用ノートブック
- 趣味/情熱/外出 → 個人用ノートブック
- 健康/フィットネス/医師 → 個人用ノートブックまたは健康
- 家族/友人 → 個人用ノートブック
- 仕事/会議/プロジェクト → 仕事用ノートブック
- コーディング/技術/開発 → 仕事用ノートブックまたはコード
- 金融/請求書/銀行 → 個人用ノートブックまたは金融
回答形式 (JSON):
各ノートブックについて、それに属するノートをリストアップしてください:
{
"carnets": [
{
"nom": "ノートブック名",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "日本語での短い説明"
}
]
}
]
}
ルール:
- 信頼度が 0.60 を超えるノートのみを割り当ててください
- ノートが一般的すぎる場合は、割り当てないでください
- テーマ別のグループ化において正確であってください
- 回答は有効な JSON のみにしてください
`.trim(),
ko: `
당신은 주제별로 노트를 분류하여 노트북으로 정리하는 도우미입니다.
사용 가능한 노트북:
${notebookList}
정리할 노트 (받은 편지함):
${notesList}
작업:
각 노트를 분석하고 가장 적절한 노트북을 제안하십시오.
고려 사항:
1. 노트의 주제/테마 (가장 중요)
2. 각 노트북의 기존 라벨
3. 동일한 노트북 내 노트 간의 주제별 일관성
분류 가이드:
- 스포츠/운동/쇼핑 → 개인 노트북
- 취미/열정/외출 → 개인 노트북
- 건강/피트니스/의사 → 개인 노트북 또는 건강
- 가족/친구 → 개인 노트북
- 업무/회의/프로젝트 → 업무 노트북
- 코딩/기술/개발 → 업무 노트북 또는 코드
- 금융/청구서/은행 → 개인 노트북 또는 금융
응답 형식 (JSON):
각 노트북에 대해 속한 노트를 나열하십시오:
{
"carnets": [
{
"nom": "노트북 이름",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "한국어로 된 짧은 설명"
}
]
}
]
}
규칙:
- 신뢰도가 0.60을 초과하는 노트만 할당하십시오
- 노트가 너무 일반적인 경우 할당하지 마십시오
- 주제별 그룹화에서 정확해야 합니다
- 응답은 유효한 JSON이어야 합니다
`.trim(),
zh: `
你是一个助手,负责通过按主题将笔记分组到笔记本中来整理笔记。
可用笔记本:
${notebookList}
待整理笔记(收件箱):
${notesList}
任务:
分析每个笔记并建议最合适的笔记本。
考虑:
1. 笔记的主题/题材(最重要)
2. 每个笔记本中的现有标签
3. 同一笔记本中笔记之间的主题一致性
分类指南:
- 运动/锻炼/购物 → 个人笔记本
- 爱好/激情/郊游 → 个人笔记本
- 健康/健身/医生 → 个人笔记本或健康
- 家庭/朋友 → 个人笔记本
- 工作/会议/项目 → 工作笔记本
- 编码/技术/开发 → 工作笔记本或代码
- 金融/账单/银行 → 个人笔记本或金融
响应格式 (JSON):
对于每个笔记本,列出属于它的笔记:
{
"carnets": [
{
"nom": "笔记本名称",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "中文简短说明"
}
]
}
]
}
规则:
- 仅分配置信度 > 0.60 的笔记
- 如果笔记太普通,请勿分配
- 在主题分组中要精确
- 您的响应必须仅为有效的 JSON
`.trim(),
ar: `
أنت مساعد يقوم بتنظيم الملاحظات عن طريق تجميعها حسب الموضوع في دفاتر ملاحظات.
دفاتر الملاحظات المتاحة:
${notebookList}
ملاحظات للتنظيم (صندوق الوارد):
${notesList}
المهمة:
حلل كل ملاحظة واقترح دفتر الملاحظات الأكثر ملاءمة.
اعتبار:
1. موضوع/مادة الملاحظة (الأهم)
2. التسميات الموجودة في كل دفتر ملاحظات
3. الاتساق الموضوعي بين الملاحظات في نفس دفتر الملاحظات
دليل التصنيف:
- الرياضة/التمرين/التسوق → دفتر ملاحظات شخصي
- الهوايات/الشغف/النزهات → دفتر ملاحظات شخصي
- الصحة/اللياقة البدنية/الطبيب → دفتر ملاحظات شخصي أو صحة
- العائلة/الأصدقاء → دفتر ملاحظات شخصي
- العمل/الاجتماعات/المشاريع → دفتر ملاحظات العمل
- البرمجة/التقنية/التطوير → دفتر ملاحظات العمل أو الكود
- المالية/الفواتير/البنك → دفتر ملاحظات شخصي أو مالية
تنسيق الاستجابة (JSON):
لكل دفتر ملاحظات، ضع قائمة بالملاحظات التي تنتمي إليه:
{
"carnets": [
{
"nom": "اسم دفتر الملاحظات",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "شرح قصير باللغة العربية"
}
]
}
]
}
القواعد:
- عيّن فقط الملاحظات ذات الثقة > 0.60
- إذا كانت الملاحظة عامة جدًا، فلا تقم بتعيينها
- كن دقيقًا في مجموعاتك الموضوعية
- يجب أن تكون إجابتك بتنسيق JSON صالح فقط
`.trim(),
hi: `
आप एक सहायक हैं जो नोटों को विषय के आधार पर नोटबुक में समूहित करके व्यवस्थित करते हैं।
उपलब्ध नोटबुक:
${notebookList}
व्यवस्थित करने के लिए नोट्स (इनबॉक्स):
${notesList}
कार्य:
प्रत्येक नोट का विश्लेषण करें और सबसे उपयुक्त नोटबुक का सुझाव दें।
विचार करें:
1. नोट का विषय/थीम (सबसे महत्वपूर्ण)
2. प्रत्येक नोटबुक में मौजूदा लेबल
3. एक ही नोटबुक में नोटों के बीच विषयगत स्थिरता
वर्गीकरण गाइड:
- खेल/व्यायाम/खरीदारी → व्यक्तिगत नोटबुक
- शौक/जुनून/बाहर जाना → व्यक्तिगत नोटबुक
- स्वास्थ्य/फिटनेस/डॉक्टर → व्यक्तिगत नोटबुक या स्वास्थ्य
- परिवार/मित्र → व्यक्तिगत नोटबुक
- कार्य/बैठकें/परियोजनाएं → कार्य नोटबुक
- कोडिंग/तकनीक/विकास → कार्य नोटबुक या कोड
- वित्त/बिल/बैंक → व्यक्तिगत नोटबुक या वित्त
प्रतिक्रिया प्रारूप (JSON):
प्रत्येक नोटबुक के लिए, उन नोटों को सूचीबद्ध करें जो उससे संबंधित हैं:
{
"carnets": [
{
"nom": "नोटबुक का नाम",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "हिंदी में संक्षिप्त स्पष्टीकरण"
}
]
}
]
}
नियम:
- केवल > 0.60 आत्मविश्वास वाले नोट्स असाइन करें
- यदि कोई नोट बहुत सामान्य है, तो उसे असाइन न करें
- अपने विषयगत समूहों में सटीक रहें
- आपकी प्रतिक्रिया केवल वैध JSON होनी चाहिए
`.trim(),
fa: `
شما دستیاری هستید که یادداشت‌ها را با گروه‌بندی موضوعی در دفترچه‌ها سازماندهی می‌کنید.
دفترچه‌های موجود:
${notebookList}
یادداشت‌های برای سازماندهی (صندوق ورودی):
${notesList}
وظیفه:
هر یادداشت را تحلیل کنید و مناسب‌ترین دفترچه را پیشنهاد دهید.
در نظر بگیرید:
1. موضوع/تم یادداشت (مهم‌ترین)
2. برچسب‌های موجود در هر دفترچه
3. سازگاری موضوعی بین یادداشت‌ها در همان دفترچه
راهنمای طبقه‌بندی:
- ورزش/تمرین/خرید → دفترچه شخصی
- سرگرمی‌ها/علایق/گردش → دفترچه شخصی
- سلامت/تناسب اندام/پزشک → دفترچه شخصی یا سلامت
- خانواده/دوستان → دفترچه شخصی
- کار/جلسات/پروژه‌ها → دفترچه کار
- کدنویسی/تکنولوژی/توسعه → دفترچه کار یا کد
- مالی/قبض‌ها/بانک → دفترچه شخصی یا مالی
فرمت پاسخ (JSON):
برای هر دفترچه، یادداشت‌هایی که به آن تعلق دارند را لیست کنید:
{
"carnets": [
{
"nom": "نام دفترچه",
"notes": [
{
"index": 0,
"confiance": 0.95,
"raison": "توضیح کوتاه به فارسی"
}
]
}
]
}
قوانین:
- فقط یادداشت‌های با اطمینان > 0.60 را اختصاص دهید
- اگر یادداشتی خیلی کلی است، آن را اختصاص ندهید
- در گروه‌بندی‌های موضوعی خود دقیق باشید
- پاسخ شما باید فقط یک JSON معتبر باشد
`.trim()
}
// Return instruction for requested language, fallback to English
return instructions[language] || instructions['en'] || instructions['fr']
}
/**
* Parse AI response into OrganizationPlan
*/
private parseAIResponse(
response: string,
notes: NoteForOrganization[],
notebooks: any[]
): OrganizationPlan {
try {
// Try to parse JSON response
const jsonMatch = response.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
throw new Error('No JSON found in response')
}
const aiData = JSON.parse(jsonMatch[0])
const notebookOrganizations: NotebookOrganization[] = []
// Process each notebook in AI response
for (const aiNotebook of aiData.carnets || []) {
const notebook = notebooks.find(nb => nb.name === aiNotebook.nom)
if (!notebook) continue
const noteAssignments = aiNotebook.notes
.filter((n: any) => n.confiance > 0.60) // Only high confidence
.map((n: any) => {
const note = notes[n.index]
if (!note) return null
return {
noteId: note.id,
title: note.title,
content: note.content,
confidence: n.confiance,
reason: n.raison || '',
}
})
.filter(Boolean)
if (noteAssignments.length > 0) {
notebookOrganizations.push({
notebookId: notebook.id,
notebookName: notebook.name,
notebookIcon: notebook.icon,
notebookColor: notebook.color,
notes: noteAssignments,
})
}
}
// Count unorganized notes
const organizedNoteIds = new Set(
notebookOrganizations.flatMap(nb => nb.notes.map(n => n.noteId))
)
const unorganizedCount = notes.length - organizedNoteIds.size
return {
notebooks: notebookOrganizations,
totalNotes: notes.length,
unorganizedNotes: unorganizedCount,
}
} catch (error) {
console.error('Failed to parse AI response:', error)
return {
notebooks: [],
totalNotes: notes.length,
unorganizedNotes: notes.length,
}
}
}
/**
* Apply the organization plan (move notes to notebooks)
* @param userId - User ID
* @param plan - Organization plan to apply
* @param selectedNoteIds - Specific note IDs to organize (user can deselect)
* @returns Number of notes moved
*/
async applyOrganizationPlan(
userId: string,
plan: OrganizationPlan,
selectedNoteIds: string[]
): Promise<number> {
let movedCount = 0
for (const notebookOrg of plan.notebooks) {
// Filter notes that are selected
const notesToMove = notebookOrg.notes.filter(n =>
selectedNoteIds.includes(n.noteId)
)
if (notesToMove.length === 0) continue
// Move notes to notebook
await prisma.note.updateMany({
where: {
id: { in: notesToMove.map(n => n.noteId) },
userId,
},
data: {
notebookId: notebookOrg.notebookId,
},
})
movedCount += notesToMove.length
}
return movedCount
}
}
// Export singleton instance
export const batchOrganizationService = new BatchOrganizationService()

View File

@@ -0,0 +1,592 @@
/**
* Contextual Auto-Tagging Service (IA2)
* Suggests labels from the current notebook's existing labels
* OR creates new label suggestions for empty notebooks
*/
import { prisma } from '@/lib/prisma'
import { getAIProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
export interface LabelSuggestion {
label: string
confidence: number // 0-100
reasoning?: string
isNewLabel?: boolean // true if this is a suggestion to CREATE a new label
}
export class ContextualAutoTagService {
/**
* Suggest labels for a note
* @param noteContent - Content of the note
* @param notebookId - ID of the notebook (to get available labels)
* @param userId - User ID
* @returns Array of label suggestions (max 3)
*/
async suggestLabels(
noteContent: string,
notebookId: string | null,
userId: string,
language: string = 'en'
): Promise<LabelSuggestion[]> {
// If no notebook, return empty (no context)
if (!notebookId) {
return []
}
// Get notebook with its labels
const notebook = await prisma.notebook.findFirst({
where: {
id: notebookId,
userId,
},
include: {
labels: {
orderBy: {
name: 'asc',
},
},
},
})
if (!notebook) {
return []
}
// CASE 1: Notebook has existing labels → suggest from them (IA2)
if (notebook.labels.length > 0) {
return await this.suggestFromExistingLabels(noteContent, notebook, language)
}
// CASE 2: Notebook has NO labels → suggest NEW labels to create
return await this.suggestNewLabels(noteContent, notebook, language)
}
/**
* Suggest labels from existing labels in the notebook (IA2)
*/
private async suggestFromExistingLabels(
noteContent: string,
notebook: any,
language: string
): Promise<LabelSuggestion[]> {
const availableLabels = notebook.labels.map((l: any) => l.name)
// Build prompt with available labels
const prompt = this.buildPrompt(noteContent, notebook.name, availableLabels, language)
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
// Use generateText with JSON response
const response = await provider.generateText(prompt)
// Improved JSON parsing with multiple fallback strategies
let parsed: any
// Strategy 1: Direct parse
try {
parsed = JSON.parse(response)
} catch (e) {
// Strategy 2: Extract JSON from markdown code blocks
const codeBlockMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```/)
if (codeBlockMatch) {
parsed = JSON.parse(codeBlockMatch[1])
} else {
// Strategy 3: Extract JSON object or array
const jsonArrayMatch = response.match(/\[[\s\S]*\]/)
const jsonObjectMatch = response.match(/\{[\s\S]*\}/)
if (jsonArrayMatch) {
let cleanedJson = jsonArrayMatch[0]
cleanedJson = cleanedJson.replace(/,\s*([}\]])/g, '$1')
cleanedJson = cleanedJson.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":')
parsed = JSON.parse(cleanedJson)
} else if (jsonObjectMatch) {
let cleanedJson = jsonObjectMatch[0]
cleanedJson = cleanedJson.replace(/,\s*([}\]])/g, '$1')
cleanedJson = cleanedJson.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":')
parsed = JSON.parse(cleanedJson)
} else {
console.error('❌ Could not extract JSON from response')
return []
}
}
}
// Handle both formats: array directly OR {suggestions: array}
let suggestionsArray = parsed
if (parsed.suggestions && Array.isArray(parsed.suggestions)) {
suggestionsArray = parsed.suggestions
} else if (Array.isArray(parsed)) {
suggestionsArray = parsed
} else {
console.error('❌ Invalid response structure:', parsed)
return []
}
// Filter and map suggestions
const suggestions = suggestionsArray
.filter((s: any) => {
// Must be in available labels
return availableLabels.includes(s.label) && s.confidence > 0.6
})
.map((s: any) => ({
label: s.label,
confidence: Math.round(s.confidence * 100),
reasoning: s.reasoning || '',
isNewLabel: false,
}))
.sort((a: any, b: any) => b.confidence - a.confidence)
.slice(0, 3) // Max 3 suggestions
return suggestions as LabelSuggestion[]
} catch (error) {
console.error('Failed to suggest labels:', error)
return []
}
}
/**
* Suggest NEW labels to create for empty notebooks (Hybrid IA2+IA4)
*/
private async suggestNewLabels(
noteContent: string,
notebook: any,
language: string
): Promise<LabelSuggestion[]> {
// Build prompt to suggest NEW labels based on content
const prompt = this.buildNewLabelsPrompt(noteContent, notebook.name, language)
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
// Use generateText with JSON response
const response = await provider.generateText(prompt)
// Improved JSON parsing with multiple fallback strategies
let parsed: any
// Strategy 1: Direct parse
try {
parsed = JSON.parse(response)
} catch (e) {
// Strategy 2: Extract JSON from markdown code blocks
const codeBlockMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```/)
if (codeBlockMatch) {
parsed = JSON.parse(codeBlockMatch[1])
} else {
// Strategy 3: Extract JSON object or array
const jsonArrayMatch = response.match(/\[[\s\S]*\]/)
const jsonObjectMatch = response.match(/\{[\s\S]*\}/)
if (jsonArrayMatch) {
let cleanedJson = jsonArrayMatch[0]
cleanedJson = cleanedJson.replace(/,\s*([}\]])/g, '$1')
cleanedJson = cleanedJson.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":')
parsed = JSON.parse(cleanedJson)
} else if (jsonObjectMatch) {
let cleanedJson = jsonObjectMatch[0]
cleanedJson = cleanedJson.replace(/,\s*([}\]])/g, '$1')
cleanedJson = cleanedJson.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":')
parsed = JSON.parse(cleanedJson)
} else {
console.error('❌ Could not extract JSON from response')
return []
}
}
}
// Handle both formats: array directly OR {suggestions: array}
let suggestionsArray = parsed
if (parsed.suggestions && Array.isArray(parsed.suggestions)) {
suggestionsArray = parsed.suggestions
} else if (Array.isArray(parsed)) {
suggestionsArray = parsed
} else {
console.error('❌ Invalid response structure:', parsed)
return []
}
// Filter and map suggestions
const suggestions = suggestionsArray
.filter((s: any) => {
return s.label && s.label.length > 0 && s.confidence > 0.6
})
.map((s: any) => ({
label: s.label,
confidence: Math.round(s.confidence * 100),
reasoning: s.reasoning || '',
isNewLabel: true, // Mark as new label suggestion
}))
.sort((a: any, b: any) => b.confidence - a.confidence)
.slice(0, 3) // Max 3 suggestions
return suggestions as LabelSuggestion[]
} catch (error) {
console.error('❌ Failed to suggest new labels:', error)
return []
}
}
/**
* Build the AI prompt for contextual label suggestion (localized)
*/
private buildPrompt(noteContent: string, notebookName: string, availableLabels: string[], language: string = 'en'): string {
const labelList = availableLabels.map(l => `- ${l}`).join('\n')
const instructions: Record<string, string> = {
fr: `
Tu es un assistant qui suggère les labels les plus appropriés pour une note.
CONTENU DE LA NOTE :
${noteContent.substring(0, 1000)}
NOTEBOOK ACTUEL :
${notebookName}
LABELS DISPONIBLES DANS CE NOTEBOOK :
${labelList}
TÂCHE :
Analyse le contenu de la note et suggère les labels les PLUS appropriés parmi les labels disponibles ci-dessus.
Considère :
1. La pertinence du label pour le contenu
2. Le nombre de labels (maximum 3 suggestions)
3. La confiance (seuil minimum : 0.6)
RÈGLES :
- Suggère SEULEMENT des labels qui sont dans la liste des labels disponibles
- Retourne au maximum 3 suggestions
- Chaque suggestion doit avoir une confiance > 0.6
- Si aucun label n'est pertinent, retourne un tableau vide
FORMAT DE RÉPONSE (JSON uniquement) :
{
"suggestions": [
{ "label": "nom_du_label", "confidence": 0.85, "reasoning": "Pourquoi ce label est pertinent" }
]
}
Ta réponse :
`.trim(),
en: `
You are an assistant that suggests the most appropriate labels for a note.
NOTE CONTENT:
${noteContent.substring(0, 1000)}
CURRENT NOTEBOOK:
${notebookName}
AVAILABLE LABELS IN THIS NOTEBOOK:
${labelList}
TASK:
Analyze the note content and suggest the MOST appropriate labels from the available labels above.
Consider:
1. Label relevance to content
2. Number of labels (maximum 3 suggestions)
3. Confidence (minimum threshold: 0.6)
RULES:
- Suggest ONLY labels that are in the available labels list
- Return maximum 3 suggestions
- Each suggestion must have confidence > 0.6
- If no label is relevant, return an empty array
RESPONSE FORMAT (JSON only):
{
"suggestions": [
{ "label": "label_name", "confidence": 0.85, "reasoning": "Why this label is relevant" }
]
}
Your response:
`.trim(),
fa: `
شما یک دستیار هستید که مناسب‌ترین برچسب‌ها را برای یک یادداشت پیشنهاد می‌دهید.
محتوای یادداشت:
${noteContent.substring(0, 1000)}
دفترچه فعلی:
${notebookName}
برچسب‌های موجود در این دفترچه:
${labelList}
وظیفه:
محتوای یادداشت را تحلیل کنید و مناسب‌ترین برچسب‌ها را از لیست برچسب‌های موجود در بالا پیشنهاد دهید.
در نظر بگیرید:
1. ارتباط برچسب با محتوا
2. تعداد برچسب‌ها (حداکثر ۳ پیشنهاد)
3. اطمینان (حداقل آستانه: 0.6)
قوانین:
- فقط برچسب‌هایی را پیشنهاد دهید که در لیست برچسب‌های موجود هستند
- حداکثر ۳ پیشنهاد برگردانید
- هر پیشنهاد باید دارای اطمینان > 0.6 باشد
- اگر هیچ برچسبی مرتبط نیست، یک اینرایه خالی برگردانید
فرمت پاسخ (فقط JSON):
{
"suggestions": [
{ "label": "نام_برچسب", "confidence": 0.85, "reasoning": "چرا این برچسب مرتبط است" }
]
}
پاسخ شما:
`.trim(),
es: `
Eres un asistente que sugiere las etiquetas más apropiadas para una nota.
CONTENIDO DE LA NOTA:
${noteContent.substring(0, 1000)}
CUADERNO ACTUAL:
${notebookName}
ETIQUETAS DISPONIBLES EN ESTE CUADERNO:
${labelList}
TAREA:
Analiza el contenido de la nota y sugiere las etiquetas MÁS apropiadas de las etiquetas disponibles arriba.
Considera:
1. Relevancia de la etiqueta para el contenido
2. Número de etiquetas (máximo 3 sugerencias)
3. Confianza (umbral mínimo: 0.6)
REGLAS:
- Sugiere SOLO etiquetas que estén en la lista de etiquetas disponibles
- Devuelve máximo 3 sugerencias
- Cada sugerencia debe tener confianza > 0.6
- Si ninguna etiqueta es relevante, devuelve un array vacío
FORMATO DE RESPUESTA (solo JSON):
{
"suggestions": [
{ "label": "nombre_etiqueta", "confidence": 0.85, "reasoning": "Por qué esta etiqueta es relevante" }
]
}
Tu respuesta:
`.trim(),
de: `
Du bist ein Assistent, der die passendsten Labels für eine Notiz vorschlägt.
NOTIZINHALT:
${noteContent.substring(0, 1000)}
AKTUELLES NOTIZBUCH:
${notebookName}
VERFÜGBARE LABELS IN DIESEM NOTIZBUCH:
${labelList}
AUFGABE:
Analysiere den Notizinhalt und schlage die AM BESTEN geeigneten Labels aus den oben verfügbaren Labels vor.
Berücksichtige:
1. Relevanz des Labels für den Inhalt
2. Anzahl der Labels (maximal 3 Vorschläge)
3. Konfidenz (Mindestschwellenwert: 0.6)
REGELN:
- Schlage NUR Labels vor, die in der Liste der verfügbaren Labels sind
- Gib maximal 3 Vorschläge zurück
- Jeder Vorschlag muss eine Konfidenz > 0.6 haben
- Wenn kein Label relevant ist, gib ein leeres Array zurück
ANTWORTFORMAT (nur JSON):
{
"suggestions": [
{ "label": "label_name", "confidence": 0.85, "reasoning": "Warum dieses Label relevant ist" }
]
}
Deine Antwort:
`.trim()
}
return instructions[language] || instructions['en'] || instructions['fr']
}
/**
* Build the AI prompt for NEW label suggestions (when notebook is empty) (localized)
*/
private buildNewLabelsPrompt(noteContent: string, notebookName: string, language: string = 'en'): string {
const instructions: Record<string, string> = {
fr: `
Tu es un assistant qui suggère de nouveaux labels pour organiser une note.
CONTENU DE LA NOTE :
${noteContent.substring(0, 1000)}
NOTEBOOK ACTUEL :
${notebookName}
CONTEXTE :
Ce notebook n'a pas encore de labels. Tu dois suggérer les PREMIERS labels appropriés pour cette note.
TÂCHE :
Analyse le contenu de la note et suggère 1-3 labels qui seraient pertinents pour organiser cette note.
Considère :
1. Les sujets ou thèmes abordés
2. Le type de contenu (idée, tâche, référence, etc.)
3. Le contexte du notebook "${notebookName}"
RÈGLES :
- Les labels doivent être COURTS (1-2 mots maximum)
- Les labels doivent être en minuscules
- Évite les accents si possible (ex: "idee" au lieu de "idée")
- Retourne au maximum 3 suggestions
- Chaque suggestion doit avoir une confiance > 0.6
IMPORTANT : Réponds UNIQUEMENT avec du JSON valide, sans texte avant ou après. Pas de markdown, pas de code blocks.
FORMAT DE RÉPONSE (JSON brut, sans markdown) :
{"suggestions":[{"label":"nom_du_label","confidence":0.85,"reasoning":"Pourquoi ce label est pertinent"}]}
Ta réponse (JSON brut uniquement) :
`.trim(),
en: `
You are an assistant that suggests new labels to organize a note.
NOTE CONTENT:
${noteContent.substring(0, 1000)}
CURRENT NOTEBOOK:
${notebookName}
CONTEXT:
This notebook has no labels yet. You must suggest the FIRST appropriate labels for this note.
TASK:
Analyze the note content and suggest 1-3 labels that would be relevant to organize this note.
Consider:
1. Topics or themes covered
2. Content type (idea, task, reference, etc.)
3. Context of the notebook "${notebookName}"
RULES:
- Labels must be SHORT (max 1-2 words)
- Labels must be lowercase
- Avoid accents if possible
- Return maximum 3 suggestions
- Each suggestion must have confidence > 0.6
IMPORTANT: Respond ONLY with valid JSON, without text before or after. No markdown, no code blocks.
RESPONSE FORMAT (raw JSON, no markdown):
{"suggestions":[{"label":"label_name","confidence":0.85,"reasoning":"Why this label is relevant"}]}
Your response (raw JSON only):
`.trim(),
fa: `
شما یک دستیار هستید که برچسب‌های جدیدی برای سازماندهی یک یادداشت پیشنهاد می‌دهید.
محتوای یادداشت:
${noteContent.substring(0, 1000)}
دفترچه فعلی:
${notebookName}
زمینه:
این دفترچه هنوز هیچ برچسبی ندارد. شما باید اولین برچسب‌های مناسب را برای این یادداشت پیشنهاد دهید.
وظیفه:
محتوای یادداشت را تحلیل کنید و ۱-۳ برچسب پیشنهاد دهید که برای سازماندهی این یادداشت مرتبط باشند.
در نظر بگیرید:
1. موضوعات یا تم‌های پوشش داده شده
2. نوع محتوا (ایده، وظیفه، مرجع و غیره)
3. زمینه دفترچه "${notebookName}"
قوانین:
- برچسب‌ها باید کوتاه باشند (حداکثر ۱-۲ کلمه)
- برچسب‌ها باید با حروف کوچک باشند
- حداکثر ۳ پیشنهاد برگردانید
- هر پیشنهاد باید دارای اطمینان > 0.6 باشد
مهم: فقط با یک JSON معتبر پاسخ دهید، بدون متن قبل یا بعد. بدون مارک‌داون، بدون بلوک کد.
فرمت پاسخ (JSON خام، بدون مارک‌داون):
{"suggestions":[{"label":"نام_برچسب","confidence":0.85,"reasoning":"چرا این برچسب مرتبط است"}]}
پاسخ شما (فقط JSON خام):
`.trim(),
es: `
Eres un asistente que sugiere nuevas etiquetas para organizar una nota.
CONTENIDO DE LA NOTA:
${noteContent.substring(0, 1000)}
CUADERNO ACTUAL:
${notebookName}
CONTEXTO:
Este cuaderno aún no tiene etiquetas. Debes sugerir las PRIMERAS etiquetas apropiadas para esta nota.
TAREA:
Analiza el contenido de la nota y sugiere 1-3 etiquetas que serían relevantes para organizar esta nota.
Considera:
1. Temas o tópicos cubiertos
2. Tipo de contenido (idea, tarea, referencia, etc.)
3. Contexto del cuaderno "${notebookName}"
REGLAS:
- Las etiquetas deben ser CORTAS (máx 1-2 palabras)
- Las etiquetas deben estar en minúsculas
- Evita acentos si es posible
- Devuelve máximo 3 sugerencias
- Cada sugerencia debe tener confianza > 0.6
IMPORTANTE: Responde SOLO con JSON válido, sin texto antes o después. Sin markdown, sin bloques de código.
FORMATO DE RESPUESTA (JSON crudo, sin markdown):
{"suggestions":[{"label":"nombre_etiqueta","confidence":0.85,"reasoning":"Por qué esta etiqueta es relevante"}]}
Tu respuesta (solo JSON crudo):
`.trim(),
de: `
Du bist ein Assistent, der neue Labels vorschlägt, um eine Notiz zu organisieren.
NOTIZINHALT:
${noteContent.substring(0, 1000)}
AKTUELLES NOTIZBUCH:
${notebookName}
KONTEXT:
Dieses Notizbuch hat noch keine Labels. Du musst die ERSTEN passenden Labels für diese Notiz vorschlagen.
AUFGABE:
Analysiere den Notizinhalt und schlage 1-3 Labels vor, die relevant wären, um diese Notiz zu organisieren.
Berücksichtige:
1. Abgedeckte Themen oder Bereiche
2. Inhaltstyp (Idee, Aufgabe, Referenz, usw.)
3. Kontext des Notizbuchs "${notebookName}"
REGELN:
- Labels müssen KURZ sein (max 1-2 Wörter)
- Labels müssen kleingeschrieben sein
- Vermeide Akzente wenn möglich
- Gib maximal 3 Vorschläge zurück
- Jeder Vorschlag muss eine Konfidenz > 0.6 haben
WICHTIG: Antworte NUR mit gültigem JSON, ohne Text davor oder danach. Kein Markdown, keine Code-Blöcke.
ANTWORTFORMAT (rohes JSON, kein Markdown):
{"suggestions":[{"label":"label_name","confidence":0.85,"reasoning":"Warum dieses Label relevant ist"}]}
Deine Antwort (nur rohes JSON):
`.trim()
}
return instructions[language] || instructions['en'] || instructions['fr']
}
}
// Export singleton instance
export const contextualAutoTagService = new ContextualAutoTagService()

View File

@@ -0,0 +1,227 @@
/**
* Embedding Service
* Generates vector embeddings for semantic search and similarity analysis
* Uses text-embedding-3-small model via OpenAI (or Ollama alternatives)
*/
import { getAIProvider } from '../factory'
import { getSystemConfig } from '@/lib/config'
export interface EmbeddingResult {
embedding: number[]
model: string
dimension: number
}
/**
* Service for generating and managing text embeddings
*/
export class EmbeddingService {
private readonly EMBEDDING_MODEL = 'text-embedding-3-small'
private readonly EMBEDDING_DIMENSION = 1536 // OpenAI's embedding dimension
/**
* Generate embedding for a single text
*/
async generateEmbedding(text: string): Promise<EmbeddingResult> {
if (!text || text.trim().length === 0) {
throw new Error('Cannot generate embedding for empty text')
}
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
// Use the existing getEmbeddings method from AIProvider
const embedding = await provider.getEmbeddings(text)
// Validate embedding dimension
if (embedding.length !== this.EMBEDDING_DIMENSION) {
}
return {
embedding,
model: this.EMBEDDING_MODEL,
dimension: embedding.length
}
} catch (error) {
console.error('Error generating embedding:', error)
throw new Error(`Failed to generate embedding: ${error}`)
}
}
/**
* Generate embeddings for multiple texts in batch
* More efficient than calling generateEmbedding multiple times
*/
async generateBatchEmbeddings(texts: string[]): Promise<EmbeddingResult[]> {
if (!texts || texts.length === 0) {
return []
}
// Filter out empty texts
const validTexts = texts.filter(t => t && t.trim().length > 0)
if (validTexts.length === 0) {
return []
}
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
// Batch embedding using the existing getEmbeddings method
const embeddings = await Promise.all(
validTexts.map(text => provider.getEmbeddings(text))
)
return embeddings.map(embedding => ({
embedding,
model: this.EMBEDDING_MODEL,
dimension: embedding.length
}))
} catch (error) {
console.error('Error generating batch embeddings:', error)
throw error
}
}
/**
* Calculate cosine similarity between two embeddings
* Returns value between -1 and 1, where 1 is identical
*/
calculateCosineSimilarity(embedding1: number[], embedding2: number[]): number {
if (embedding1.length !== embedding2.length) {
throw new Error('Embeddings must have the same dimension')
}
let dotProduct = 0
let magnitude1 = 0
let magnitude2 = 0
for (let i = 0; i < embedding1.length; i++) {
dotProduct += embedding1[i] * embedding2[i]
magnitude1 += embedding1[i] * embedding1[i]
magnitude2 += embedding2[i] * embedding2[i]
}
magnitude1 = Math.sqrt(magnitude1)
magnitude2 = Math.sqrt(magnitude2)
if (magnitude1 === 0 || magnitude2 === 0) {
return 0
}
return dotProduct / (magnitude1 * magnitude2)
}
/**
* Calculate similarity between an embedding and multiple other embeddings
* Returns array of similarities
*/
calculateSimilarities(
queryEmbedding: number[],
targetEmbeddings: number[][]
): number[] {
return targetEmbeddings.map(embedding =>
this.calculateCosineSimilarity(queryEmbedding, embedding)
)
}
/**
* Find most similar embeddings to a query
* Returns top-k results with their similarities
*/
findMostSimilar(
queryEmbedding: number[],
targetEmbeddings: Array<{ id: string; embedding: number[] }>,
topK: number = 10
): Array<{ id: string; similarity: number }> {
const similarities = targetEmbeddings.map(({ id, embedding }) => ({
id,
similarity: this.calculateCosineSimilarity(queryEmbedding, embedding)
}))
// Sort by similarity descending and return top-k
return similarities
.sort((a, b) => b.similarity - a.similarity)
.slice(0, topK)
}
/**
* Get average embedding from multiple embeddings
* Useful for clustering or centroid calculation
*/
averageEmbeddings(embeddings: number[][]): number[] {
if (embeddings.length === 0) {
throw new Error('Cannot average empty embeddings array')
}
const dimension = embeddings[0].length
const average = new Array(dimension).fill(0)
for (const embedding of embeddings) {
if (embedding.length !== dimension) {
throw new Error('All embeddings must have the same dimension')
}
for (let i = 0; i < dimension; i++) {
average[i] += embedding[i]
}
}
// Divide by number of embeddings
return average.map(val => val / embeddings.length)
}
/**
* Serialize embedding to JSON-safe format (for storage)
*/
serialize(embedding: number[]): string {
return JSON.stringify(embedding)
}
/**
* Deserialize embedding from JSON string
*/
deserialize(jsonString: string): number[] {
try {
const parsed = JSON.parse(jsonString)
if (!Array.isArray(parsed)) {
throw new Error('Invalid embedding format')
}
return parsed
} catch (error) {
console.error('Error deserializing embedding:', error)
throw new Error('Failed to deserialize embedding')
}
}
/**
* Check if a note needs embedding regeneration
* (e.g., if content has changed significantly)
*/
shouldRegenerateEmbedding(
noteContent: string,
lastEmbeddingContent: string | null,
lastAnalysis: Date | null
): boolean {
// If no previous embedding, generate one
if (!lastEmbeddingContent || !lastAnalysis) {
return true
}
// If content has changed more than 20% (simple heuristic)
const contentChanged =
Math.abs(noteContent.length - lastEmbeddingContent.length) / lastEmbeddingContent.length > 0.2
// If last analysis is more than 7 days old
const daysSinceAnalysis = (Date.now() - lastAnalysis.getTime()) / (1000 * 60 * 60 * 24)
const isStale = daysSinceAnalysis > 7
return contentChanged || isStale
}
}
// Singleton instance
export const embeddingService = new EmbeddingService()

View File

@@ -0,0 +1,70 @@
/**
* AI Services Index
* Central exports for all AI-powered services
*/
// Language Detection
export { LanguageDetectionService } from './language-detection.service'
// Title Suggestions
export {
TitleSuggestionService,
titleSuggestionService,
type TitleSuggestion
} from './title-suggestion.service'
// Embeddings
export {
EmbeddingService,
embeddingService,
type EmbeddingResult
} from './embedding.service'
// Semantic Search
export {
SemanticSearchService,
semanticSearchService,
type SearchResult,
type SearchOptions
} from './semantic-search.service'
// Paragraph Refactor
export {
ParagraphRefactorService,
paragraphRefactorService,
type RefactorMode,
type RefactorOption,
type RefactorResult,
REFACTOR_OPTIONS
} from './paragraph-refactor.service'
// Memory Echo
export {
MemoryEchoService,
memoryEchoService,
type MemoryEchoInsight
} from './memory-echo.service'
// Batch Organization
export {
BatchOrganizationService,
batchOrganizationService,
type NoteForOrganization,
type NotebookOrganization,
type OrganizationPlan
} from './batch-organization.service'
// Auto Label Creation
export {
AutoLabelCreationService,
autoLabelCreationService,
type SuggestedLabel,
type AutoLabelSuggestion
} from './auto-label-creation.service'
// Notebook Summary
export {
NotebookSummaryService,
notebookSummaryService,
type NotebookSummary
} from './notebook-summary.service'

View File

@@ -0,0 +1,133 @@
import { detect } from 'tinyld'
/**
* Language Detection Service
*
* Uses hybrid approach:
* - TinyLD for notes < 50 words (fast, ~8ms)
* - AI for notes ≥ 50 words (more accurate, ~200-500ms)
*
* Supports 62 languages including Persian (fa)
*/
export class LanguageDetectionService {
private readonly MIN_WORDS_FOR_AI = 50
private readonly MIN_CONFIDENCE = 0.7
/**
* Detect language of content using hybrid approach
*/
async detectLanguage(content: string): Promise<{
language: string // 'fr' | 'en' | 'es' | 'de' | 'fa' | 'unknown'
confidence: number // 0.0-1.0
method: 'tinyld' | 'ai' | 'unknown'
}> {
if (!content || content.trim().length === 0) {
return {
language: 'unknown',
confidence: 0.0,
method: 'unknown'
}
}
const wordCount = content.split(/\s+/).length
// Short notes: TinyLD (fast, TypeScript native)
if (wordCount < this.MIN_WORDS_FOR_AI) {
const result = detect(content)
return {
language: this.mapToISO(result),
confidence: 0.8,
method: 'tinyld'
}
}
// Long notes: AI for better accuracy
try {
const detected = await this.detectLanguageWithAI(content)
return {
language: detected,
confidence: 0.9,
method: 'ai'
}
} catch (error) {
console.error('Language detection error:', error)
// Fallback to TinyLD
const result = detect(content)
return {
language: this.mapToISO(result),
confidence: 0.6,
method: 'tinyld'
}
}
}
/**
* Detect language using AI provider
* (Fallback method for long content)
*/
private async detectLanguageWithAI(content: string): Promise<string> {
// For now, use TinyLD as AI detection is not yet implemented
// In Phase 2, we can add AI-based detection for better accuracy
const result = detect(content)
return this.mapToISO(result)
}
/**
* Map TinyLD language codes to ISO 639-1
*/
private mapToISO(code: string): string {
const mapping: Record<string, string> = {
'fra': 'fr',
'eng': 'en',
'spa': 'es',
'deu': 'de',
'fas': 'fa',
'pes': 'fa', // Persian (Farsi)
'por': 'pt',
'ita': 'it',
'rus': 'ru',
'zho': 'zh',
'jpn': 'ja',
'kor': 'ko',
'ara': 'ar',
'hin': 'hi',
'nld': 'nl',
'pol': 'pl',
'tur': 'tr',
'vie': 'vi',
'tha': 'th',
'ind': 'id'
}
// Direct mapping for ISO codes
if (code.length === 2 && /^[a-z]{2}$/.test(code)) {
return code
}
// Use mapping or fallback
return mapping[code] || code.substring(0, 2).toLowerCase()
}
/**
* Get supported languages count
*/
getSupportedLanguagesCount(): number {
return 62 // TinyLD supports 62 languages
}
/**
* Check if a language code is supported
*/
isLanguageSupported(languageCode: string): boolean {
// TinyLD supports 62 languages including Persian (fa)
const supportedCodes = [
'fr', 'en', 'es', 'de', 'fa', 'pt', 'it', 'ru', 'zh',
'ja', 'ko', 'ar', 'hi', 'nl', 'pl', 'tr', 'vi', 'th', 'id'
// ... and 43 more
]
return supportedCodes.includes(languageCode.toLowerCase())
}
}

View File

@@ -0,0 +1,528 @@
import { getAIProvider } from '../factory'
import { cosineSimilarity } from '@/lib/utils'
import prisma from '@/lib/prisma'
export interface NoteConnection {
note1: {
id: string
title: string | null
content: string
createdAt: Date
}
note2: {
id: string
title: string | null
content: string | null
createdAt: Date
}
similarityScore: number
insight: string
daysApart: number
}
export interface MemoryEchoInsight {
id: string
note1Id: string
note2Id: string
note1: {
id: string
title: string | null
content: string
}
note2: {
id: string
title: string | null
content: string
}
similarityScore: number
insight: string
insightDate: Date
viewed: boolean
feedback: string | null
}
/**
* Memory Echo Service - Proactive note connections
* "I didn't search, it found me"
*/
export class MemoryEchoService {
private readonly SIMILARITY_THRESHOLD = 0.75 // High threshold for quality connections
private readonly SIMILARITY_THRESHOLD_DEMO = 0.50 // Lower threshold for demo mode
private readonly MIN_DAYS_APART = 7 // Notes must be at least 7 days apart
private readonly MIN_DAYS_APART_DEMO = 0 // No delay for demo mode
private readonly MAX_INSIGHTS_PER_USER = 100 // Prevent spam
/**
* Find meaningful connections between user's notes
*/
async findConnections(userId: string, demoMode: boolean = false): Promise<NoteConnection[]> {
// Get all user's notes with embeddings
const notes = await prisma.note.findMany({
where: {
userId,
isArchived: false,
embedding: { not: null } // Only notes with embeddings
},
select: {
id: true,
title: true,
content: true,
embedding: true,
createdAt: true
},
orderBy: { createdAt: 'desc' }
})
if (notes.length < 2) {
return [] // Need at least 2 notes to find connections
}
// Parse embeddings
const notesWithEmbeddings = notes
.map(note => ({
...note,
embedding: note.embedding ? JSON.parse(note.embedding) : null
}))
.filter(note => note.embedding && Array.isArray(note.embedding))
const connections: NoteConnection[] = []
// Use demo mode parameters if enabled
const minDaysApart = demoMode ? this.MIN_DAYS_APART_DEMO : this.MIN_DAYS_APART
const similarityThreshold = demoMode ? this.SIMILARITY_THRESHOLD_DEMO : this.SIMILARITY_THRESHOLD
// Compare all pairs of notes
for (let i = 0; i < notesWithEmbeddings.length; i++) {
for (let j = i + 1; j < notesWithEmbeddings.length; j++) {
const note1 = notesWithEmbeddings[i]
const note2 = notesWithEmbeddings[j]
// Calculate time difference
const daysApart = Math.abs(
Math.floor((note1.createdAt.getTime() - note2.createdAt.getTime()) / (1000 * 60 * 60 * 24))
)
// Time diversity filter: notes must be from different time periods
if (daysApart < minDaysApart) {
continue
}
// Calculate cosine similarity
const similarity = cosineSimilarity(note1.embedding, note2.embedding)
// Similarity threshold for meaningful connections
if (similarity >= similarityThreshold) {
connections.push({
note1: {
id: note1.id,
title: note1.title,
content: note1.content.substring(0, 200) + (note1.content.length > 200 ? '...' : ''),
createdAt: note1.createdAt
},
note2: {
id: note2.id,
title: note2.title,
content: note2.content ? note2.content.substring(0, 200) + (note2.content.length > 200 ? '...' : '') : '',
createdAt: note2.createdAt
},
similarityScore: similarity,
insight: '', // Will be generated by AI
daysApart
})
}
}
}
// Sort by similarity score (descending)
connections.sort((a, b) => b.similarityScore - a.similarityScore)
// Return top connections
return connections.slice(0, 10)
}
/**
* Generate AI explanation for the connection
*/
async generateInsight(
note1Title: string | null,
note1Content: string,
note2Title: string | null,
note2Content: string
): Promise<string> {
try {
const config = await prisma.systemConfig.findFirst()
const provider = getAIProvider(config || undefined)
const note1Desc = note1Title || 'Untitled note'
const note2Desc = note2Title || 'Untitled note'
const prompt = `You are a helpful assistant analyzing connections between notes.
Note 1: "${note1Desc}"
Content: ${note1Content.substring(0, 300)}
Note 2: "${note2Desc}"
Content: ${note2Content.substring(0, 300)}
Explain in one brief sentence (max 15 words) why these notes are connected. Focus on the semantic relationship.`
const response = await provider.generateText(prompt)
// Clean up response
const insight = response
.replace(/^["']|["']$/g, '') // Remove quotes
.replace(/^[^.]+\.\s*/, '') // Remove "Here is..." prefix
.trim()
.substring(0, 150) // Max length
return insight || 'These notes appear to be semantically related.'
} catch (error) {
console.error('[MemoryEcho] Failed to generate insight:', error)
return 'These notes appear to be semantically related.'
}
}
/**
* Get next pending insight for user (based on frequency limit)
*/
async getNextInsight(userId: string): Promise<MemoryEchoInsight | null> {
// Check if Memory Echo is enabled for user
const settings = await prisma.userAISettings.findUnique({
where: { userId }
})
if (!settings || !settings.memoryEcho) {
return null // Memory Echo disabled
}
const demoMode = settings.demoMode || false
// Skip frequency checks in demo mode
if (!demoMode) {
// Check frequency limit
const today = new Date()
today.setHours(0, 0, 0, 0)
const insightsShownToday = await prisma.memoryEchoInsight.count({
where: {
userId,
insightDate: {
gte: today
}
}
})
// Frequency limits
const maxPerDay = settings.memoryEchoFrequency === 'daily' ? 1 :
settings.memoryEchoFrequency === 'weekly' ? 0 : // 1 per 7 days (handled below)
3 // custom = 3 per day
if (settings.memoryEchoFrequency === 'weekly') {
// Check if shown in last 7 days
const weekAgo = new Date(today)
weekAgo.setDate(weekAgo.getDate() - 7)
const recentInsight = await prisma.memoryEchoInsight.findFirst({
where: {
userId,
insightDate: {
gte: weekAgo
}
}
})
if (recentInsight) {
return null // Already shown this week
}
} else if (insightsShownToday >= maxPerDay) {
return null // Daily limit reached
}
// Check total insights limit (prevent spam)
const totalInsights = await prisma.memoryEchoInsight.count({
where: { userId }
})
if (totalInsights >= this.MAX_INSIGHTS_PER_USER) {
return null // User has too many insights
}
}
// Find new connections (pass demoMode)
const connections = await this.findConnections(userId, demoMode)
if (connections.length === 0) {
return null // No connections found
}
// Filter out already shown connections
const existingInsights = await prisma.memoryEchoInsight.findMany({
where: { userId },
select: { note1Id: true, note2Id: true }
})
const shownPairs = new Set(
existingInsights.map(i => `${i.note1Id}-${i.note2Id}`)
)
const newConnection = connections.find(c =>
!shownPairs.has(`${c.note1.id}-${c.note2.id}`) &&
!shownPairs.has(`${c.note2.id}-${c.note1.id}`)
)
if (!newConnection) {
return null // All connections already shown
}
// Generate AI insight
const insightText = await this.generateInsight(
newConnection.note1.title,
newConnection.note1.content,
newConnection.note2.title,
newConnection.note2.content || ''
)
// Store insight in database
const insight = await prisma.memoryEchoInsight.create({
data: {
userId,
note1Id: newConnection.note1.id,
note2Id: newConnection.note2.id,
similarityScore: newConnection.similarityScore,
insight: insightText,
insightDate: new Date(),
viewed: false
},
include: {
note1: {
select: {
id: true,
title: true,
content: true
}
},
note2: {
select: {
id: true,
title: true,
content: true
}
}
}
})
return insight
}
/**
* Mark insight as viewed
*/
async markAsViewed(insightId: string): Promise<void> {
await prisma.memoryEchoInsight.update({
where: { id: insightId },
data: { viewed: true }
})
}
/**
* Submit feedback for insight
*/
async submitFeedback(insightId: string, feedback: 'thumbs_up' | 'thumbs_down'): Promise<void> {
await prisma.memoryEchoInsight.update({
where: { id: insightId },
data: { feedback }
})
// Optional: Store in AiFeedback for analytics
const insight = await prisma.memoryEchoInsight.findUnique({
where: { id: insightId },
select: { userId: true, note1Id: true }
})
if (insight) {
await prisma.aiFeedback.create({
data: {
noteId: insight.note1Id,
userId: insight.userId,
feedbackType: feedback,
feature: 'memory_echo',
originalContent: JSON.stringify({ insightId }),
metadata: JSON.stringify({
timestamp: new Date().toISOString()
})
}
})
}
}
/**
* Get all connections for a specific note
*/
async getConnectionsForNote(noteId: string, userId: string): Promise<NoteConnection[]> {
// Get the note with embedding
const targetNote = await prisma.note.findUnique({
where: { id: noteId },
select: {
id: true,
title: true,
content: true,
embedding: true,
createdAt: true,
userId: true
}
})
if (!targetNote || targetNote.userId !== userId) {
return [] // Note not found or doesn't belong to user
}
if (!targetNote.embedding) {
return [] // Note has no embedding
}
// Get dismissed connections for this note (to filter them out)
const dismissedInsights = await prisma.memoryEchoInsight.findMany({
where: {
userId,
dismissed: true,
OR: [
{ note1Id: noteId },
{ note2Id: noteId }
]
},
select: {
note1Id: true,
note2Id: true
}
})
// Create a set of dismissed note pairs for quick lookup
const dismissedPairs = new Set(
dismissedInsights.map(i =>
`${i.note1Id}-${i.note2Id}`
)
)
// Get all other user's notes with embeddings
const otherNotes = await prisma.note.findMany({
where: {
userId,
id: { not: noteId }, // Exclude the target note
isArchived: false,
embedding: { not: null }
},
select: {
id: true,
title: true,
content: true,
embedding: true,
createdAt: true
},
orderBy: { createdAt: 'desc' }
})
if (otherNotes.length === 0) {
return []
}
// Parse target note embedding
const targetEmbedding = JSON.parse(targetNote.embedding)
// Check if user has demo mode enabled
const settings = await prisma.userAISettings.findUnique({
where: { userId }
})
const demoMode = settings?.demoMode || false
const minDaysApart = demoMode ? this.MIN_DAYS_APART_DEMO : this.MIN_DAYS_APART
const similarityThreshold = demoMode ? this.SIMILARITY_THRESHOLD_DEMO : this.SIMILARITY_THRESHOLD
const connections: NoteConnection[] = []
// Compare target note with all other notes
for (const otherNote of otherNotes) {
if (!otherNote.embedding) continue
const otherEmbedding = JSON.parse(otherNote.embedding)
// Check if this connection was dismissed
const pairKey1 = `${targetNote.id}-${otherNote.id}`
const pairKey2 = `${otherNote.id}-${targetNote.id}`
if (dismissedPairs.has(pairKey1) || dismissedPairs.has(pairKey2)) {
continue
}
// Calculate time difference
const daysApart = Math.abs(
Math.floor((targetNote.createdAt.getTime() - otherNote.createdAt.getTime()) / (1000 * 60 * 60 * 24))
)
// Time diversity filter
if (daysApart < minDaysApart) {
continue
}
// Calculate cosine similarity
const similarity = cosineSimilarity(targetEmbedding, otherEmbedding)
// Similarity threshold
if (similarity >= similarityThreshold) {
connections.push({
note1: {
id: targetNote.id,
title: targetNote.title,
content: targetNote.content.substring(0, 200) + (targetNote.content.length > 200 ? '...' : ''),
createdAt: targetNote.createdAt
},
note2: {
id: otherNote.id,
title: otherNote.title,
content: otherNote.content ? otherNote.content.substring(0, 200) + (otherNote.content.length > 200 ? '...' : '') : '',
createdAt: otherNote.createdAt
},
similarityScore: similarity,
insight: '', // Will be generated on demand
daysApart
})
}
}
// Sort by similarity score (descending)
connections.sort((a, b) => b.similarityScore - a.similarityScore)
return connections
}
/**
* Get insights history for user
*/
async getInsightsHistory(userId: string): Promise<MemoryEchoInsight[]> {
const insights = await prisma.memoryEchoInsight.findMany({
where: { userId },
include: {
note1: {
select: {
id: true,
title: true,
content: true
}
},
note2: {
select: {
id: true,
title: true,
content: true
}
}
},
orderBy: { insightDate: 'desc' },
take: 20
})
return insights
}
}
// Export singleton instance
export const memoryEchoService = new MemoryEchoService()

View File

@@ -0,0 +1,297 @@
import { prisma } from '@/lib/prisma'
import { getAIProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import type { Notebook } from '@/lib/types'
export class NotebookSuggestionService {
/**
* Suggest the most appropriate notebook for a note
* @param noteContent - Content of the note
* @param userId - User ID (for fetching user's notebooks)
* @returns Suggested notebook or null (if no good match)
*/
async suggestNotebook(noteContent: string, userId: string, language: string = 'en'): Promise<Notebook | null> {
// 1. Get all notebooks for this user
const notebooks = await prisma.notebook.findMany({
where: { userId },
include: {
labels: true,
_count: {
select: { notes: true },
},
},
orderBy: { order: 'asc' },
})
if (notebooks.length === 0) {
return null // No notebooks to suggest
}
// 2. Build prompt for AI (always in French - interface language)
const prompt = this.buildPrompt(noteContent, notebooks, language)
// 3. Call AI
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
const response = await provider.generateText(prompt)
const suggestedName = response.trim().toUpperCase()
// 5. Find matching notebook
const suggestedNotebook = notebooks.find(nb =>
nb.name.toUpperCase() === suggestedName
)
// If AI says "NONE" or no match, return null
if (suggestedName === 'NONE' || !suggestedNotebook) {
return null
}
return suggestedNotebook as Notebook
} catch (error) {
console.error('Failed to suggest notebook:', error)
return null
}
}
/**
* Build the AI prompt for notebook suggestion (localized)
*/
private buildPrompt(noteContent: string, notebooks: any[], language: string = 'en'): string {
const notebookList = notebooks
.map(nb => {
const labels = nb.labels.map((l: any) => l.name).join(', ')
const count = nb._count?.notes || 0
return `- ${nb.name} (${count} notes)${labels ? ` [labels: ${labels}]` : ''}`
})
.join('\n')
const instructions: Record<string, string> = {
fr: `
Tu es un assistant qui suggère à quel carnet une note devrait appartenir.
CONTENU DE LA NOTE :
${noteContent.substring(0, 500)}
CARNETS DISPONIBLES :
${notebookList}
TÂCHE :
Analyse le contenu de la note (peu importe la langue) et suggère le carnet le PLUS approprié pour cette note.
Considère :
1. Le sujet/thème de la note (LE PLUS IMPORTANT)
2. Les labels existants dans chaque carnet
3. Le nombre de notes (préfère les carnets avec du contenu connexe)
GUIDES DE CLASSIFICATION :
- SPORT/EXERCICE/ACHATS/COURSSES → Carnet Personnel
- LOISIRS/PASSIONS/SORTIES → Carnet Personnel
- SANTÉ/FITNESS/MÉDECIN → Carnet Personnel ou Santé
- FAMILLE/AMIS → Carnet Personnel
- TRAVAIL/RÉUNIONS/PROJETS/CLIENTS → Carnet Travail
- CODING/TECH/DÉVELOPPEMENT → Carnet Travail ou Code
- FINANCES/FACTURES/BANQUE → Carnet Personnel ou Finances
RÈGLES :
- Retourne SEULEMENT le nom du carnet, EXACTEMENT comme indiqué ci-dessus (insensible à la casse)
- Si aucune bonne correspondance n'existe, retourne "NONE"
- Si la note est trop générique/vague, retourne "NONE"
- N'inclus pas d'explications ou de texte supplémentaire
Exemples :
- "Réunion avec Jean sur le planning du projet" → carnet "Travail"
- "Liste de courses ou achat de vêtements" → carnet "Personnel"
- "Script Python pour analyse de données" → carnet "Code"
- "Séance de sport ou fitness" → carnet "Personnel"
- "Achat d'une chemise et d'un jean" → carnet "Personnel"
Ta suggestion :
`.trim(),
en: `
You are an assistant that suggests which notebook a note should belong to.
NOTE CONTENT:
${noteContent.substring(0, 500)}
AVAILABLE NOTEBOOKS:
${notebookList}
TASK:
Analyze the note content (regardless of language) and suggest the MOST appropriate notebook for this note.
Consider:
1. The subject/theme of the note (MOST IMPORTANT)
2. Existing labels in each notebook
3. The number of notes (prefer notebooks with related content)
CLASSIFICATION GUIDE:
- SPORT/EXERCISE/SHOPPING/GROCERIES → Personal Notebook
- HOBBIES/PASSIONS/OUTINGS → Personal Notebook
- HEALTH/FITNESS/DOCTOR → Personal Notebook or Health
- FAMILY/FRIENDS → Personal Notebook
- WORK/MEETINGS/PROJECTS/CLIENTS → Work Notebook
- CODING/TECH/DEVELOPMENT → Work Notebook or Code
- FINANCE/BILLS/BANKING → Personal Notebook or Finance
RULES:
- Return ONLY the notebook name, EXACTLY as listed above (case insensitive)
- If no good match exists, return "NONE"
- If the note is too generic/vague, return "NONE"
- Do not include explanations or extra text
Examples:
- "Meeting with John about project planning" → notebook "Work"
- "Grocery list or buying clothes" → notebook "Personal"
- "Python script for data analysis" → notebook "Code"
- "Gym session or fitness" → notebook "Personal"
Your suggestion:
`.trim(),
fa: `
شما یک دستیار هستید که پیشنهاد می‌دهد یک یادداشت به کدام دفترچه تعلق داشته باشد.
محتوای یادداشت:
${noteContent.substring(0, 500)}
دفترچه‌های موجود:
${notebookList}
وظیفه:
محتوای یادداشت را تحلیل کنید (صرف نظر از زبان) و مناسب‌ترین دفترچه را برای این یادداشت پیشنهاد دهید.
در نظر بگیرید:
1. موضوع/تم یادداشت (مهم‌ترین)
2. برچسب‌های موجود در هر دفترچه
3. تعداد یادداشت‌ها (دفترچه‌های با محتوای مرتبط را ترجیح دهید)
راهنمای طبقه‌بندی:
- ورزش/تمرین/خرید → دفترچه شخصی
- سرگرمی‌ها/علایق/گردش → دفترچه شخصی
- سلامت/تناسب اندام/پزشک → دفترچه شخصی یا سلامت
- خانواده/دوستان → دفترچه شخصی
- کار/جلسات/پروژه‌ها/مشتریان → دفترچه کار
- کدنویسی/تکنولوژی/توسعه → دفترچه کار یا کد
- مالی/قبض‌ها/بانک → دفترچه شخصی یا مالی
قوانین:
- فقط نام دفترچه را برگردانید، دقیقاً همانطور که در بالا ذکر شده است (بدون حساسیت به حروف بزرگ و کوچک)
- اگر تطابق خوبی وجود ندارد، "NONE" را برگردانید
- اگر یادداشت خیلی کلی/مبهم است، "NONE" را برگردانید
- توضیحات یا متن اضافی را شامل نکنید
پیشناد شما:
`.trim(),
es: `
Eres un asistente que sugiere a qué cuaderno debería pertenecer una nota.
CONTENIDO DE LA NOTA:
${noteContent.substring(0, 500)}
CUADERNOS DISPONIBLES:
${notebookList}
TAREA:
Analiza el contenido de la nota (independientemente del idioma) y sugiere el cuaderno MÁS apropiado para esta nota.
Considera:
1. El tema/asunto de la nota (LO MÁS IMPORTANTE)
2. Etiquetas existentes en cada cuaderno
3. El número de notas (prefiere cuadernos con contenido relacionado)
GUÍA DE CLASIFICACIÓN:
- DEPORTE/EJERCICIO/COMPRAS → Cuaderno Personal
- HOBBIES/PASIONES/SALIDAS → Cuaderno Personal
- SALUD/FITNESS/DOCTOR → Cuaderno Personal o Salud
- FAMILIA/AMIGOS → Cuaderno Personal
- TRABAJO/REUNIONES/PROYECTOS → Cuaderno Trabajo
- CODING/TECH/DESARROLLO → Cuaderno Trabajo o Código
- FINANZAS/FACTURAS/BANCO → Cuaderno Personal o Finanzas
REGLAS:
- Devuelve SOLO el nombre del cuaderno, EXACTAMENTE como se lista arriba (insensible a mayúsculas/minúsculas)
- Si no existe una buena coincidencia, devuelve "NONE"
- Si la nota es demasiado genérica/vaga, devuelve "NONE"
- No incluyas explicaciones o texto extra
Tu sugerencia:
`.trim(),
de: `
Du bist ein Assistent, der vorschlägt, zu welchem Notizbuch eine Notiz gehören sollte.
NOTIZINHALT:
${noteContent.substring(0, 500)}
VERFÜGBARE NOTIZBÜCHER:
${notebookList}
AUFGABE:
Analysiere den Notizinhalt (unabhängig von der Sprache) und schlage das AM BESTEN geeignete Notizbuch für diese Notiz vor.
Berücksichtige:
1. Das Thema/den Inhalt der Notiz (AM WICHTIGSTEN)
2. Vorhandene Labels in jedem Notizbuch
3. Die Anzahl der Notizen (bevorzuge Notizbücher mit verwandtem Inhalt)
KLASSIFIZIERUNGSLEITFADEN:
- SPORT/ÜBUNG/EINKAUFEN → Persönliches Notizbuch
- HOBBYS/LEIDENSCHAFTEN → Persönliches Notizbuch
- GESUNDHEIT/FITNESS/ARZT → Persönliches Notizbuch oder Gesundheit
- FAMILIE/FREUNDE → Persönliches Notizbuch
- ARBEIT/MEETINGS/PROJEKTE → Arbeitsnotizbuch
- CODING/TECH/ENTWICKLUNG → Arbeitsnotizbuch oder Code
- FINANZEN/RECHNUNGEN/BANK → Persönliches Notizbuch oder Finanzen
REGELN:
- Gib NUR den Namen des Notizbuchs zurück, GENAU wie oben aufgeführt (Groß-/Kleinschreibung egal)
- Wenn keine gute Übereinstimmung existiert, gib "NONE" zurück
- Wenn die Notiz zu allgemein/vage ist, gib "NONE" zurück
- Füge keine Erklärungen oder zusätzlichen Text hinzu
Dein Vorschlag:
`.trim()
}
return instructions[language] || instructions['en'] || instructions['fr']
}
/**
* Batch suggest notebooks for multiple notes (IA3)
* @param noteContents - Array of note contents
* @param userId - User ID
* @returns Map of note index -> suggested notebook
*/
async suggestNotebooksBatch(
noteContents: string[],
userId: string,
language: string = 'en'
): Promise<Map<number, Notebook | null>> {
const results = new Map<number, Notebook | null>()
// For efficiency, we could batch this into a single AI call
// For now, process sequentially (could be parallelized)
for (let i = 0; i < noteContents.length; i++) {
const suggestion = await this.suggestNotebook(noteContents[i], userId, language)
results.set(i, suggestion)
}
return results
}
/**
* Get notebook suggestion confidence score
* (For future UI enhancement: show confidence level)
*/
async suggestNotebookWithConfidence(
noteContent: string,
userId: string
): Promise<{ notebook: Notebook | null; confidence: number }> {
// This could use logprobs from OpenAI API to calculate confidence
// For now, return binary confidence
const notebook = await this.suggestNotebook(noteContent, userId)
return {
notebook,
confidence: notebook ? 0.8 : 0, // Fixed confidence for now
}
}
}
// Export singleton instance
export const notebookSuggestionService = new NotebookSuggestionService()

View File

@@ -0,0 +1,380 @@
import { prisma } from '@/lib/prisma'
import { getAIProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
export interface NotebookSummary {
notebookId: string
notebookName: string
notebookIcon: string | null
summary: string // Markdown formatted summary
stats: {
totalNotes: number
totalLabels: number
labelsUsed: string[]
}
generatedAt: Date
}
/**
* Service for generating AI-powered notebook summaries
* (Story 5.6 - IA6)
*/
export class NotebookSummaryService {
/**
* Generate a summary for a notebook
* @param notebookId - Notebook ID
* @param userId - User ID (for authorization)
* @returns Notebook summary or null
*/
async generateSummary(notebookId: string, userId: string, language: string = 'en'): Promise<NotebookSummary | null> {
// 1. Get notebook with notes and labels
const notebook = await prisma.notebook.findFirst({
where: {
id: notebookId,
userId,
},
include: {
labels: {
select: {
id: true,
name: true,
},
},
_count: {
select: { notes: true },
},
},
})
if (!notebook) {
throw new Error('Notebook not found')
}
// Get all notes in this notebook
const notes = await prisma.note.findMany({
where: {
notebookId,
userId,
},
select: {
id: true,
title: true,
content: true,
createdAt: true,
updatedAt: true,
labelRelations: {
select: {
name: true,
},
},
},
orderBy: {
updatedAt: 'desc',
},
take: 100, // Limit to 100 most recent notes for summary
})
if (notes.length === 0) {
return null
}
// 2. Generate summary using AI
const summary = await this.generateAISummary(notes, notebook, language)
// 3. Get labels used in this notebook
const labelsUsed = Array.from(
new Set(
notes.flatMap(note =>
note.labelRelations.map(l => l.name)
)
)
)
return {
notebookId: notebook.id,
notebookName: notebook.name,
notebookIcon: notebook.icon,
summary,
stats: {
totalNotes: notebook._count.notes,
totalLabels: notebook.labels.length,
labelsUsed,
},
generatedAt: new Date(),
}
}
/**
* Use AI to generate notebook summary
*/
private async generateAISummary(notes: any[], notebook: any, language: string): Promise<string> {
// Build notes summary for AI
const notesSummary = notes
.map((note, index) => {
const title = note.title || 'Sans titre'
const content = note.content.substring(0, 200) // Limit content length
const labels = note.labelRelations.map((l: any) => l.name).join(', ')
const date = new Date(note.createdAt).toLocaleDateString('fr-FR')
return `[${index + 1}] **${title}** (${date})
${labels ? `Labels: ${labels}` : ''}
${content}...`
})
.join('\n\n')
const prompt = this.buildPrompt(notesSummary, notebook.name, language)
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
const summary = await provider.generateText(prompt)
return summary.trim()
} catch (error) {
console.error('Failed to generate notebook summary:', error)
throw error
}
}
/**
* Build prompt for AI (localized)
*/
private buildPrompt(notesSummary: string, notebookName: string, language: string = 'en'): string {
const instructions: Record<string, string> = {
fr: `
Tu es un assistant qui génère des synthèses structurées de carnets de notes.
CARNET: ${notebookName}
NOTES DU CARNET:
${notesSummary}
TÂCHE:
Génère une synthèse structurée et organisée de ce carnet en analysant toutes les notes.
FORMAT DE LA RÉPONSE (Markdown avec emojis):
# 📊 Synthèse du Carnet ${notebookName}
## 🌍 Thèmes Principaux
• Identifie 3-5 thèmes récurrents ou sujets abordés
## 📝 Statistiques
• Nombre total de notes analysées
• Principales catégories de contenu
## 📅 Éléments Temporels
• Dates ou périodes importantes mentionnées
• Événements planifiés vs passés
## ⚠️ Points d'Attention / Actions Requises
• Tâches ou actions identifiées dans les notes
• Rappels ou échéances importantes
• Éléments nécessitant une attention particulière
## 💡 Insights Clés
• Résumé des informations les plus importantes
• Tendances ou patterns observés
• Connexions entre les différentes notes
RÈGLES:
- Utilise le format Markdown avec emojis comme dans l'exemple
- Sois concis et organise l'information de manière claire
- Identifie les vraies tendances, ne pas inventer d'informations
- Si une section n'est pas pertinente, utilise "N/A" ou omets-la
- Ton: professionnel mais accessible
- TA RÉPONSE DOIT ÊTRE EN FRANÇAIS
Ta réponse :
`.trim(),
en: `
You are an assistant that generates structured summaries of notebooks.
NOTEBOOK: ${notebookName}
NOTEBOOK NOTES:
${notesSummary}
TASK:
Generate a structured and organized summary of this notebook by analyzing all notes.
RESPONSE FORMAT (Markdown with emojis):
# 📊 Summary of Notebook ${notebookName}
## 🌍 Main Themes
• Identify 3-5 recurring themes or topics covered
## 📝 Statistics
• Total number of notes analyzed
• Main content categories
## 📅 Temporal Elements
• Important dates or periods mentioned
• Planned vs past events
## ⚠️ Action Items / Attention Points
• Tasks or actions identified in notes
• Important reminders or deadlines
• Items requiring special attention
## 💡 Key Insights
• Summary of most important information
• Observed trends or patterns
• Connections between different notes
RULES:
- Use Markdown format with emojis as in the example
- Be concise and organize information clearly
- Identify real trends, do not invent information
- If a section is not relevant, use "N/A" or omit it
- Tone: professional but accessible
- YOUR RESPONSE MUST BE IN ENGLISH
Your response:
`.trim(),
fa: `
شما یک دستیار هستید که خلاصه‌های ساختاریافته از دفترچه‌های یادداشت تولید می‌کنید.
دفترچه: ${notebookName}
یادداشت‌های دفترچه:
${notesSummary}
وظیفه:
یک خلاصه ساختاریافته و منظم از این دفترچه با تحلیل تمام یادداشت‌ها تولید کنید.
فرمت پاسخ (مارک‌داون با ایموجی):
# 📊 خلاصه دفترچه ${notebookName}
## 🌍 موضوعات اصلی
• ۳-۵ موضوع تکرارشونده یا مبحث پوشش داده شده را شناسایی کنید
## 📝 آمار
• تعداد کل یادداشت‌های تحلیل شده
• دسته‌بندی‌های اصلی محتوا
## 📅 عناصر زمانی
• تاریخ‌ها یا دوره‌های مهم ذکر شده
• رویدادهای برنامه‌ریزی شده در مقابل گذشته
## ⚠️ موارد اقدام / نقاط توجه
• وظایف یا اقدامات شناسایی شده در یادداشت‌ها
• یادآوری‌ها یا مهلت‌های مهم
• مواردی که نیاز به توجه ویژه دارند
## 💡 بینش‌های کلیدی
• خلاصه مهم‌ترین اطلاعات
• روندها یا الگوهای مشاهده شده
• ارتباطات بین یادداشت‌های مختلف
قوانین:
- از فرمت مارک‌داون با ایموجی مانند مثال استفاده کنید
- مختصر باشید و اطلاعات را به وضوح سازماندهی کنید
- روندهای واقعی را شناسایی کنید، اطلاعات اختراع نکنید
- اگر بخش مرتبط نیست، از "N/A" استفاده کنید یا آن را حذف کنید
- لحن: حرفه‌ای اما قابل دسترس
- پاسخ شما باید به زبان فارسی باشد
پاسخ شما:
`.trim(),
es: `
Eres un asistente que genera resúmenes estructurados de cuadernos de notas.
CUADERNO: ${notebookName}
NOTAS DEL CUADERNO:
${notesSummary}
TAREA:
Genera un resumen estructurado y organizado de este cuaderno analizando todas las notas.
FORMATO DE RESPUESTA (Markdown con emojis):
# 📊 Resumen del Cuaderno ${notebookName}
## 🌍 Temas Principales
• Identifica 3-5 temas recurrentes o tópicos cubiertos
## 📝 Estadísticas
• Número total de notas analizadas
• Categorías principales de contenido
## 📅 Elementos Temporales
• Fechas o periodos importantes mencionados
• Eventos planificados vs pasados
## ⚠️ Puntos de Atención / Acciones Requeridas
• Tareas o acciones identificadas en las notas
• Recordatorios o plazos importantes
• Elementos que requieren atención especial
## 💡 Insights Clave
• Resumen de la información más importante
• Tendencias o patrones observados
• Conexiones entre las diferentes notas
REGLAS:
- Usa formato Markdown con emojis como en el ejemplo
- Sé conciso y organiza la información claramente
- Identifica tendencias reales, no inventes información
- Si una sección no es relevante, usa "N/A" u omítela
- Tono: profesional pero accesible
- TU RESPUESTA DEBE SER EN ESPAÑOL
Tu respuesta:
`.trim(),
de: `
Du bist ein Assistent, der strukturierte Zusammenfassungen von Notizbüchern erstellt.
NOTIZBUCH: ${notebookName}
NOTIZBUCH-NOTIZEN:
${notesSummary}
AUFGABE:
Erstelle eine strukturierte und organisierte Zusammenfassung dieses Notizbuchs, indem du alle Notizen analysierst.
ANTWORTFORMAT (Markdown mit Emojis):
# 📊 Zusammenfassung des Notizbuchs ${notebookName}
## 🌍 Hauptthemen
• Identifiziere 3-5 wiederkehrende Themen
## 📝 Statistiken
• Gesamtzahl der analysierten Notizen
• Hauptinhaltkategorien
## 📅 Zeitliche Elemente
• Wichtige erwähnte Daten oder Zeiträume
• Geplante vs. vergangene Ereignisse
## ⚠️ Handlungspunkte / Aufmerksamkeitspunkte
• In Notizen identifizierte Aufgaben oder Aktionen
• Wichtige Erinnerungen oder Fristen
• Elemente, die besondere Aufmerksamkeit erfordern
## 💡 Wichtige Erkenntnisse
• Zusammenfassung der wichtigsten Informationen
• Beobachtete Trends oder Muster
• Verbindungen zwischen verschiedenen Notizen
REGELN:
- Verwende Markdown-Format mit Emojis wie im Beispiel
- Sei prägnant und organisiere Informationen klar
- Identifiziere echte Trends, erfinde keine Informationen
- Wenn ein Abschnitt nicht relevant ist, verwende "N/A" oder lass ihn weg
- Ton: professionell aber zugänglich
- DEINE ANTWORT MUSS AUF DEUTSCH SEIN
Deine Antwort:
`.trim()
}
return instructions[language] || instructions['en'] || instructions['fr']
}
}
// Export singleton instance
export const notebookSummaryService = new NotebookSummaryService()

View File

@@ -0,0 +1,313 @@
/**
* Paragraph Refactor Service
* Provides AI-powered text reformulation with 3 options:
* 1. Clarify - Make ambiguous text clearer
* 2. Shorten - Condense while keeping meaning
* 3. Improve Style - Enhance readability and flow
*/
import { LanguageDetectionService } from './language-detection.service'
import { getTagsProvider } from '../factory'
import { getSystemConfig } from '@/lib/config'
export type RefactorMode = 'clarify' | 'shorten' | 'improveStyle'
export interface RefactorOption {
mode: RefactorMode
label: string
description: string
icon: string
}
export interface RefactorResult {
original: string
refactored: string
mode: RefactorMode
language: string
wordCountChange: {
original: number
refactored: number
difference: number
percentage: number
}
}
export const REFACTOR_OPTIONS: RefactorOption[] = [
{
mode: 'clarify',
label: 'Clarify',
description: 'Make the text clearer and easier to understand',
icon: '💡'
},
{
mode: 'shorten',
label: 'Shorten',
description: 'Condense the text while keeping key information',
icon: '✂️'
},
{
mode: 'improveStyle',
label: 'Improve Style',
description: 'Enhance readability, flow, and expression',
icon: '✨'
}
]
export class ParagraphRefactorService {
private languageDetection: LanguageDetectionService
private readonly MIN_WORDS = 10
private readonly MAX_WORDS = 500
constructor() {
this.languageDetection = new LanguageDetectionService()
}
/**
* Refactor a paragraph with the specified mode
*/
async refactor(
content: string,
mode: RefactorMode
): Promise<RefactorResult> {
// Validate word count
const wordCount = content.split(/\s+/).length
if (wordCount < this.MIN_WORDS || wordCount > this.MAX_WORDS) {
throw new Error(
`Please select ${this.MIN_WORDS}-${this.MAX_WORDS} words to reformulate`
)
}
// Detect language
const { language } = await this.languageDetection.detectLanguage(content)
try {
// Build prompts
const systemPrompt = this.getSystemPrompt(mode)
const userPrompt = this.getUserPrompt(mode, content, language)
// Get AI provider from factory
const config = await getSystemConfig()
const provider = getTagsProvider(config)
// Use provider's generateText method
const fullPrompt = `${systemPrompt}\n\n${userPrompt}`
const refactored = await provider.generateText(fullPrompt)
// Calculate word count change
const refactoredWordCount = refactored.split(/\s+/).length
const wordCountChange = {
original: wordCount,
refactored: refactoredWordCount,
difference: refactoredWordCount - wordCount,
percentage: ((refactoredWordCount - wordCount) / wordCount) * 100
}
return {
original: content,
refactored,
mode,
language,
wordCountChange
}
} catch (error) {
throw new Error('Failed to refactor paragraph. Please try again.')
}
}
/**
* Get all 3 refactor options for a paragraph at once
* More efficient than calling refactor() 3 times
*/
async refactorAllModes(content: string): Promise<RefactorResult[]> {
// Validate word count
const wordCount = content.split(/\s+/).length
if (wordCount < this.MIN_WORDS || wordCount > this.MAX_WORDS) {
throw new Error(
`Please select ${this.MIN_WORDS}-${this.MAX_WORDS} words to reformulate`
)
}
// Detect language
const { language } = await this.languageDetection.detectLanguage(content)
try {
// System prompt for all modes
const systemPrompt = `You are an expert text editor who can improve text in multiple ways.
Your task is to provide 3 different reformulations of the user's text.
For each reformulation:
1. Clarify: Make the text clearer, more explicit, easier to understand
2. Shorten: Condense the text while preserving all key information and meaning
3. Improve Style: Enhance readability, flow, vocabulary, and expression
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text.
- If input is French, ALL 3 outputs MUST be in French
- If input is German, ALL 3 outputs MUST be in German
- If input is Spanish, ALL 3 outputs MUST be in Spanish
- NEVER translate to English unless the input is in English
Maintain the original meaning and intent:
- For "shorten", aim to reduce by 30-50% while keeping all key points
- For "clarify", expand where necessary but keep it natural
- For "improve style", keep similar length but enhance quality
Output Format (JSON):
{
"clarify": "clarified text here...",
"shorten": "shortened text here...",
"improveStyle": "improved text here..."
}`
const userPrompt = `CRITICAL LANGUAGE INSTRUCTION: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
Please provide 3 reformulations of this ${language} text:
${content}
Original language: ${language}
IMPORTANT: Provide all 3 versions in ${language}. No English, no explanations.`
// Get AI provider from factory
const config = await getSystemConfig()
const provider = getTagsProvider(config)
// Use provider's generateText method
const fullPrompt = `${systemPrompt}\n\n${userPrompt}`
const response = await provider.generateText(fullPrompt)
// Parse JSON response
const jsonResponse = JSON.parse(response)
const modes: RefactorMode[] = ['clarify', 'shorten', 'improveStyle']
const results: RefactorResult[] = []
for (const mode of modes) {
if (!jsonResponse[mode]) continue
const refactored = this.extractRefactoredText(jsonResponse[mode])
const refactoredWordCount = refactored.split(/\s+/).length
results.push({
original: content,
refactored,
mode,
language,
wordCountChange: {
original: wordCount,
refactored: refactoredWordCount,
difference: refactoredWordCount - wordCount,
percentage: ((refactoredWordCount - wordCount) / wordCount) * 100
}
})
}
return results
} catch (error) {
throw new Error('Failed to generate refactor options. Please try again.')
}
}
/**
* Get mode-specific system prompt
*/
private getSystemPrompt(mode: RefactorMode): string {
const prompts = {
clarify: `You are an expert at making text clearer and more understandable.
Your goal: Rewrite the text to eliminate ambiguity, add necessary context, and improve clarity.
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. If input is French, output MUST be French. If input is German, output MUST be German. NEVER translate to English.
Maintain the original meaning and tone, just make it clearer.`,
shorten: `You are an expert at concise writing.
Your goal: Reduce the text length by 30-50% while preserving ALL key information and meaning.
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. If input is French, output MUST be French. If input is German, output MUST be German. NEVER translate to English.
Remove fluff, repetition, and unnecessary words, but keep the substance.`,
improveStyle: `You are an expert editor with a focus on readability and flow.
Your goal: Enhance the text's style, vocabulary, sentence structure, and overall quality.
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. If input is French, output MUST be French. If input is German, output MUST be German. NEVER translate to English.
Maintain similar length but make it sound more professional and polished.`
}
return prompts[mode]
}
/**
* Get mode-specific user prompt
*/
private getUserPrompt(mode: RefactorMode, content: string, language: string): string {
const instructions = {
clarify: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
Please clarify and make this ${language} text easier to understand:`,
shorten: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
Please shorten this ${language} text while keeping all key information:`,
improveStyle: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
Please improve the style and readability of this ${language} text:`
}
return `${instructions[mode]}
${content}
CRITICAL: Respond ONLY with the refactored text in ${language}. No explanations, no meta-commentary, no English.`
}
/**
* Extract refactored text from AI response
* Handles JSON, markdown code blocks, or plain text
*/
private extractRefactoredText(response: string): string {
// Try JSON first
if (response.trim().startsWith('{')) {
try {
const parsed = JSON.parse(response)
// Look for common response fields
return parsed.refactored || parsed.text || parsed.result || response
} catch {
// Not valid JSON, continue
}
}
// Try markdown code block
const codeBlockMatch = response.match(/```(?:markdown)?\n([\s\S]+?)\n```/)
if (codeBlockMatch) {
return codeBlockMatch[1].trim()
}
// Fallback: trim whitespace and quotes
return response.trim().replace(/^["']|["']$/g, '')
}
/**
* Validate that text is within acceptable word count range
*/
validateWordCount(content: string): { valid: boolean; error?: string } {
const wordCount = content.split(/\s+/).length
if (wordCount < this.MIN_WORDS) {
return {
valid: false,
error: `Please select at least ${this.MIN_WORDS} words to reformulate (currently ${wordCount} words)`
}
}
if (wordCount > this.MAX_WORDS) {
return {
valid: false,
error: `Please select at most ${this.MAX_WORDS} words to reformulate (currently ${wordCount} words)`
}
}
return { valid: true }
}
}
// Singleton instance
export const paragraphRefactorService = new ParagraphRefactorService()

View File

@@ -0,0 +1,330 @@
/**
* Semantic Search Service
* Hybrid search combining keyword matching and semantic similarity
* Uses Reciprocal Rank Fusion (RRF) for result ranking
*/
import { embeddingService } from './embedding.service'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
export interface SearchResult {
noteId: string
title: string | null
content: string
score: number
matchType: 'exact' | 'related'
language?: string | null
}
export interface SearchOptions {
limit?: number
threshold?: number // Minimum similarity score (0-1)
includeExactMatches?: boolean
notebookId?: string // NEW: Filter by notebook for contextual search (IA5)
}
export class SemanticSearchService {
private readonly RRF_K = 60 // RRF constant (default recommended value)
private readonly DEFAULT_LIMIT = 20
private readonly DEFAULT_THRESHOLD = 0.6
/**
* Hybrid search: keyword + semantic with RRF fusion
*/
async search(
query: string,
options: SearchOptions = {}
): Promise<SearchResult[]> {
const {
limit = this.DEFAULT_LIMIT,
threshold = this.DEFAULT_THRESHOLD,
includeExactMatches = true,
notebookId // NEW: Contextual search within notebook (IA5)
} = options
if (!query || query.trim().length < 2) {
return []
}
const session = await auth()
const userId = session?.user?.id || null
try {
// 1. Keyword search (SQLite FTS)
const keywordResults = await this.keywordSearch(query, userId, notebookId)
// 2. Semantic search (vector similarity)
const semanticResults = await this.semanticVectorSearch(query, userId, threshold, notebookId)
// 3. Reciprocal Rank Fusion
const fusedResults = await this.reciprocalRankFusion(
keywordResults,
semanticResults
)
// 4. Sort by final score and limit
return fusedResults
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(result => ({
...result,
matchType: result.score > 0.8 ? 'exact' : 'related'
}))
} catch (error) {
console.error('Error in hybrid search:', error)
// Fallback to keyword-only search
const keywordResults = await this.keywordSearch(query, userId)
// Fetch note details for keyword results
const noteIds = keywordResults.slice(0, limit).map(r => r.noteId)
const notes = await prisma.note.findMany({
where: { id: { in: noteIds } },
select: {
id: true,
title: true,
content: true,
language: true
}
})
return notes.map(note => ({
noteId: note.id,
title: note.title,
content: note.content,
score: 1.0, // Default score for keyword-only results
matchType: 'related' as const,
language: note.language
}))
}
}
/**
* Keyword search using SQLite LIKE/FTS
*/
private async keywordSearch(
query: string,
userId: string | null,
notebookId?: string // NEW: Filter by notebook (IA5)
): Promise<Array<{ noteId: string; rank: number }>> {
// Build query for case-insensitive search
const searchPattern = `%${query}%`
const notes = await prisma.note.findMany({
where: {
...(userId ? { userId } : {}),
...(notebookId !== undefined ? { notebookId } : {}), // NEW: Notebook filter
OR: [
{ title: { contains: query } },
{ content: { contains: query } }
]
},
select: {
id: true,
title: true,
content: true
}
})
// Simple relevance scoring based on match position and frequency
const results = notes.map(note => {
const title = note.title || ''
const content = note.content || ''
const queryLower = query.toLowerCase()
// Count occurrences
const titleMatches = (title.match(new RegExp(queryLower, 'gi')) || []).length
const contentMatches = (content.match(new RegExp(queryLower, 'gi')) || []).length
// Boost title matches significantly
const titlePosition = title.toLowerCase().indexOf(queryLower)
const contentPosition = content.toLowerCase().indexOf(queryLower)
// Calculate rank (lower is better)
let rank = 100
if (titleMatches > 0) {
rank = titlePosition === 0 ? 1 : 10
rank -= titleMatches * 2
} else if (contentMatches > 0) {
rank = contentPosition < 100 ? 20 : 30
rank -= contentMatches
}
return {
noteId: note.id,
rank
}
})
return results.sort((a, b) => a.rank - b.rank)
}
/**
* Semantic vector search using embeddings
*/
private async semanticVectorSearch(
query: string,
userId: string | null,
threshold: number,
notebookId?: string // NEW: Filter by notebook (IA5)
): Promise<Array<{ noteId: string; rank: number }>> {
try {
// Generate query embedding
const { embedding: queryEmbedding } = await embeddingService.generateEmbedding(query)
// Fetch all user's notes with embeddings
const notes = await prisma.note.findMany({
where: {
...(userId ? { userId } : {}),
...(notebookId !== undefined ? { notebookId } : {}), // NEW: Notebook filter
embedding: { not: null }
},
select: {
id: true,
embedding: true
}
})
if (notes.length === 0) {
return []
}
// Calculate similarities for all notes
const similarities = notes.map(note => {
const noteEmbedding = embeddingService.deserialize(note.embedding || '[]')
const similarity = embeddingService.calculateCosineSimilarity(
queryEmbedding,
noteEmbedding
)
return {
noteId: note.id,
similarity
}
})
// Filter by threshold and convert to rank
return similarities
.filter(s => s.similarity >= threshold)
.sort((a, b) => b.similarity - a.similarity)
.map((s, index) => ({
noteId: s.noteId,
rank: index + 1 // 1-based rank
}))
} catch (error) {
console.error('Error in semantic vector search:', error)
return []
}
}
/**
* Reciprocal Rank Fusion algorithm
* Combines multiple ranked lists into a single ranking
* Formula: RRF(score) = 1 / (k + rank)
* k = 60 (default, prevents high rank from dominating)
*/
private async reciprocalRankFusion(
keywordResults: Array<{ noteId: string; rank: number }>,
semanticResults: Array<{ noteId: string; rank: number }>
): Promise<SearchResult[]> {
const scores = new Map<string, number>()
// Add keyword scores
for (const result of keywordResults) {
const rrfScore = 1 / (this.RRF_K + result.rank)
scores.set(result.noteId, (scores.get(result.noteId) || 0) + rrfScore)
}
// Add semantic scores
for (const result of semanticResults) {
const rrfScore = 1 / (this.RRF_K + result.rank)
scores.set(result.noteId, (scores.get(result.noteId) || 0) + rrfScore)
}
// Fetch note details
const noteIds = Array.from(scores.keys())
const notes = await prisma.note.findMany({
where: { id: { in: noteIds } },
select: {
id: true,
title: true,
content: true,
language: true
}
})
// Combine scores with note details
return notes.map(note => ({
noteId: note.id,
title: note.title,
content: note.content,
score: scores.get(note.id) || 0,
matchType: 'related' as const,
language: note.language
}))
}
/**
* Generate or update embedding for a note
* Called when note is created or significantly updated
*/
async indexNote(noteId: string): Promise<void> {
try {
const note = await prisma.note.findUnique({
where: { id: noteId },
select: { content: true, embedding: true, lastAiAnalysis: true }
})
if (!note) {
throw new Error('Note not found')
}
// Check if embedding needs regeneration
const shouldRegenerate = embeddingService.shouldRegenerateEmbedding(
note.content,
note.embedding,
note.lastAiAnalysis
)
if (!shouldRegenerate) {
return
}
// Generate new embedding
const { embedding } = await embeddingService.generateEmbedding(note.content)
// Save to database
await prisma.note.update({
where: { id: noteId },
data: {
embedding: embeddingService.serialize(embedding),
lastAiAnalysis: new Date()
}
})
} catch (error) {
console.error(`Error indexing note ${noteId}:`, error)
throw error
}
}
/**
* Batch index multiple notes (for initial migration or bulk updates)
*/
async indexBatchNotes(noteIds: string[]): Promise<void> {
const BATCH_SIZE = 10 // Process in batches to avoid overwhelming
for (let i = 0; i < noteIds.length; i += BATCH_SIZE) {
const batch = noteIds.slice(i, i + BATCH_SIZE)
await Promise.allSettled(
batch.map(noteId => this.indexNote(noteId))
)
}
}
}
// Singleton instance
export const semanticSearchService = new SemanticSearchService()

View File

@@ -0,0 +1,182 @@
/**
* Title Suggestion Service
* Generates intelligent title suggestions based on note content
*/
import { createOpenAI } from '@ai-sdk/openai'
import { generateText } from 'ai'
import { LanguageDetectionService } from './language-detection.service'
// Helper to get AI model for text generation
function getTextGenerationModel() {
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) {
throw new Error('OPENAI_API_KEY not configured for title generation')
}
const openai = createOpenAI({ apiKey })
return openai('gpt-4o-mini')
}
export interface TitleSuggestion {
title: string
confidence: number // 0-100
reasoning?: string // Why this title was suggested
}
export class TitleSuggestionService {
private languageDetection: LanguageDetectionService
constructor() {
this.languageDetection = new LanguageDetectionService()
}
/**
* Generate 3 title suggestions for a note
* Uses interface language (from user settings) for prompts
*/
async generateSuggestions(noteContent: string): Promise<TitleSuggestion[]> {
// Detect language of the note content
const { language: contentLanguage } = await this.languageDetection.detectLanguage(noteContent)
try {
const model = getTextGenerationModel()
// System prompt - explains what to do
const systemPrompt = `You are an expert title generator for a note-taking application.
Your task is to generate 3 distinct, engaging titles that capture the essence of the user's note.
Requirements:
- Generate EXACTLY 3 titles
- Each title should be 3-8 words
- Titles should be concise but descriptive
- Each title should have a different style:
1. Direct/Summary style - What the note is about
2. Question style - Posing a question the note addresses
3. Creative/Metaphorical style - Using imagery or analogy
- Return titles in the SAME LANGUAGE as the user's note
- Be helpful and avoid generic titles like "My Note" or "Untitled"
Output Format (JSON):
{
"suggestions": [
{ "title": "...", "confidence": 85, "reasoning": "..." },
{ "title": "...", "confidence": 80, "reasoning": "..." },
{ "title": "...", "confidence": 75, "reasoning": "..." }
]
}`
// User prompt with language context
const userPrompt = `Generate 3 title suggestions for this note:
${noteContent}
Note language detected: ${contentLanguage}
Respond with titles in ${contentLanguage} (same language as the note).`
const { text } = await generateText({
model,
system: systemPrompt,
prompt: userPrompt,
temperature: 0.7
})
// Parse JSON response
const response = JSON.parse(text)
if (!response.suggestions || !Array.isArray(response.suggestions)) {
throw new Error('Invalid response format')
}
// Validate and limit to exactly 3 suggestions
const suggestions = response.suggestions
.slice(0, 3)
.filter((s: any) => s.title && typeof s.title === 'string')
.map((s: any) => ({
title: s.title.trim(),
confidence: Math.min(100, Math.max(0, s.confidence || 75)),
reasoning: s.reasoning || ''
}))
// Ensure we always return exactly 3 suggestions
while (suggestions.length < 3) {
suggestions.push({
title: this.generateFallbackTitle(noteContent, contentLanguage),
confidence: 60,
reasoning: 'Generated fallback title'
})
}
return suggestions
} catch (error) {
console.error('Error generating title suggestions:', error)
// Fallback to simple extraction
return this.generateFallbackSuggestions(noteContent, contentLanguage)
}
}
/**
* Generate fallback title from first few meaningful words
*/
private generateFallbackTitle(content: string, language: string): string {
const words = content.split(/\s+/).filter(w => w.length > 3)
if (words.length === 0) {
return language === 'fr' ? 'Note sans titre' : 'Untitled Note'
}
// Take first 3-5 meaningful words
const titleWords = words.slice(0, Math.min(5, words.length))
return titleWords.join(' ').charAt(0).toUpperCase() + titleWords.join(' ').slice(1)
}
/**
* Generate fallback suggestions when AI fails
*/
private generateFallbackSuggestions(content: string, language: string): TitleSuggestion[] {
const baseTitle = this.generateFallbackTitle(content, language)
return [
{
title: baseTitle,
confidence: 70,
reasoning: 'Extracted from note content'
},
{
title: language === 'fr'
? `Réflexions sur ${baseTitle.toLowerCase()}`
: `Thoughts on ${baseTitle.toLowerCase()}`,
confidence: 65,
reasoning: 'Contextual variation'
},
{
title: language === 'fr'
? `${baseTitle}: Points clés`
: `${baseTitle}: Key Points`,
confidence: 60,
reasoning: 'Summary style'
}
]
}
/**
* Save selected title to note metadata
*/
async recordFeedback(
noteId: string,
selectedTitle: string,
allSuggestions: TitleSuggestion[]
): Promise<void> {
// This will be implemented in Phase 3 when we add feedback collection
// For now, we just log it
// TODO: In Phase 3, save to AiFeedback table for:
// - Improving future suggestions
// - Building user preference model
// - Computing confidence scores
}
}
// Singleton instance
export const titleSuggestionService = new TitleSuggestionService()

View File

@@ -0,0 +1,41 @@
export interface TagSuggestion {
tag: string;
confidence: number;
}
export interface TitleSuggestion {
title: string;
confidence: number;
}
export interface AIProvider {
/**
* Analyse le contenu et suggère des tags pertinents.
*/
generateTags(content: string): Promise<TagSuggestion[]>;
/**
* Génère un vecteur d'embeddings pour la recherche sémantique.
*/
getEmbeddings(text: string): Promise<number[]>;
/**
* Génère des suggestions de titres basées sur le contenu.
*/
generateTitles(prompt: string): Promise<TitleSuggestion[]>;
/**
* Génère du texte basé sur un prompt.
*/
generateText(prompt: string): Promise<string>;
}
export type AIProviderType = 'openai' | 'ollama';
export interface AIConfig {
provider: AIProviderType;
apiKey?: string;
baseUrl?: string; // Utile pour Ollama
model?: string;
embeddingModel?: string;
}

View File

@@ -0,0 +1,310 @@
/**
* PROPOSITION D'HARMONIE DE COULEURS
* ===================================
*
* Recommandations pour un système de couleurs unifié et moderne
* Inspiré de Google Keep avec une approche contemporaine
*
*/
// =============================================================================
// 1. PALETTE PRINCIPALE DU THÈME (OKLCH)
// =============================================================================
/**
* Proposition de palette principale avec meilleure cohérence
* Utilise OKLCH pour une meilleure accessibilité et cohérence perceptive
*/
export const RECOMMENDED_THEME_COLORS = {
/* ============ THEME LIGHT ============ */
light: {
// Backgrounds
background: 'oklch(0.99 0.002 250)', // Blanc très légèrement bleuté
card: 'oklch(1 0 0)', // Blanc pur
sidebar: 'oklch(0.96 0.005 250)', // Gris-bleu très pâle
input: 'oklch(0.97 0.003 250)', // Gris-bleu pâle
// Textes
foreground: 'oklch(0.18 0.01 250)', // Gris-bleu foncé (meilleur contraste)
'foreground-secondary': 'oklch(0.45 0.01 250)', // Gris moyen
'foreground-muted': 'oklch(0.6 0.005 250)', // Gris clair
// Primary Actions
primary: 'oklch(0.55 0.2 250)', // Bleu Keep (plus vibrant)
'primary-hover': 'oklch(0.5 0.22 250)', // Bleu Keep foncé
'primary-foreground': 'oklch(0.99 0 0)', // Blanc pur
// Accents
accent: 'oklch(0.92 0.015 250)', // Bleu très pâle
'accent-foreground': 'oklch(0.18 0.01 250)',
// Borders
border: 'oklch(0.88 0.01 250)', // Gris-bleu très clair
'border-hover': 'oklch(0.8 0.015 250)',
// Functional
success: 'oklch(0.65 0.15 145)', // Vert
warning: 'oklch(0.75 0.12 70)', // Jaune/orange
destructive: 'oklch(0.6 0.2 25)', // Rouge
},
/* ============ THEME DARK ============ */
dark: {
// Backgrounds
background: 'oklch(0.12 0.01 250)', // Noir légèrement bleuté
card: 'oklch(0.16 0.01 250)', // Gris-bleu foncé
sidebar: 'oklch(0.1 0.01 250)', // Noir bleuté
input: 'oklch(0.18 0.01 250)',
// Textes
foreground: 'oklch(0.96 0.002 250)', // Blanc légèrement bleuté
'foreground-secondary': 'oklch(0.75 0.005 250)',
'foreground-muted': 'oklch(0.55 0.01 250)',
// Primary Actions
primary: 'oklch(0.65 0.2 250)', // Bleu plus clair
'primary-hover': 'oklch(0.7 0.2 250)',
'primary-foreground': 'oklch(0.1 0 0)',
// Accents
accent: 'oklch(0.22 0.01 250)',
'accent-foreground': 'oklch(0.96 0.002 250)',
// Borders
border: 'oklch(0.25 0.015 250)',
'border-hover': 'oklch(0.35 0.015 250)',
// Functional
success: 'oklch(0.7 0.15 145)',
warning: 'oklch(0.8 0.12 70)',
destructive: 'oklch(0.65 0.2 25)',
},
} as const;
// =============================================================================
// 2. PALETTE DES COULEURS DE NOTES (UNIFIÉE)
// =============================================================================
/**
* Nouvelle palette de couleurs pour les notes
* Harmonisée avec le thème principal
* Meilleure accessibilité WCAG AA (contraste minimum 4.5:1)
*/
export const RECOMMENDED_NOTE_COLORS = {
default: {
// Light theme
bg: 'bg-white',
'bg-dark': 'dark:bg-neutral-900',
border: 'border-neutral-200',
'border-dark': 'dark:border-neutral-800',
hover: 'hover:bg-neutral-50',
'hover-dark': 'dark:hover:bg-neutral-800',
// Pour texte sombre
text: 'text-neutral-900',
'text-dark': 'dark:text-neutral-100',
},
red: {
bg: 'bg-red-50',
'bg-dark': 'dark:bg-red-950/40',
border: 'border-red-100',
'border-dark': 'dark:border-red-900/50',
hover: 'hover:bg-red-100',
'hover-dark': 'dark:hover:bg-red-950/60',
text: 'text-red-950',
'text-dark': 'dark:text-red-100',
},
orange: {
bg: 'bg-orange-50',
'bg-dark': 'dark:bg-orange-950/40',
border: 'border-orange-100',
'border-dark': 'dark:border-orange-900/50',
hover: 'hover:bg-orange-100',
'hover-dark': 'dark:hover:bg-orange-950/60',
text: 'text-orange-950',
'text-dark': 'dark:text-orange-100',
},
yellow: {
bg: 'bg-yellow-50',
'bg-dark': 'dark:bg-yellow-950/40',
border: 'border-yellow-100',
'border-dark': 'dark:border-yellow-900/50',
hover: 'hover:bg-yellow-100',
'hover-dark': 'dark:hover:bg-yellow-950/60',
text: 'text-yellow-950',
'text-dark': 'dark:text-yellow-100',
},
green: {
bg: 'bg-emerald-50',
'bg-dark': 'dark:bg-emerald-950/40',
border: 'border-emerald-100',
'border-dark': 'dark:border-emerald-900/50',
hover: 'hover:bg-emerald-100',
'hover-dark': 'dark:hover:bg-emerald-950/60',
text: 'text-emerald-950',
'text-dark': 'dark:text-emerald-100',
},
teal: {
bg: 'bg-teal-50',
'bg-dark': 'dark:bg-teal-950/40',
border: 'border-teal-100',
'border-dark': 'dark:border-teal-900/50',
hover: 'hover:bg-teal-100',
'hover-dark': 'dark:hover:bg-teal-950/60',
text: 'text-teal-950',
'text-dark': 'dark:text-teal-100',
},
blue: {
bg: 'bg-blue-50',
'bg-dark': 'dark:bg-blue-950/40',
border: 'border-blue-100',
'border-dark': 'dark:border-blue-900/50',
hover: 'hover:bg-blue-100',
'hover-dark': 'dark:hover:bg-blue-950/60',
text: 'text-blue-950',
'text-dark': 'dark:text-blue-100',
},
indigo: {
bg: 'bg-indigo-50',
'bg-dark': 'dark:bg-indigo-950/40',
border: 'border-indigo-100',
'border-dark': 'dark:border-indigo-900/50',
hover: 'hover:bg-indigo-100',
'hover-dark': 'dark:hover:bg-indigo-950/60',
text: 'text-indigo-950',
'text-dark': 'dark:text-indigo-100',
},
violet: {
bg: 'bg-violet-50',
'bg-dark': 'dark:bg-violet-950/40',
border: 'border-violet-100',
'border-dark': 'dark:border-violet-900/50',
hover: 'hover:bg-violet-100',
'hover-dark': 'dark:hover:bg-violet-950/60',
text: 'text-violet-950',
'text-dark': 'dark:text-violet-100',
},
purple: {
bg: 'bg-purple-50',
'bg-dark': 'dark:bg-purple-950/40',
border: 'border-purple-100',
'border-dark': 'dark:border-purple-900/50',
hover: 'hover:bg-purple-100',
'hover-dark': 'dark:hover:bg-purple-950/60',
text: 'text-purple-950',
'text-dark': 'dark:text-purple-100',
},
pink: {
bg: 'bg-pink-50',
'bg-dark': 'dark:bg-pink-950/40',
border: 'border-pink-100',
'border-dark': 'dark:border-pink-900/50',
hover: 'hover:bg-pink-100',
'hover-dark': 'dark:hover:bg-pink-950/60',
text: 'text-pink-950',
'text-dark': 'dark:text-pink-100',
},
rose: {
bg: 'bg-rose-50',
'bg-dark': 'dark:bg-rose-950/40',
border: 'border-rose-100',
'border-dark': 'dark:border-rose-900/50',
hover: 'hover:bg-rose-100',
'hover-dark': 'dark:hover:bg-rose-950/60',
text: 'text-rose-950',
'text-dark': 'dark:text-rose-100',
},
gray: {
bg: 'bg-neutral-100',
'bg-dark': 'dark:bg-neutral-800',
border: 'border-neutral-200',
'border-dark': 'dark:border-neutral-700',
hover: 'hover:bg-neutral-200',
'hover-dark': 'dark:hover:bg-neutral-700',
text: 'text-neutral-900',
'text-dark': 'dark:text-neutral-100',
},
} as const;
// =============================================================================
// 3. RÈGLES DE DESIGN
// =============================================================================
/**
* Principes de design couleur :
*
* 1. HARMONIE : Toutes les couleurs partagent la même teinte de base (bleu 250°)
* - Crée une cohérence visuelle
* - Réduit la fatigue visuelle
* - Améliore la perception de marque
*
* 2. CONTRASTE : Respecte WCAG AA (4.5:1 minimum)
* - Texte sur fond : toujours ≥ 4.5:1
* - Éléments interactifs : ≥ 3:1
* - Utilise OKLCH pour une mesure plus précise
*
* 3. ACCESSIBILITÉ :
* - Ne pas utiliser la couleur seule pour véhiculer l'information
* - Inclure des icônes ou des symboles
* - Supporter le mode de contraste élevé
*
* 4. ADAPTABILITÉ :
* - Mode light/dark automatique
* - Variations de couleur par note
* - Thèmes alternatifs (midnight, blue, sepia)
*
* 5. PERFORMANCE PERCEPTIVE :
* - OKLCH pour une échelle perceptuelle uniforme
* - Même légèreté perçue entre light et dark
* - Transition fluide entre thèmes
*/
// =============================================================================
// 4. EXEMPLE D'IMPLEMENTATION
// =============================================================================
/**
* Comment utiliser ces couleurs dans les composants :
*
* ```tsx
* import { RECOMMENDED_NOTE_COLORS } from '@/lib/color-harmony-recommendation'
*
* // Pour une carte de note
* <div className={`
* ${noteColors.bg}
* dark:${noteColors['bg-dark']}
* ${noteColors.border}
* dark:${noteColors['border-dark']}
* ${noteColors.text}
* dark:${noteColors['text-dark']}
* hover:${noteColors.hover}
* dark:hover:${noteColors['hover-dark']}
* `}>
* {note.content}
* </div>
*
* // Pour le thème global (globals.css)
* :root {
* --background: oklch(0.99 0.002 250);
* --foreground: oklch(0.18 0.01 250);
* --primary: oklch(0.55 0.2 250);
* // ... autres couleurs
* }
* ```
*/
// =============================================================================
// 5. AVANTAGES DE CETTE APPROCHE
// =============================================================================
/**
* ✅ Cohérence visuelle accrue
* ✅ Meilleure accessibilité (WCAG AA+)
* ✅ Adaptation native aux modes light/dark
* ✅ Palette extensible (facile à ajouter de nouvelles couleurs)
* ✅ Performance OKLCH (perception humaine)
* ✅ Compatible avec Tailwind CSS
* ✅ Maintenance simplifiée
* ✅ Professionalisme et modernité
*/
export type RecommendedNoteColor = keyof typeof RECOMMENDED_NOTE_COLORS;

View File

@@ -0,0 +1,55 @@
import prisma from './prisma';
export async function getSystemConfig() {
try {
const configs = await prisma.systemConfig.findMany();
return configs.reduce((acc, conf) => {
acc[conf.key] = conf.value;
return acc;
}, {} as Record<string, string>);
} catch (e) {
console.error('Failed to load system config from DB:', e);
return {};
}
}
/**
* Get a config value with a default fallback
*/
export async function getConfigValue(key: string, defaultValue: string = ''): Promise<string> {
const config = await getSystemConfig();
return config[key] || defaultValue;
}
/**
* Get a numeric config value with a default fallback
*/
export async function getConfigNumber(key: string, defaultValue: number): Promise<number> {
const value = await getConfigValue(key, String(defaultValue));
const num = parseFloat(value);
return isNaN(num) ? defaultValue : num;
}
/**
* Get a boolean config value with a default fallback
*/
export async function getConfigBoolean(key: string, defaultValue: boolean): Promise<boolean> {
const value = await getConfigValue(key, String(defaultValue));
return value === 'true';
}
/**
* Search configuration defaults
*/
export const SEARCH_DEFAULTS = {
SEMANTIC_THRESHOLD: 0.65,
RRF_K_BASE: 20,
RRF_K_ADAPTIVE: true,
KEYWORD_BOOST_EXACT: 2.0,
KEYWORD_BOOST_CONCEPTUAL: 0.7,
SEMANTIC_BOOST_EXACT: 0.7,
SEMANTIC_BOOST_CONCEPTUAL: 1.5,
QUERY_EXPANSION_ENABLED: false,
QUERY_EXPANSION_MAX_SYNONYMS: 3,
DEBUG_MODE: false,
} as const;

View File

@@ -0,0 +1,35 @@
export function getEmailTemplate(title: string, content: string, actionLink?: string, actionText?: string) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 8px; background: #fff; }
.header { text-align: center; margin-bottom: 30px; }
.logo { color: #f59e0b; font-size: 24px; font-weight: bold; text-decoration: none; display: flex; align-items: center; justify-content: center; gap: 10px; }
.button { display: inline-block; padding: 12px 24px; background-color: #f59e0b; color: white !important; text-decoration: none; border-radius: 6px; font-weight: bold; margin: 20px 0; }
.footer { margin-top: 30px; font-size: 12px; color: #888; text-align: center; border-top: 1px solid #eee; padding-top: 20px; }
</style>
</head>
<body>
<div className="container">
<div className="header">
<a href="${process.env.NEXTAUTH_URL}" className="logo">
📒 Memento
</a>
</div>
<h1>${title}</h1>
<div>
${content}
</div>
${actionLink ? `<div style="text-align: center;"><a href="${actionLink}" className="button">${actionText || 'Click here'}</a></div>` : ''}
<div className="footer">
<p>This email was sent from your Memento instance.</p>
</div>
</div>
</body>
</html>
`;
}

View File

@@ -0,0 +1,90 @@
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { SupportedLanguage, loadTranslations, getTranslationValue, Translations } from './load-translations'
type LanguageContextType = {
language: SupportedLanguage
setLanguage: (lang: SupportedLanguage) => void
t: (key: string, params?: Record<string, string | number>) => string
translations: Translations
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined)
export function LanguageProvider({ children, initialLanguage = 'en' }: {
children: ReactNode
initialLanguage?: SupportedLanguage
}) {
const [language, setLanguageState] = useState<SupportedLanguage>(initialLanguage)
const [translations, setTranslations] = useState<Translations | null>(null)
// Load translations when language changes
useEffect(() => {
const loadLang = async () => {
const loaded = await loadTranslations(language)
setTranslations(loaded)
}
loadLang()
}, [language])
// Load saved language from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem('user-language') as SupportedLanguage
if (saved) {
setLanguageState(saved)
document.documentElement.lang = saved
} else {
// Auto-detect from browser language
const browserLang = navigator.language.split('-')[0] as SupportedLanguage
const supportedLangs: SupportedLanguage[] = ['en', 'fr', 'es', 'de', 'fa', 'it', 'pt', 'ru', 'zh', 'ja', 'ko', 'ar', 'hi', 'nl', 'pl']
if (supportedLangs.includes(browserLang)) {
setLanguageState(browserLang)
localStorage.setItem('user-language', browserLang)
document.documentElement.lang = browserLang
}
}
}, [])
const setLanguage = (lang: SupportedLanguage) => {
setLanguageState(lang)
localStorage.setItem('user-language', lang)
// Update HTML lang attribute for font styling
document.documentElement.lang = lang
}
const t = (key: string, params?: Record<string, string | number>) => {
if (!translations) return key
let value: any = getTranslationValue(translations, key)
// Replace parameters like {count}, {percentage}, etc.
if (params) {
Object.entries(params).forEach(([param, paramValue]) => {
value = value.replace(`{${param}}`, String(paramValue))
})
}
return value
}
if (!translations) {
return null // Show loading state if needed
}
return (
<LanguageContext.Provider value={{ language, setLanguage, t, translations }}>
{children}
</LanguageContext.Provider>
)
}
export function useLanguage() {
const context = useContext(LanguageContext)
if (context === undefined) {
throw new Error('useLanguage must be used within a LanguageProvider')
}
return context
}

View File

@@ -0,0 +1,62 @@
/**
* Detect user's preferred language from their existing notes
* Analyzes language distribution across all user's notes
*/
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { SupportedLanguage } from './load-translations'
export async function detectUserLanguage(): Promise<SupportedLanguage> {
const session = await auth()
// Default to English for non-logged-in users
if (!session?.user?.id) {
return 'en'
}
try {
// Get all user's notes with detected languages
const notes = await prisma.note.findMany({
where: {
userId: session.user.id,
language: { not: null }
},
select: {
language: true,
languageConfidence: true
}
})
if (notes.length === 0) {
return 'en' // Default for new users
}
// Count language occurrences weighted by confidence
const languageScores: Record<string, number> = {}
for (const note of notes) {
if (note.language) {
const confidence = note.languageConfidence || 0.8
languageScores[note.language] = (languageScores[note.language] || 0) + confidence
}
}
// Find language with highest score
const sortedLanguages = Object.entries(languageScores)
.sort(([, a], [, b]) => b - a)
if (sortedLanguages.length > 0) {
const topLanguage = sortedLanguages[0][0] as SupportedLanguage
// Verify it's a supported language
if (['fr', 'en', 'es', 'de', 'fa'].includes(topLanguage)) {
return topLanguage
}
}
return 'en'
} catch (error) {
console.error('Error detecting user language:', error)
return 'en'
}
}

View File

@@ -0,0 +1,8 @@
/**
* i18n exports
* Centralized internationalization system with JSON-based translations
*/
export { LanguageProvider, useLanguage } from './LanguageProvider'
export { detectUserLanguage } from './detect-user-language'
export { loadTranslations, getTranslationValue, type SupportedLanguage, type Translations } from './load-translations'

View File

@@ -0,0 +1,925 @@
/**
* Load translations from JSON files
*/
export type SupportedLanguage = 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
export interface Translations {
auth: {
signIn: string
signUp: string
email: string
password: string
name: string
emailPlaceholder: string
passwordPlaceholder: string
namePlaceholder: string
passwordMinChars: string
resetPassword: string
resetPasswordInstructions: string
forgotPassword: string
noAccount: string
hasAccount: string
signInToAccount: string
createAccount: string
rememberMe: string
orContinueWith: string
checkYourEmail: string
resetEmailSent: string
returnToLogin: string
forgotPasswordTitle: string
forgotPasswordDescription: string
sending: string
sendResetLink: string
backToLogin: string
signOut: string
}
sidebar: {
notes: string
reminders: string
labels: string
editLabels: string
archive: string
trash: string
}
notes: {
title: string
newNote: string
untitled: string
placeholder: string
markdownPlaceholder: string
titlePlaceholder: string
listItem: string
addListItem: string
newChecklist: string
add: string
adding: string
close: string
confirmDelete: string
confirmLeaveShare: string
sharedBy: string
leaveShare: string
delete: string
archive: string
unarchive: string
pin: string
unpin: string
color: string
changeColor: string
setReminder: string
setReminderButton: string
date: string
time: string
reminderDateTimeRequired: string
invalidDateTime: string
reminderMustBeFuture: string
reminderSet: string
reminderPastError: string
reminderRemoved: string
addImage: string
addLink: string
linkAdded: string
linkMetadataFailed: string
linkAddFailed: string
invalidFileType: string
fileTooLarge: string
uploadFailed: string
contentOrMediaRequired: string
itemOrMediaRequired: string
noteCreated: string
noteCreateFailed: string
aiAssistant: string
changeSize: string
backgroundOptions: string
moreOptions: string
remindMe: string
markdownMode: string
addCollaborators: string
duplicate: string
share: string
showCollaborators: string
pinned: string
others: string
noNotes: string
noNotesFound: string
createFirstNote: string
size: string
small: string
medium: string
large: string
shareWithCollaborators: string
view: string
edit: string
readOnly: string
preview: string
noContent: string
takeNote: string
takeNoteMarkdown: string
addItem: string
sharedReadOnly: string
makeCopy: string
saving: string
copySuccess: string
copyFailed: string
copy: string
markdownOn: string
markdownOff: string
undo: string
redo: string
}
pagination: {
previous: string
pageInfo: string
next: string
}
labels: {
title: string
filter: string
manage: string
manageTooltip: string
changeColor: string
changeColorTooltip: string
delete: string
deleteTooltip: string
confirmDelete: string
newLabelPlaceholder: string
namePlaceholder: string
addLabel: string
createLabel: string
labelName: string
labelColor: string
manageLabels: string
manageLabelsDescription: string
selectedLabels: string
allLabels: string
clearAll: string
filterByLabel: string
tagAdded: string
showLess: string
showMore: string
editLabels: string
editLabelsDescription: string
noLabelsFound: string
loading: string
notebookRequired: string
}
search: {
placeholder: string
searchPlaceholder: string
semanticInProgress: string
semanticTooltip: string
searching: string
noResults: string
resultsFound: string
exactMatch: string
related: string
}
collaboration: {
emailPlaceholder: string
addCollaborator: string
removeCollaborator: string
owner: string
canEdit: string
canView: string
shareNote: string
shareWithCollaborators: string
addCollaboratorDescription: string
viewerDescription: string
emailAddress: string
enterEmailAddress: string
invite: string
peopleWithAccess: string
noCollaborators: string
noCollaboratorsViewer: string
pendingInvite: string
pending: string
remove: string
unnamedUser: string
done: string
willBeAdded: string
alreadyInList: string
nowHasAccess: string
accessRevoked: string
errorLoading: string
failedToAdd: string
failedToRemove: string
}
ai: {
analyzing: string
clickToAddTag: string
ignoreSuggestion: string
generatingTitles: string
generateTitlesTooltip: string
poweredByAI: string
languageDetected: string
processing: string
tagAdded: string
titleGenerating: string
titleGenerateWithAI: string
titleGenerationMinWords: string
titleGenerationError: string
titlesGenerated: string
titleGenerationFailed: string
titleApplied: string
reformulationNoText: string
reformulationSelectionTooShort: string
reformulationMinWords: string
reformulationMaxWords: string
reformulationError: string
reformulationFailed: string
reformulationApplied: string
transformMarkdown: string
transforming: string
transformSuccess: string
transformError: string
assistant: string
generating: string
generateTitles: string
reformulateText: string
reformulating: string
clarify: string
shorten: string
improveStyle: string
reformulationComparison: string
original: string
reformulated: string
}
batchOrganization: {
error: string
noNotesSelected: string
title: string
description: string
analyzing: string
notesToOrganize: string
selected: string
noNotebooks: string
noSuggestions: string
confidence: string
unorganized: string
applying: string
apply: string
}
autoLabels: {
error: string
noLabelsSelected: string
created: string
analyzing: string
title: string
description: string
note: string
notes: string
typeContent: string
createNewLabel: string
new: string
}
titleSuggestions: {
available: string
title: string
generating: string
selectTitle: string
dismiss: string
}
semanticSearch: {
exactMatch: string
related: string
searching: string
}
paragraphRefactor: {
title: string
shorten: string
expand: string
improve: string
formal: string
casual: string
}
memoryEcho: {
title: string
description: string
dailyInsight: string
insightReady: string
viewConnection: string
helpful: string
notHelpful: string
dismiss: string
thanksFeedback: string
thanksFeedbackImproving: string
connections: string
connection: string
connectionsBadge: string
fused: string
overlay: {
title: string
searchPlaceholder: string
sortBy: string
sortSimilarity: string
sortRecent: string
sortOldest: string
viewAll: string
loading: string
noConnections: string
}
comparison: {
title: string
similarityInfo: string
highSimilarityInsight: string
untitled: string
clickToView: string
helpfulQuestion: string
helpful: string
notHelpful: string
}
editorSection: {
title: string
loading: string
view: string
compare: string
merge: string
compareAll: string
mergeAll: string
}
fusion: {
title: string
mergeNotes: string
notesToMerge: string
optionalPrompt: string
promptPlaceholder: string
generateFusion: string
generating: string
previewTitle: string
edit: string
modify: string
finishEditing: string
optionsTitle: string
archiveOriginals: string
keepAllTags: string
useLatestTitle: string
createBacklinks: string
cancel: string
confirmFusion: string
success: string
error: string
generateError: string
noContentReturned: string
unknownDate: string
}
}
nav: {
home: string
notes: string
notebooks: string
generalNotes: string
archive: string
settings: string
profile: string
aiSettings: string
logout: string
login: string
adminDashboard: string
diagnostics: string
trash: string
support: string
reminders: string
userManagement: string
accountSettings: string
manageAISettings: string
configureAI: string
supportDevelopment: string
supportDescription: string
buyMeACoffee: string
donationDescription: string
donateOnKofi: string
donationNote: string
sponsorOnGithub: string
sponsorDescription: string
workspace: string
quickAccess: string
myLibrary: string
favorites: string
recent: string
proPlan: string
}
settings: {
title: string
description: string
account: string
appearance: string
theme: string
themeLight: string
themeDark: string
themeSystem: string
notifications: string
language: string
selectLanguage: string
privacy: string
security: string
about: string
version: string
settingsSaved: string
settingsError: string
}
profile: {
title: string
description: string
displayName: string
email: string
changePassword: string
changePasswordDescription: string
currentPassword: string
newPassword: string
confirmPassword: string
updatePassword: string
passwordChangeSuccess: string
passwordChangeFailed: string
passwordUpdated: string
passwordError: string
languagePreferences: string
languagePreferencesDescription: string
preferredLanguage: string
selectLanguage: string
languageDescription: string
autoDetect: string
updateSuccess: string
updateFailed: string
languageUpdateSuccess: string
languageUpdateFailed: string
profileUpdated: string
profileError: string
accountSettings: string
manageAISettings: string
displaySettings: string
displaySettingsDescription: string
fontSize: string
selectFontSize: string
fontSizeSmall: string
fontSizeMedium: string
fontSizeLarge: string
fontSizeExtraLarge: string
fontSizeDescription: string
fontSizeUpdateSuccess: string
fontSizeUpdateFailed: string
showRecentNotes: string
showRecentNotesDescription: string
recentNotesUpdateSuccess: string
recentNotesUpdateFailed: string
}
aiSettings: {
title: string
description: string
features: string
provider: string
providerAuto: string
providerOllama: string
providerOpenAI: string
frequency: string
frequencyDaily: string
frequencyWeekly: string
saving: string
saved: string
error: string
}
general: {
loading: string
save: string
cancel: string
add: string
edit: string
confirm: string
close: string
back: string
next: string
previous: string
submit: string
reset: string
apply: string
clear: string
select: string
tryAgain: string
error: string
operationSuccess: string
operationFailed: string
}
colors: {
default: string
red: string
blue: string
green: string
yellow: string
purple: string
pink: string
orange: string
gray: string
}
reminder: {
title: string
setReminder: string
removeReminder: string
reminderDate: string
reminderTime: string
save: string
cancel: string
}
notebook: {
create: string
createNew: string
createDescription: string
name: string
selectIcon: string
selectColor: string
cancel: string
creating: string
edit: string
editDescription: string
delete: string
deleteWarning: string
deleteConfirm: string
summary: string
summaryDescription: string
generating: string
summaryError: string
}
notebookSuggestion: {
title: string
description: string
move: string
dismiss: string
dismissIn: string
moveToNotebook: string
generalNotes: string
}
admin: {
title: string
userManagement: string
aiTesting: string
settings: string
security: {
title: string
description: string
allowPublicRegistration: string
allowPublicRegistrationDescription: string
updateSuccess: string
updateFailed: string
}
ai: {
title: string
description: string
tagsGenerationProvider: string
tagsGenerationDescription: string
embeddingsProvider: string
embeddingsDescription: string
provider: string
baseUrl: string
model: string
apiKey: string
selectOllamaModel: string
openAIKeyDescription: string
modelRecommendations: string
commonModelsDescription: string
selectEmbeddingModel: string
commonEmbeddingModels: string
saving: string
saveSettings: string
openTestPanel: string
updateSuccess: string
updateFailed: string
providerTagsRequired: string
providerEmbeddingRequired: string
}
smtp: {
title: string
description: string
host: string
port: string
username: string
password: string
fromEmail: string
forceSSL: string
ignoreCertErrors: string
saveSettings: string
sending: string
testEmail: string
updateSuccess: string
updateFailed: string
testSuccess: string
testFailed: string
}
users: {
createUser: string
addUser: string
createUserDescription: string
name: string
email: string
password: string
role: string
createSuccess: string
createFailed: string
deleteSuccess: string
deleteFailed: string
roleUpdateSuccess: string
roleUpdateFailed: string
table: {
name: string
email: string
role: string
createdAt: string
actions: string
}
}
aiTest: {
title: string
description: string
tagsTestTitle: string
tagsTestDescription: string
embeddingsTestTitle: string
embeddingsTestDescription: string
howItWorksTitle: string
provider: string
model: string
testing: string
runTest: string
testPassed: string
testFailed: string
responseTime: string
generatedTags: string
embeddingDimensions: string
vectorDimensions: string
first5Values: string
error: string
testError: string
tipTitle: string
tipDescription: string
}
}
about: {
title: string
description: string
appName: string
appDescription: string
version: string
buildDate: string
platform: string
platformWeb: string
features: {
title: string
description: string
titleSuggestions: string
semanticSearch: string
paragraphReformulation: string
memoryEcho: string
notebookOrganization: string
dragDrop: string
labelSystem: string
multipleProviders: string
}
technology: {
title: string
description: string
frontend: string
backend: string
database: string
authentication: string
ai: string
ui: string
testing: string
}
support: {
title: string
description: string
documentation: string
reportIssues: string
feedback: string
}
}
support: {
title: string
description: string
buyMeACoffee: string
donationDescription: string
donateOnKofi: string
kofiDescription: string
sponsorOnGithub: string
sponsorDescription: string
githubDescription: string
howSupportHelps: string
directImpact: string
sponsorPerks: string
transparency: string
transparencyDescription: string
hostingServers: string
domainSSL: string
aiApiCosts: string
totalExpenses: string
otherWaysTitle: string
starGithub: string
reportBug: string
contributeCode: string
shareTwitter: string
}
demoMode: {
title: string
activated: string
deactivated: string
toggleFailed: string
description: string
parametersActive: string
similarityThreshold: string
delayBetweenNotes: string
unlimitedInsights: string
createNotesTip: string
}
resetPassword: {
title: string
description: string
invalidLinkTitle: string
invalidLinkDescription: string
requestNewLink: string
newPassword: string
confirmNewPassword: string
resetting: string
resetPassword: string
passwordMismatch: string
success: string
loading: string
}
dataManagement: {
title: string
toolsDescription: string
export: {
title: string
description: string
button: string
success: string
failed: string
}
import: {
title: string
description: string
button: string
success: string
failed: string
}
delete: {
title: string
description: string
button: string
confirm: string
success: string
failed: string
}
indexing: {
title: string
description: string
button: string
success: string
failed: string
}
cleanup: {
title: string
description: string
button: string
failed: string
}
}
appearance: {
title: string
description: string
}
generalSettings: {
title: string
description: string
}
toast: {
saved: string
saveFailed: string
operationSuccess: string
operationFailed: string
openingConnection: string
openConnectionFailed: string
thanksFeedback: string
thanksFeedbackImproving: string
feedbackFailed: string
notesFusionSuccess: string
}
testPages: {
titleSuggestions: {
title: string
contentLabel: string
placeholder: string
wordCount: string
status: string
analyzing: string
idle: string
error: string
suggestions: string
noSuggestions: string
}
}
trash: {
title: string
empty: string
restore: string
deletePermanently: string
}
footer: {
privacy: string
terms: string
openSource: string
}
connection: {
similarityInfo: string
clickToView: string
isHelpful: string
helpful: string
notHelpful: string
memoryEchoDiscovery: string
}
diagnostics: {
title: string
configuredProvider: string
apiStatus: string
testDetails: string
troubleshootingTitle: string
tip1: string
tip2: string
tip3: string
tip4: string
}
batch: {
organizeWithAI: string
organize: string
}
common: {
unknown: string
notAvailable: string
loading: string
error: string
success: string
confirm: string
cancel: string
close: string
save: string
delete: string
edit: string
add: string
remove: string
search: string
noResults: string
required: string
optional: string
}
time: {
justNow: string
minutesAgo: string
hoursAgo: string
daysAgo: string
yesterday: string
today: string
tomorrow: string
}
favorites: {
title: string
toggleSection: string
noFavorites: string
pinToFavorite: string
}
notebooks: {
create: string
allNotebooks: string
noNotebooks: string
createFirst: string
}
ui: {
close: string
open: string
expand: string
collapse: string
}
[key: string]: any
}
/**
* Load translations from JSON files
*/
export async function loadTranslations(language: SupportedLanguage): Promise<Translations> {
try {
const translations = await import(`@/locales/${language}.json`)
return translations.default as unknown as Translations
} catch (error) {
console.error(`Failed to load translations for ${language}:`, error)
const enTranslations = await import(`@/locales/en.json`)
return enTranslations.default as unknown as Translations
}
}
/**
* Get nested translation value from object using dot notation
*/
export function getTranslationValue(translations: Translations, key: string): string {
const keys = key.split('.')
let value: any = translations
for (const k of keys) {
value = value?.[k]
}
return typeof value === 'string' ? value : key
}

View File

@@ -0,0 +1,57 @@
import { LabelColorName } from './types'
const STORAGE_KEY = 'memento-label-colors'
// Store label colors in localStorage
export function getLabelColor(label: string): LabelColorName {
if (typeof window === 'undefined') return 'gray'
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored) return 'gray'
const colors = JSON.parse(stored) as Record<string, LabelColorName>
return colors[label] || 'gray'
} catch {
return 'gray'
}
}
export function setLabelColor(label: string, color: LabelColorName) {
if (typeof window === 'undefined') return
try {
const stored = localStorage.getItem(STORAGE_KEY)
const colors = stored ? JSON.parse(stored) as Record<string, LabelColorName> : {}
colors[label] = color
localStorage.setItem(STORAGE_KEY, JSON.stringify(colors))
} catch (error) {
console.error('Failed to save label color:', error)
}
}
export function getAllLabelColors(): Record<string, LabelColorName> {
if (typeof window === 'undefined') return {}
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) : {}
} catch {
return {}
}
}
export function deleteLabelColor(label: string) {
if (typeof window === 'undefined') return
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored) return
const colors = JSON.parse(stored) as Record<string, LabelColorName>
delete colors[label]
localStorage.setItem(STORAGE_KEY, JSON.stringify(colors))
} catch (error) {
console.error('Failed to delete label color:', error)
}
}

65
memento-note/lib/mail.ts Normal file
View File

@@ -0,0 +1,65 @@
import nodemailer from 'nodemailer';
import { getSystemConfig } from './config';
interface MailOptions {
to: string;
subject: string;
html: string;
}
export async function sendEmail({ to, subject, html }: MailOptions) {
const config = await getSystemConfig();
const host = config.SMTP_HOST || process.env.SMTP_HOST;
const port = parseInt(config.SMTP_PORT || process.env.SMTP_PORT || '587');
const user = (config.SMTP_USER || process.env.SMTP_USER || '').trim();
const pass = (config.SMTP_PASS || process.env.SMTP_PASS || '').trim();
const from = config.SMTP_FROM || process.env.SMTP_FROM || 'noreply@memento.app';
// Options de sécurité
const forceSecure = config.SMTP_SECURE === 'true'; // Forcé par l'admin
const isPort465 = port === 465;
// Si secure n'est pas forcé, on déduit du port (465 = secure, autres = starttls)
const secure = forceSecure || isPort465;
const ignoreCerts = config.SMTP_IGNORE_CERT === 'true';
const transporter = nodemailer.createTransport({
host: host || undefined,
port: port || undefined,
secure: secure || false,
auth: { user, pass },
// Force IPv4 pour éviter les problèmes de résolution DNS/Docker
family: 4,
// Force AUTH LOGIN pour meilleure compatibilité (Mailcow, Exchange) vs PLAIN par défaut
authMethod: 'LOGIN',
// Timeout généreux
connectionTimeout: 10000,
tls: {
// Si on ignore les certs, on autorise tout.
// Sinon on laisse les défauts stricts de Node.
rejectUnauthorized: !ignoreCerts,
// Compatibilité vieux serveurs si besoin (optionnel, activé si ignoreCerts pour maximiser les chances)
ciphers: ignoreCerts ? 'SSLv3' : undefined
}
} as any);
try {
await transporter.verify();
const info = await transporter.sendMail({
from: `"Memento App" <${from}>`,
to,
subject,
html,
});
return { success: true, messageId: info.messageId };
} catch (error: any) {
console.error("❌ Erreur SMTP:", error);
return {
success: false,
error: `Erreur envoi: ${error.message} (Code: ${error.code})`
};
}
}

View File

@@ -0,0 +1,307 @@
/**
* OPTIONS DE COULEURS MODERNES - PAS DE DÉGRADÉS
* =================================================
*
* Alternatives au bleu traditionnel pour un design contemporain
*/
// =============================================================================
// OPTION 1: GRIS-BLEU (SLATE) - RECOMMANDÉE ✅
// =============================================================================
/**
* Gris-bleu moderne et professionnel
* Inspiré par Linear, Vercel, GitHub
* Très élégant, discret et apaisant pour les yeux
*/
export const SLATE_THEME = {
name: 'Gris-Bleu (Slate)',
description: 'Moderne, professionnel et apaisant - comme Linear/Vercel',
light: {
// Backgrounds
background: 'oklch(0.985 0.003 230)', // Blanc grisâtre très léger
card: 'oklch(1 0 0)', // Blanc pur
sidebar: 'oklch(0.97 0.004 230)', // Gris-bleu très pâle
input: 'oklch(0.98 0.003 230)', // Gris-bleu pâle
// Textes
foreground: 'oklch(0.2 0.02 230)', // Gris-bleu foncé
'foreground-secondary': 'oklch(0.45 0.015 230)', // Gris-bleu moyen
'foreground-muted': 'oklch(0.6 0.01 230)', // Gris-bleu clair
// Primary (le point coloré de l'interface)
primary: 'oklch(0.45 0.08 230)', // Gris-bleu doux
'primary-hover': 'oklch(0.4 0.09 230)', // Gris-bleu plus foncé
'primary-foreground': 'oklch(0.99 0 0)', // Blanc
// Accents
accent: 'oklch(0.94 0.005 230)', // Gris-bleu très pâle
'accent-foreground': 'oklch(0.2 0.02 230)',
// Borders
border: 'oklch(0.9 0.008 230)', // Gris-bleu très clair
'border-hover': 'oklch(0.82 0.01 230)',
// Functional
success: 'oklch(0.65 0.15 145)', // Vert emerald
warning: 'oklch(0.75 0.12 70)', // Jaune/orange
destructive: 'oklch(0.6 0.18 25)', // Rouge
},
dark: {
// Backgrounds
background: 'oklch(0.14 0.005 230)', // Noir grisâtre léger
card: 'oklch(0.18 0.006 230)', // Gris-bleu foncé
sidebar: 'oklch(0.12 0.005 230)', // Noir grisâtre
input: 'oklch(0.2 0.006 230)',
// Textes
foreground: 'oklch(0.97 0.003 230)', // Blanc grisâtre
'foreground-secondary': 'oklch(0.75 0.008 230)',
'foreground-muted': 'oklch(0.55 0.01 230)',
// Primary
primary: 'oklch(0.55 0.08 230)', // Gris-bleu plus clair
'primary-hover': 'oklch(0.6 0.09 230)',
'primary-foreground': 'oklch(0.1 0 0)',
// Accents
accent: 'oklch(0.24 0.006 230)',
'accent-foreground': 'oklch(0.97 0.003 230)',
// Borders
border: 'oklch(0.28 0.01 230)',
'border-hover': 'oklch(0.38 0.012 230)',
// Functional
success: 'oklch(0.7 0.15 145)',
warning: 'oklch(0.8 0.12 70)',
destructive: 'oklch(0.65 0.18 25)',
},
} as const;
// =============================================================================
// OPTION 2: MONOCHROME GRIS (MINIMALISTE)
// =============================================================================
/**
* Noir et blanc avec subtilités de gris
* Style minimaliste ultra-moderne (Linear, Stripe, Apple)
* Absolument pas de couleur sauf pour les fonctionnalités
*/
export const MONOCHROME_THEME = {
name: 'Monochrome Gris',
description: 'Minimaliste et élégant - style Linear/Apple',
light: {
background: 'oklch(0.99 0 0)', // Blanc pur
card: 'oklch(1 0 0)', // Blanc pur
sidebar: 'oklch(0.96 0 0)', // Gris très pâle
input: 'oklch(0.98 0 0)',
foreground: 'oklch(0.15 0 0)', // Noir pur
'foreground-secondary': 'oklch(0.45 0 0)', // Gris moyen
'foreground-muted': 'oklch(0.6 0 0)', // Gris clair
primary: 'oklch(0.2 0 0)', // Gris foncé
'primary-hover': 'oklch(0.15 0 0)', // Noir
'primary-foreground': 'oklch(1 0 0)', // Blanc
accent: 'oklch(0.95 0 0)', // Gris très pâle
'accent-foreground': 'oklch(0.2 0 0)',
border: 'oklch(0.89 0 0)', // Gris très clair
'border-hover': 'oklch(0.75 0 0)',
success: 'oklch(0.6 0.15 145)', // Vert subtil
warning: 'oklch(0.7 0.12 70)', // Jaune subtil
destructive: 'oklch(0.55 0.18 25)', // Rouge subtil
},
dark: {
background: 'oklch(0.1 0 0)', // Noir pur
card: 'oklch(0.14 0 0)', // Gris très foncé
sidebar: 'oklch(0.08 0 0)', // Noir pur
input: 'oklch(0.16 0 0)',
foreground: 'oklch(0.98 0 0)', // Blanc pur
'foreground-secondary': 'oklch(0.7 0 0)', // Gris moyen
'foreground-muted': 'oklch(0.5 0 0)', // Gris foncé
primary: 'oklch(0.85 0 0)', // Gris clair
'primary-hover': 'oklch(0.9 0 0)', // Blanc
'primary-foreground': 'oklch(0.1 0 0)', // Noir
accent: 'oklch(0.2 0 0)', // Gris foncé
'accent-foreground': 'oklch(0.98 0 0)',
border: 'oklch(0.25 0 0)', // Gris foncé
'border-hover': 'oklch(0.35 0 0)',
success: 'oklch(0.65 0.15 145)',
warning: 'oklch(0.75 0.12 70)',
destructive: 'oklch(0.6 0.18 25)',
},
} as const;
// =============================================================================
// OPTION 3: VIOLET PROFOND (INDIGO)
// =============================================================================
/**
* Violet profond élégant et moderne
* Entre le bleu et le violet
* Très professionnel (Discord, Notion, Figma)
*/
export const INDIGO_THEME = {
name: 'Violet Profond',
description: 'Élégant et moderne - style Discord/Notion',
light: {
background: 'oklch(0.99 0.005 260)', // Blanc légèrement violacé
card: 'oklch(1 0 0)',
sidebar: 'oklch(0.96 0.006 260)',
input: 'oklch(0.97 0.005 260)',
foreground: 'oklch(0.2 0.015 260)', // Gris-violet foncé
'foreground-secondary': 'oklch(0.45 0.012 260)',
'foreground-muted': 'oklch(0.6 0.008 260)',
primary: 'oklch(0.55 0.18 260)', // Violet profond
'primary-hover': 'oklch(0.5 0.2 260)', // Violet plus foncé
'primary-foreground': 'oklch(0.99 0 0)',
accent: 'oklch(0.94 0.008 260)',
'accent-foreground': 'oklch(0.2 0.015 260)',
border: 'oklch(0.9 0.01 260)',
'border-hover': 'oklch(0.82 0.012 260)',
success: 'oklch(0.65 0.15 145)',
warning: 'oklch(0.75 0.12 70)',
destructive: 'oklch(0.6 0.18 25)',
},
dark: {
background: 'oklch(0.14 0.008 260)', // Noir légèrement violacé
card: 'oklch(0.18 0.01 260)', // Gris-violet foncé
sidebar: 'oklch(0.12 0.008 260)',
input: 'oklch(0.2 0.01 260)',
foreground: 'oklch(0.97 0.005 260)', // Blanc légèrement violacé
'foreground-secondary': 'oklch(0.75 0.008 260)',
'foreground-muted': 'oklch(0.55 0.01 260)',
primary: 'oklch(0.65 0.18 260)', // Violet plus clair
'primary-hover': 'oklch(0.7 0.2 260)',
'primary-foreground': 'oklch(0.1 0 0)',
accent: 'oklch(0.24 0.01 260)',
'accent-foreground': 'oklch(0.97 0.005 260)',
border: 'oklch(0.28 0.012 260)',
'border-hover': 'oklch(0.38 0.015 260)',
success: 'oklch(0.7 0.15 145)',
warning: 'oklch(0.8 0.12 70)',
destructive: 'oklch(0.65 0.18 25)',
},
} as const;
// =============================================================================
// OPTION 4: TEAL (TURQUOISE)
// =============================================================================
/**
* Turquoise/teal moderne et rafraîchissante
* Entre le bleu et le vert
* Très appréciée dans le design moderne (Linear, Atlassian)
*/
export const TEAL_THEME = {
name: 'Teal (Turquoise)',
description: 'Moderne et rafraîchissant - style Atlassian/Linear',
light: {
background: 'oklch(0.99 0.003 195)', // Blanc légèrement teinté
card: 'oklch(1 0 0)',
sidebar: 'oklch(0.96 0.004 195)',
input: 'oklch(0.97 0.003 195)',
foreground: 'oklch(0.2 0.015 195)', // Gris-teal foncé
'foreground-secondary': 'oklch(0.45 0.012 195)',
'foreground-muted': 'oklch(0.6 0.008 195)',
primary: 'oklch(0.55 0.14 195)', // Teal moderne
'primary-hover': 'oklch(0.5 0.16 195)', // Teal plus foncé
'primary-foreground': 'oklch(0.99 0 0)',
accent: 'oklch(0.94 0.005 195)',
'accent-foreground': 'oklch(0.2 0.015 195)',
border: 'oklch(0.9 0.008 195)',
'border-hover': 'oklch(0.82 0.01 195)',
success: 'oklch(0.65 0.15 145)',
warning: 'oklch(0.75 0.12 70)',
destructive: 'oklch(0.6 0.18 25)',
},
dark: {
background: 'oklch(0.14 0.005 195)', // Noir légèrement teinté
card: 'oklch(0.18 0.006 195)', // Gris-teal foncé
sidebar: 'oklch(0.12 0.005 195)',
input: 'oklch(0.2 0.006 195)',
foreground: 'oklch(0.97 0.003 195)', // Blanc légèrement teinté
'foreground-secondary': 'oklch(0.75 0.006 195)',
'foreground-muted': 'oklch(0.55 0.008 195)',
primary: 'oklch(0.65 0.14 195)', // Teal plus clair
'primary-hover': 'oklch(0.7 0.16 195)',
'primary-foreground': 'oklch(0.1 0 0)',
accent: 'oklch(0.24 0.006 195)',
'accent-foreground': 'oklch(0.97 0.003 195)',
border: 'oklch(0.28 0.01 195)',
'border-hover': 'oklch(0.38 0.012 195)',
success: 'oklch(0.7 0.15 145)',
warning: 'oklch(0.8 0.12 70)',
destructive: 'oklch(0.65 0.18 25)',
},
} as const;
// =============================================================================
// RÉSUMÉ DES OPTIONS
// =============================================================================
/**
* Comparaison des options :
*
* | Option | Modernité | Professionnalisme | Fatigue oculaire | Unicité |
* |--------|-----------|-------------------|------------------|----------|
* | Slate (Gris-Bleu) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
* | Monochrome | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
* | Indigo | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
* | Teal | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
*
* Recommandation : Slate (Gris-Bleu)
* - Le plus professionnel
* - Fatigue oculaire minimale
* - Très moderne et tendance
* - Différent du bleu traditionnel
* - Cohérent avec votre suggestion
*/
export type ThemeOption = 'slate' | 'monochrome' | 'indigo' | 'teal';
/**
* Pour choisir le thème :
*
* function getTheme(option: ThemeOption) {
* switch(option) {
* case 'slate': return SLATE_THEME;
* case 'monochrome': return MONOCHROME_THEME;
* case 'indigo': return INDIGO_THEME;
* case 'teal': return TEAL_THEME;
* }
* }
*/

View File

@@ -0,0 +1,23 @@
// @ts-ignore - Generated client
import { PrismaClient } from '../prisma/client-generated'
const prismaClientSingleton = () => {
return new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
})
}
declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
export { prisma }
export default prisma
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma

View File

@@ -0,0 +1,31 @@
export function getThemeScript(theme: string = 'light') {
return `
(function() {
try {
var localTheme = localStorage.getItem('theme-preference');
var theme = localTheme || '${theme}';
var root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
if (theme === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
root.classList.add('dark');
}
} else if (theme === 'dark') {
root.classList.add('dark');
} else if (theme === 'light') {
// do nothing
} else {
root.setAttribute('data-theme', theme);
if (theme === 'midnight') {
root.classList.add('dark');
}
}
} catch (e) {
console.error('Theme script error', e);
}
})();
`
}

207
memento-note/lib/types.ts Normal file
View File

@@ -0,0 +1,207 @@
export interface CheckItem {
id: string;
text: string;
checked: boolean;
}
/**
* Notebook model for organizing notes
*/
export interface Notebook {
id: string;
name: string;
icon: string | null;
color: string | null;
order: number;
userId: string;
createdAt: Date;
updatedAt: Date;
// Relations
notes?: Note[];
labels?: Label[];
}
/**
* Label model - contextual to notebooks
*/
export interface Label {
id: string;
name: string;
color: LabelColorName;
notebookId: string | null; // NEW: Belongs to a notebook
userId?: string | null; // DEPRECATED: Kept for backward compatibility
createdAt: Date;
updatedAt: Date;
// Relations
notebook?: Notebook | null;
}
export interface LinkMetadata {
url: string;
title?: string;
description?: string;
imageUrl?: string;
siteName?: string;
}
export interface Note {
id: string;
title: string | null;
content: string;
color: string;
isPinned: boolean;
isArchived: boolean;
type: 'text' | 'checklist';
checkItems: CheckItem[] | null;
labels: string[] | null; // DEPRECATED: Array of label names stored as JSON string
images: string[] | null;
links: LinkMetadata[] | null;
reminder: Date | null;
reminderRecurrence: string | null;
reminderLocation: string | null;
isMarkdown: boolean;
size: NoteSize;
order: number;
createdAt: Date;
updatedAt: Date;
contentUpdatedAt: Date;
embedding?: number[] | null;
sharedWith?: string[];
userId?: string | null;
// NEW: Notebook relation (optional - null = "Notes générales" / Inbox)
notebookId?: string | null;
notebook?: Notebook | null;
autoGenerated?: boolean | null;
// Search result metadata (optional)
matchType?: 'exact' | 'related' | null;
searchScore?: number | null;
}
export type NoteSize = 'small' | 'medium' | 'large';
export interface LabelWithColor {
name: string;
color: LabelColorName;
}
export const LABEL_COLORS = {
gray: {
bg: 'bg-zinc-100 dark:bg-zinc-800',
text: 'text-zinc-700 dark:text-zinc-300',
border: 'border-zinc-200 dark:border-zinc-700',
icon: 'text-zinc-500 dark:text-zinc-400'
},
red: {
bg: 'bg-rose-100 dark:bg-rose-900/40',
text: 'text-rose-700 dark:text-rose-300',
border: 'border-rose-200 dark:border-rose-800',
icon: 'text-rose-500 dark:text-rose-400'
},
orange: {
bg: 'bg-orange-100 dark:bg-orange-900/40',
text: 'text-orange-700 dark:text-orange-300',
border: 'border-orange-200 dark:border-orange-800',
icon: 'text-orange-500 dark:text-orange-400'
},
yellow: {
bg: 'bg-amber-100 dark:bg-amber-900/40',
text: 'text-amber-700 dark:text-amber-300',
border: 'border-amber-200 dark:border-amber-800',
icon: 'text-amber-500 dark:text-amber-400'
},
green: {
bg: 'bg-emerald-100 dark:bg-emerald-900/40',
text: 'text-emerald-700 dark:text-emerald-300',
border: 'border-emerald-200 dark:border-emerald-800',
icon: 'text-emerald-500 dark:text-emerald-400'
},
teal: {
bg: 'bg-teal-100 dark:bg-teal-900/40',
text: 'text-teal-700 dark:text-teal-300',
border: 'border-teal-200 dark:border-teal-800',
icon: 'text-teal-500 dark:text-teal-400'
},
blue: {
bg: 'bg-sky-100 dark:bg-sky-900/40',
text: 'text-sky-700 dark:text-sky-300',
border: 'border-sky-200 dark:border-sky-800',
icon: 'text-sky-500 dark:text-sky-400'
},
purple: {
bg: 'bg-violet-100 dark:bg-violet-900/40',
text: 'text-violet-700 dark:text-violet-300',
border: 'border-violet-200 dark:border-violet-800',
icon: 'text-violet-500 dark:text-violet-400'
},
pink: {
bg: 'bg-fuchsia-100 dark:bg-fuchsia-900/40',
text: 'text-fuchsia-700 dark:text-fuchsia-300',
border: 'border-fuchsia-200 dark:border-fuchsia-800',
icon: 'text-fuchsia-500 dark:text-fuchsia-400'
},
} as const;
export type LabelColorName = keyof typeof LABEL_COLORS
export const NOTE_COLORS = {
default: {
bg: 'bg-white dark:bg-zinc-900',
hover: 'hover:bg-gray-50 dark:hover:bg-zinc-800',
card: 'bg-white dark:bg-zinc-900 border-gray-200 dark:border-zinc-700'
},
red: {
bg: 'bg-red-50 dark:bg-red-950/30',
hover: 'hover:bg-red-100 dark:hover:bg-red-950/50',
card: 'bg-red-50 dark:bg-red-950/30 border-red-100 dark:border-red-900/50'
},
orange: {
bg: 'bg-orange-50 dark:bg-orange-950/30',
hover: 'hover:bg-orange-100 dark:hover:bg-orange-950/50',
card: 'bg-orange-50 dark:bg-orange-950/30 border-orange-100 dark:border-orange-900/50'
},
yellow: {
bg: 'bg-yellow-50 dark:bg-yellow-950/30',
hover: 'hover:bg-yellow-100 dark:hover:bg-yellow-950/50',
card: 'bg-yellow-50 dark:bg-yellow-950/30 border-yellow-100 dark:border-yellow-900/50'
},
green: {
bg: 'bg-green-50 dark:bg-green-950/30',
hover: 'hover:bg-green-100 dark:hover:bg-green-950/50',
card: 'bg-green-50 dark:bg-green-950/30 border-green-100 dark:border-green-900/50'
},
teal: {
bg: 'bg-teal-50 dark:bg-teal-950/30',
hover: 'hover:bg-teal-100 dark:hover:bg-teal-950/50',
card: 'bg-teal-50 dark:bg-teal-950/30 border-teal-100 dark:border-teal-900/50'
},
blue: {
bg: 'bg-blue-50 dark:bg-blue-950/30',
hover: 'hover:bg-blue-100 dark:hover:bg-blue-950/50',
card: 'bg-blue-50 dark:bg-blue-950/30 border-blue-100 dark:border-blue-900/50'
},
purple: {
bg: 'bg-purple-50 dark:bg-purple-950/30',
hover: 'hover:bg-purple-100 dark:hover:bg-purple-950/50',
card: 'bg-purple-50 dark:bg-purple-950/30 border-purple-100 dark:border-purple-900/50'
},
pink: {
bg: 'bg-pink-50 dark:bg-pink-950/30',
hover: 'hover:bg-pink-100 dark:hover:bg-pink-950/50',
card: 'bg-pink-50 dark:bg-pink-950/30 border-pink-100 dark:border-pink-900/50'
},
gray: {
bg: 'bg-gray-100 dark:bg-gray-800/50',
hover: 'hover:bg-gray-200 dark:hover:bg-gray-700/50',
card: 'bg-gray-100 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
},
} as const;
export type NoteColor = keyof typeof NOTE_COLORS;
/**
* Query types for adaptive search weighting
* - 'exact': User searched with quotes, looking for exact match (e.g., "Error 404")
* - 'conceptual': User is asking a question or looking for concepts (e.g., "how to cook")
* - 'mixed': No specific pattern detected, use default weights
*/
export type QueryType = 'exact' | 'conceptual' | 'mixed';

207
memento-note/lib/utils.ts Normal file
View File

@@ -0,0 +1,207 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { LABEL_COLORS, LabelColorName, QueryType } from "./types"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function getHashColor(name: string): LabelColorName {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const colors = Object.keys(LABEL_COLORS) as LabelColorName[];
// Skip 'gray' for colorful tags
const colorfulColors = colors.filter(c => c !== 'gray');
const colorIndex = Math.abs(hash) % colorfulColors.length;
return colorfulColors[colorIndex];
}
export function cosineSimilarity(vecA: number[], vecB: number[]): number {
if (vecA.length !== vecB.length) return 0;
let dotProduct = 0;
let mA = 0;
let mB = 0;
for (let i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i];
mA += vecA[i] * vecA[i];
mB += vecB[i] * vecB[i];
}
mA = Math.sqrt(mA);
mB = Math.sqrt(mB);
if (mA === 0 || mB === 0) return 0;
return dotProduct / (mA * mB);
}
/**
* Validate an embedding vector for quality issues
*/
export function validateEmbedding(embedding: number[]): { valid: boolean; issues: string[] } {
const issues: string[] = [];
// Check 1: Dimensionality > 0
if (!embedding || embedding.length === 0) {
issues.push('Embedding is empty or has zero dimensionality');
return { valid: false, issues };
}
// Check 2: Valid numbers (no NaN or Infinity)
let hasNaN = false;
let hasInfinity = false;
let hasZeroVector = true;
for (let i = 0; i < embedding.length; i++) {
const val = embedding[i];
if (isNaN(val)) hasNaN = true;
if (!isFinite(val)) hasInfinity = true;
if (val !== 0) hasZeroVector = false;
}
if (hasNaN) {
issues.push('Embedding contains NaN values');
}
if (hasInfinity) {
issues.push('Embedding contains Infinity values');
}
if (hasZeroVector) {
issues.push('Embedding is a zero vector (all values are 0)');
}
// Check 3: L2 norm is in reasonable range (0.7 to 1.2)
const l2Norm = calculateL2Norm(embedding);
if (l2Norm < 0.7 || l2Norm > 1.2) {
issues.push(`L2 norm is ${l2Norm.toFixed(3)} (expected range: 0.7-1.2)`);
}
return {
valid: issues.length === 0,
issues
};
}
/**
* Calculate L2 norm of a vector
*/
export function calculateL2Norm(vector: number[]): number {
let sum = 0;
for (let i = 0; i < vector.length; i++) {
sum += vector[i] * vector[i];
}
return Math.sqrt(sum);
}
/**
* Normalize an embedding to unit L2 norm
*/
export function normalizeEmbedding(embedding: number[]): number[] {
const norm = calculateL2Norm(embedding);
if (norm === 0) return embedding; // Can't normalize zero vector
return embedding.map(val => val / norm);
}
/**
* Calculate the RRF (Reciprocal Rank Fusion) constant k
*
* RRF Formula: score = Σ 1 / (k + rank)
*
* The k constant controls how much we penalize lower rankings:
* - Lower k (e.g., 20) penalizes low ranks more heavily
* - Higher k (e.g., 60) is more lenient with low ranks
*
* Adaptive formula: k = max(20, totalNotes / 10)
* - For small datasets (< 200 notes): k = 20 (strict)
* - For larger datasets: k scales linearly
*
* Examples:
* - 50 notes → k = 20
* - 200 notes → k = 20
* - 500 notes → k = 50
* - 1000 notes → k = 100
*/
export function calculateRRFK(totalNotes: number): number {
const BASE_K = 20;
const adaptiveK = Math.floor(totalNotes / 10);
return Math.max(BASE_K, adaptiveK);
}
/**
* Detect the type of search query to adapt search weights
*
* Detection rules:
* 1. EXACT: Query contains quotes (e.g., "Error 404")
* 2. CONCEPTUAL: Query starts with question words or is a phrase like "how to X"
* 3. MIXED: No specific pattern detected
*
* Examples:
* - "exact phrase" → 'exact'
* - "how to cook pasta" → 'conceptual'
* - "what is python" → 'conceptual'
* - "javascript tutorial" → 'mixed'
*/
export function detectQueryType(query: string): QueryType {
const trimmed = query.trim().toLowerCase();
// Rule 1: Check for quotes (exact match)
if ((query.startsWith('"') && query.endsWith('"')) ||
(query.startsWith("'") && query.endsWith("'"))) {
return 'exact';
}
// Rule 2: Check for conceptual patterns
const conceptualPatterns = [
/^(how|what|when|where|why|who|which|whose|can|could|would|should|is|are|do|does|did)\b/,
/^(how to|ways to|best way to|guide for|tips for|learn about|understand)/,
/^(tutorial|guide|introduction|overview|explanation|examples)/,
];
for (const pattern of conceptualPatterns) {
if (pattern.test(trimmed)) {
return 'conceptual';
}
}
// Default: mixed search
return 'mixed';
}
/**
* Get search weight multipliers based on query type
*
* Returns keyword and semantic weight multipliers:
* - EXACT: Boost keyword matches (2.0x), reduce semantic (0.7x)
* - CONCEPTUAL: Reduce keyword (0.7x), boost semantic (1.5x)
* - MIXED: Default weights (1.0x, 1.0x)
*/
export function getSearchWeights(queryType: QueryType): {
keywordWeight: number;
semanticWeight: number;
} {
switch (queryType) {
case 'exact':
return {
keywordWeight: 2.0,
semanticWeight: 0.7
};
case 'conceptual':
return {
keywordWeight: 0.7,
semanticWeight: 1.5
};
case 'mixed':
default:
return {
keywordWeight: 1.0,
semanticWeight: 1.0
};
}
}

View File

@@ -0,0 +1,69 @@
/**
* Date formatting utilities for recent notes display
*/
/**
* Format a date as relative time in English (e.g., "2 hours ago", "yesterday")
*/
export function formatRelativeTime(date: Date | string): string {
const now = new Date()
const then = new Date(date)
const seconds = Math.floor((now.getTime() - then.getTime()) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) {
return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours} hour${hours > 1 ? 's' : ''} ago`
}
const days = Math.floor(hours / 24)
if (days < 7) {
return `${days} day${days > 1 ? 's' : ''} ago`
}
// For dates older than 7 days, show absolute date
return then.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: then.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
})
}
/**
* Format a date as relative time in French (e.g., "il y a 2 heures", "hier")
*/
export function formatRelativeTimeFR(date: Date | string): string {
const now = new Date()
const then = new Date(date)
const seconds = Math.floor((now.getTime() - then.getTime()) / 1000)
if (seconds < 60) return "à l'instant"
const minutes = Math.floor(seconds / 60)
if (minutes < 60) {
return `il y a ${minutes} minute${minutes > 1 ? 's' : ''}`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `il y a ${hours} heure${hours > 1 ? 's' : ''}`
}
const days = Math.floor(hours / 24)
if (days < 7) {
return `il y a ${days} jour${days > 1 ? 's' : ''}`
}
// For dates older than 7 days, show absolute date
return then.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: then.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
})
}