chore: clean up repo for public release

- Remove BMAD framework, IDE configs, dev screenshots, test files,
  internal docs, and backup files
- Rename keep-notes/ to memento-note/
- Update all references from keep-notes to memento-note
- Add Apache 2.0 license with Commons Clause (non-commercial restriction)
- Add clean .gitignore and .env.docker.example
This commit is contained in:
Sepehr Ramezani
2026-04-20 22:48:06 +02:00
parent 402e88b788
commit e4d4e23dc7
3981 changed files with 407 additions and 530622 deletions

View File

@@ -0,0 +1,167 @@
import { promises as fs } from 'fs'
import path from 'path'
import { randomUUID } from 'crypto'
export interface EmailAttachment {
filename: string
content: Buffer
cid: string
}
interface AgentEmailParams {
agentName: string
content: string
appUrl: string
userName?: string
}
/**
* Read a local image file from the public directory.
*/
async function readLocalImage(relativePath: string): Promise<Buffer | null> {
try {
const filePath = path.join(process.cwd(), 'public', relativePath)
return await fs.readFile(filePath)
} catch {
return null
}
}
/**
* Convert markdown to simple HTML suitable for email clients.
* Replaces local image references with cid: placeholders for inline attachments.
* Returns the HTML and a list of attachments to include.
*/
export async function markdownToEmailHtml(md: string, appUrl: string): Promise<{ html: string; attachments: EmailAttachment[] }> {
let html = md
const attachments: EmailAttachment[] = []
const baseUrl = appUrl.replace(/\/$/, '')
// Remove the execution footer (agent trace)
html = html.replace(/\n---\n\n_\$Agent execution:[\s\S]*$/, '')
html = html.replace(/\n---\n\n_Agent execution:[\s\S]*$/, '')
// Horizontal rules
html = html.replace(/^---+$/gm, '<hr style="border:none;border-top:1px solid #e5e7eb;margin:20px 0;">')
// Headings
html = html.replace(/^### (.+)$/gm, '<h3 style="margin:16px 0 8px;font-size:15px;font-weight:600;color:#1f2937;">$1</h3>')
html = html.replace(/^## (.+)$/gm, '<h2 style="margin:20px 0 10px;font-size:16px;font-weight:700;color:#111827;">$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1 style="margin:0 0 16px;font-size:18px;font-weight:700;color:#111827;">$1</h1>')
// Bold and italic
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em style="color:#6b7280;">$1</em>')
// Unordered list items
html = html.replace(/^(\s*)[-*] (.+)$/gm, '$1<li style="margin:4px 0;padding-left:4px;">$2</li>')
// Wrap consecutive <li> in <ul>
html = html.replace(/((?:<li[^>]*>.*?<\/li>\s*)+)/g, (match) => {
return '<ul style="margin:8px 0;padding-left:20px;list-style-type:disc;">' + match + '</ul>'
})
// Images ![alt](url) — local images become CID attachments, external stay as-is
const imageMatches = [...html.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/g)]
for (const match of imageMatches) {
const [fullMatch, alt, url] = match
let imgTag: string
if (url.startsWith('/uploads/')) {
// Local image: read file and attach as CID
const buffer = await readLocalImage(url)
if (buffer) {
const cid = `img-${randomUUID()}`
const ext = path.extname(url).toLowerCase() || '.jpg'
attachments.push({ filename: `image${ext}`, content: buffer, cid })
imgTag = `<img src="cid:${cid}" alt="${alt}" style="max-width:100%;border-radius:8px;margin:12px 0;" />`
} else {
// Fallback to absolute URL if file not found
imgTag = `<img src="${baseUrl}${url}" alt="${alt}" style="max-width:100%;border-radius:8px;margin:12px 0;" />`
}
} else {
imgTag = `<img src="${url}" alt="${alt}" style="max-width:100%;border-radius:8px;margin:12px 0;" />`
}
html = html.replace(fullMatch, imgTag)
}
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => {
const absoluteUrl = url.startsWith('/') ? `${baseUrl}${url}` : url
return `<a href="${absoluteUrl}" style="color:#3b82f6;text-decoration:none;">${text}</a>`
})
// Paragraphs
html = html.replace(/\n\n+/g, '</p><p style="margin:0 0 12px;">')
html = html.replace(/\n/g, '<br>')
html = '<p style="margin:0 0 12px;">' + html + '</p>'
html = html.replace(/<p[^>]*>\s*<\/p>/g, '')
return { html, attachments }
}
export async function getAgentEmailTemplate({ agentName, content, appUrl, userName }: AgentEmailParams): Promise<{ html: string; attachments: EmailAttachment[] }> {
const greeting = userName ? `Bonjour ${userName},` : 'Bonjour,'
const { html: htmlContent, attachments } = await markdownToEmailHtml(content, appUrl)
// Extract a preview (first ~150 chars of plain text for subtitle)
const plainText = content
.replace(/^#{1,3}\s+/gm, '')
.replace(/\*\*/g, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/[-*]\s+/g, '')
.replace(/\n+/g, ' ')
.trim()
const html = `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${agentName}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #374151; background: #f3f4f6; margin: 0; padding: 0; }
.wrapper { width: 100%; background: #f3f4f6; padding: 32px 16px; }
.container { max-width: 620px; margin: 0 auto; }
.card { background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.04); }
.card-header { background: linear-gradient(135deg, #1e293b 0%, #334155 100%); padding: 28px 32px; }
.card-header h1 { margin: 0; font-size: 20px; font-weight: 700; color: #ffffff; }
.card-header .subtitle { margin: 6px 0 0; font-size: 13px; color: #94a3b8; }
.card-header .badge { display: inline-block; background: rgba(59,130,246,0.2); color: #93c5fd; font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 9999px; margin-top: 10px; letter-spacing: 0.5px; text-transform: uppercase; }
.card-body { padding: 28px 32px; font-size: 14px; color: #374151; }
.card-footer { padding: 20px 32px; border-top: 1px solid #f1f5f9; text-align: center; background: #fafbfc; }
.button { display: inline-block; padding: 12px 28px; background-color: #1e293b; color: #ffffff; text-decoration: none; border-radius: 10px; font-weight: 600; font-size: 14px; letter-spacing: 0.3px; }
.button:hover { background-color: #334155; }
.footer-text { margin-top: 20px; font-size: 12px; color: #9ca3af; text-align: center; }
.footer-text a { color: #64748b; text-decoration: none; }
.footer-text a:hover { text-decoration: underline; }
.date { font-size: 12px; color: #9ca3af; margin-top: 4px; }
</style>
</head>
<body>
<div class="wrapper">
<div class="container">
<div class="card">
<div class="card-header">
<h1>${agentName}</h1>
<div class="subtitle">${plainText.substring(0, 120)}${plainText.length > 120 ? '...' : ''}</div>
<span class="badge">Synthèse automatique</span>
</div>
<div class="card-body">
<p style="margin:0 0 8px;color:#6b7280;font-size:13px;">${greeting}</p>
<p style="margin:0 0 20px;color:#6b7280;font-size:13px;">Votre agent <strong style="color:#1f2937;">${agentName}</strong> a terminé son exécution. Voici les résultats :</p>
<hr style="border:none;border-top:1px solid #f1f5f9;margin:0 0 20px;">
${htmlContent}
</div>
<div class="card-footer">
<a href="${appUrl}" class="button">Ouvrir dans Memento</a>
</div>
</div>
<p class="footer-text">Cet email a été envoyé par votre agent Memento &middot; <a href="${appUrl}/agents">Gérer mes agents</a></p>
</div>
</div>
</body>
</html>`
return { html, attachments }
}

View File

@@ -0,0 +1,158 @@
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 ||
config?.AI_PROVIDER_EMBEDDING ||
config?.AI_PROVIDER ||
process.env.AI_PROVIDER_TAGS ||
process.env.AI_PROVIDER_EMBEDDING ||
process.env.AI_PROVIDER
);
// If no provider is configured, throw a clear error
if (!providerType) {
console.error('[getTagsProvider] FATAL: No provider configured. Config received:', config);
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 ||
config?.AI_PROVIDER_TAGS ||
config?.AI_PROVIDER ||
process.env.AI_PROVIDER_EMBEDDING ||
process.env.AI_PROVIDER_TAGS ||
process.env.AI_PROVIDER
);
// If no provider is configured, throw a clear error
if (!providerType) {
console.error('[getEmbeddingsProvider] FATAL: No provider configured. Config received:', config);
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);
}
export function getAIProvider(config?: Record<string, string>): AIProvider {
return getEmbeddingsProvider(config);
}
export function getChatProvider(config?: Record<string, string>): AIProvider {
// Check database config first, then environment variables
// Fallback cascade: chat -> tags -> embeddings
const providerType = (
config?.AI_PROVIDER_CHAT ||
config?.AI_PROVIDER_TAGS ||
config?.AI_PROVIDER_EMBEDDING ||
config?.AI_PROVIDER ||
process.env.AI_PROVIDER_CHAT ||
process.env.AI_PROVIDER_TAGS ||
process.env.AI_PROVIDER_EMBEDDING ||
process.env.AI_PROVIDER
);
// If no provider is configured, throw a clear error
if (!providerType) {
console.error('[getChatProvider] FATAL: No provider configured. Config received:', config);
throw new Error(
'AI_PROVIDER_CHAT 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_CHAT ||
process.env.AI_MODEL_CHAT ||
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);
}

View File

@@ -0,0 +1,147 @@
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject, generateText as aiGenerateText, embed, stepCountIs } from 'ai';
import { z } from 'zod';
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types';
export class CustomOpenAIProvider implements AIProvider {
private model: any;
private embeddingModel: any;
private apiKey: string;
private baseUrl: string;
constructor(
apiKey: string,
baseUrl: string,
modelName: string = 'gpt-4o-mini',
embeddingModelName: string = 'text-embedding-3-small'
) {
this.apiKey = apiKey;
this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
// Create OpenAI-compatible client with custom base URL
// Use .chat() to force /chat/completions endpoint (avoids Responses API)
const customClient = createOpenAI({
baseURL: baseUrl,
apiKey: apiKey,
fetch: async (url, options) => {
const headers = new Headers(options?.headers);
headers.set('HTTP-Referer', 'https://localhost:3000');
headers.set('X-Title', 'Memento AI');
return fetch(url, { ...options, headers });
}
});
this.model = customClient.chat(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 aiGenerateText({
model: this.model,
prompt: prompt,
});
return text.trim();
} catch (e) {
console.error('Erreur génération texte Custom OpenAI:', e);
throw e;
}
}
async chat(messages: any[], systemPrompt?: string): Promise<any> {
try {
const { text } = await aiGenerateText({
model: this.model,
system: systemPrompt,
messages: messages,
});
return { text: text.trim() };
} catch (e) {
console.error('Erreur chat Custom OpenAI:', e);
throw e;
}
}
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
const opts: Record<string, any> = {
model: this.model,
tools,
stopWhen: stepCountIs(maxSteps),
}
if (systemPrompt) opts.system = systemPrompt
if (messages) opts.messages = messages
else if (prompt) opts.prompt = prompt
const result = await aiGenerateText(opts as any)
return {
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
text: result.text,
steps: result.steps?.map((step: any) => ({
text: step.text,
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || []
})) || []
}
}
getModel() {
return this.model;
}
}

View File

@@ -0,0 +1,131 @@
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject, generateText as aiGenerateText, embed, stepCountIs } from 'ai';
import { z } from 'zod';
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } 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.chat(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 aiGenerateText({
model: this.model,
prompt: prompt,
});
return text.trim();
} catch (e) {
console.error('Erreur génération texte DeepSeek:', e);
throw e;
}
}
async chat(messages: any[], systemPrompt?: string): Promise<any> {
try {
const { text } = await aiGenerateText({
model: this.model,
system: systemPrompt,
messages: messages,
});
return { text: text.trim() };
} catch (e) {
console.error('Erreur chat DeepSeek:', e);
throw e;
}
}
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
const opts: Record<string, any> = {
model: this.model,
tools,
stopWhen: stepCountIs(maxSteps),
}
if (systemPrompt) opts.system = systemPrompt
if (messages) opts.messages = messages
else if (prompt) opts.prompt = prompt
const result = await aiGenerateText(opts as any)
return {
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
text: result.text,
steps: result.steps?.map((step: any) => ({
text: step.text,
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || []
})) || []
}
}
getModel() {
return this.model;
}
}

View File

@@ -0,0 +1,222 @@
import { createOpenAI } from '@ai-sdk/openai';
import { generateText as aiGenerateText, stepCountIs } from 'ai';
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types';
export class OllamaProvider implements AIProvider {
private baseUrl: string;
private modelName: string;
private embeddingModelName: string;
private model: any;
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;
// Create OpenAI-compatible model for streaming support
// Ollama exposes /v1/chat/completions which is compatible with the OpenAI SDK
const cleanUrl = this.baseUrl.replace(/\/api$/, '');
const ollamaClient = createOpenAI({
baseURL: `${cleanUrl}/v1`,
apiKey: 'ollama',
});
this.model = ollamaClient.chat(modelName);
}
async generateTags(content: string, language: string = "en"): Promise<TagSuggestion[]> {
try {
const promptText = language === 'fa'
? `متن زیر را تحلیل کن و مفاهیم کلیدی را به عنوان برچسب استخراج کن (حداکثر ۱-۳ کلمه).
قوانین:
- کلمات ربط را حذف کن.
- عبارات ترکیبی را حفظ کن.
- حداکثر ۵ برچسب.
پاسخ فقط به صورت لیست JSON با فرمت [{"tag": "string", "confidence": number}]
متن: "${content}"`
: language === 'fr'
? `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.
- Garde les expressions composées ensemble.
- 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}"`
: `Analyze the following note and extract key concepts as short tags (1-3 words max).
Rules:
- No stop words.
- Keep compound expressions together.
- Lowercase unless proper noun.
- Max 5 tags.
Respond ONLY as a JSON list of objects: [{"tag": "string", "confidence": number}].
Note content: "${content}"`;
const response = await fetch(`${this.baseUrl}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
prompt: promptText,
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}\n\nRé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;
}
}
async chat(messages: any[], systemPrompt?: string): Promise<any> {
try {
const ollamaMessages = messages.map(m => ({
role: m.role,
content: m.content
}));
if (systemPrompt) {
ollamaMessages.unshift({ role: 'system', content: systemPrompt });
}
const response = await fetch(`${this.baseUrl}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
messages: ollamaMessages,
stream: false,
}),
});
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
const data = await response.json();
return { text: data.message?.content?.trim() || '' };
} catch (e) {
console.error('Erreur chat Ollama:', e);
throw e;
}
}
getModel() {
return this.model;
}
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
const opts: Record<string, any> = {
model: this.model,
tools,
stopWhen: stepCountIs(maxSteps),
}
if (systemPrompt) opts.system = systemPrompt
if (messages) opts.messages = messages
else if (prompt) opts.prompt = prompt
const result = await aiGenerateText(opts as any)
return {
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
text: result.text,
steps: result.steps?.map((step: any) => ({
text: step.text,
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || []
})) || []
}
}
}

View File

@@ -0,0 +1,131 @@
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject, generateText as aiGenerateText, embed, stepCountIs } from 'ai';
import { z } from 'zod';
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } 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
// Use .chat() to force /chat/completions endpoint (avoids Responses API)
const openaiClient = createOpenAI({
apiKey: apiKey,
});
this.model = openaiClient.chat(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 aiGenerateText({
model: this.model,
prompt: prompt,
});
return text.trim();
} catch (e) {
console.error('Erreur génération texte OpenAI:', e);
throw e;
}
}
async chat(messages: any[], systemPrompt?: string): Promise<any> {
try {
const { text } = await aiGenerateText({
model: this.model,
system: systemPrompt,
messages: messages,
});
return { text: text.trim() };
} catch (e) {
console.error('Erreur chat OpenAI:', e);
throw e;
}
}
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
const opts: Record<string, any> = {
model: this.model,
tools,
stopWhen: stepCountIs(maxSteps),
}
if (systemPrompt) opts.system = systemPrompt
if (messages) opts.messages = messages
else if (prompt) opts.prompt = prompt
const result = await aiGenerateText(opts as any)
return {
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
text: result.text,
steps: result.steps?.map((step: any) => ({
text: step.text,
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || []
})) || []
}
}
getModel() {
return this.model;
}
}

View File

@@ -0,0 +1,131 @@
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject, generateText as aiGenerateText, embed, stepCountIs } from 'ai';
import { z } from 'zod';
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } 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.chat(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 aiGenerateText({
model: this.model,
prompt: prompt,
});
return text.trim();
} catch (e) {
console.error('Erreur génération texte OpenRouter:', e);
throw e;
}
}
async chat(messages: any[], systemPrompt?: string): Promise<any> {
try {
const { text } = await aiGenerateText({
model: this.model,
system: systemPrompt,
messages: messages,
});
return { text: text.trim() };
} catch (e) {
console.error('Erreur chat OpenRouter:', e);
throw e;
}
}
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
const opts: Record<string, any> = {
model: this.model,
tools,
stopWhen: stepCountIs(maxSteps),
}
if (systemPrompt) opts.system = systemPrompt
if (messages) opts.messages = messages
else if (prompt) opts.prompt = prompt
const result = await aiGenerateText(opts as any)
return {
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
text: result.text,
steps: result.steps?.map((step: any) => ({
text: step.text,
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || []
})) || []
}
}
getModel() {
return this.model;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,491 @@
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,
trashedAt: null,
},
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 to notes: UI reads `Note.labels` (JSON string[]); relations must stay in sync
for (const noteId of suggestedLabel.noteIds) {
const note = await prisma.note.findFirst({
where: { id: noteId, userId, notebookId },
select: { labels: true },
})
if (!note) continue
let names: string[] = []
if (note.labels) {
try {
const parsed = note.labels as unknown
names = Array.isArray(parsed)
? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0)
: []
} catch {
names = []
}
}
const trimmed = suggestedLabel.name.trim()
if (!names.some((n) => n.toLowerCase() === trimmed.toLowerCase())) {
names = [...names, suggestedLabel.name]
}
await prisma.note.update({
where: { id: noteId },
data: {
labels: JSON.stringify(names),
labelRelations: {
connect: { id: label.id },
},
},
})
}
createdCount++
}
return createdCount
}
}
// Export singleton instance
export const autoLabelCreationService = new AutoLabelCreationService()

View File

@@ -0,0 +1,975 @@
import { prisma } from '@/lib/prisma'
import { getTagsProvider } 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,
trashedAt: 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)
const config = await getSystemConfig()
const provider = getTagsProvider(config)
const response = await provider.generateText(prompt)
const plan = this.parseAIResponse(response, notes, notebooks)
return plan
}
/**
* 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']
}
private normalizeForMatch(str: string): string {
return str.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim()
}
/**
* Parse AI response into OrganizationPlan
*/
private parseAIResponse(
response: string,
notes: NoteForOrganization[],
notebooks: any[]
): OrganizationPlan {
try {
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[] = []
for (const aiNotebook of aiData.carnets || []) {
const aiName = this.normalizeForMatch(aiNotebook.nom || '')
const notebook = notebooks.find(nb => this.normalizeForMatch(nb.name) === aiName)
|| notebooks.find(nb => this.normalizeForMatch(nb.name).includes(aiName) || aiName.includes(this.normalizeForMatch(nb.name)))
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, '\nRaw response:', response.substring(0, 500))
throw new Error(`AI response parsing failed: ${error instanceof Error ? error.message : 'Invalid JSON'}`)
}
}
/**
* 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,141 @@
/**
* Chat Service
* Handles conversational AI with context retrieval (RAG)
*/
import { semanticSearchService } from './semantic-search.service'
import { getChatProvider } from '../factory'
import { getSystemConfig } from '@/lib/config'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
// Default untitled text for fallback
const DEFAULT_UNTITLED = 'Untitled'
export interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
export interface ChatResponse {
message: string
conversationId?: string
suggestedNotes?: Array<{ id: string; title: string }>
}
export class ChatService {
/**
* Main chat entry point with context retrieval
*/
async chat(
message: string,
conversationId?: string,
notebookId?: string,
language: SupportedLanguage = 'en'
): Promise<ChatResponse> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
const userId = session.user.id
// Load translations for the requested language
const translations = await loadTranslations(language)
const untitledText = getTranslationValue(translations, 'notes.untitled') || DEFAULT_UNTITLED
const noNotesFoundText = getTranslationValue(translations, 'chat.noNotesFoundForContext') ||
'No relevant notes found for this question. Answer with your general knowledge.'
// 1. Manage Conversation
let conversation: any
if (conversationId) {
conversation = await prisma.conversation.findUnique({
where: { id: conversationId },
include: { messages: { orderBy: { createdAt: 'asc' }, take: 10 } }
})
}
if (!conversation) {
conversation = await prisma.conversation.create({
data: {
userId,
notebookId,
title: message.substring(0, 50) + '...'
},
include: { messages: true }
})
}
// 2. Retrieval (RAG)
// We search for relevant notes based on the current message or notebook context
// Lower threshold for notebook-specific searches to ensure we find relevant content
const searchResults = await semanticSearchService.search(message, {
notebookId,
limit: 10,
threshold: notebookId ? 0.3 : 0.5
})
const contextNotes = searchResults.map(r =>
`NOTE [${r.title || untitledText}]: ${r.content}`
).join('\n\n---\n\n')
// 3. System Prompt Synthesis
const systemPrompt = `Tu es l'Assistant IA de Memento. Tu accompagnes l'utilisateur dans sa réflexion.
Tes réponses doivent être concises, premium et utiles.
${contextNotes.length > 0 ? `Voici des extraits de notes de l'utilisateur qui pourraient t'aider à répondre :\n\n${contextNotes}\n\nUtilise ces informations si elles sont pertinentes, mais ne les cite pas mot pour mot sauf si demandé.` : noNotesFoundText}
Si l'utilisateur pose une question sur un carnet spécifique, reste focalisé sur ce contexte.`
// 4. Call AI Provider
const history = (conversation.messages || []).map((m: any) => ({
role: m.role,
content: m.content
}))
const currentMessages = [...history, { role: 'user', content: message }]
const config = await getSystemConfig()
const provider = getChatProvider(config)
const aiResponse = await provider.chat(currentMessages, systemPrompt)
// 5. Save Messages to DB
await prisma.chatMessage.createMany({
data: [
{ conversationId: conversation.id, role: 'user', content: message },
{ conversationId: conversation.id, role: 'assistant', content: aiResponse.text }
]
})
return {
message: aiResponse.text,
conversationId: conversation.id,
suggestedNotes: searchResults.map(r => ({ id: r.noteId, title: r.title || untitledText }))
}
}
/**
* Get conversation history
*/
async getHistory(conversationId: string) {
return prisma.conversation.findUnique({
where: { id: conversationId },
include: {
messages: {
orderBy: { createdAt: 'asc' }
}
}
})
}
/**
* List user conversations
*/
async listConversations(userId: string) {
return prisma.conversation.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' },
take: 20
})
}
}
export const chatService = new ChatService()

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,218 @@
/**
* 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)
}
/**
* Pass-through — embeddings are stored as native JSONB in PostgreSQL
*/
serialize(embedding: number[]): number[] {
return embedding
}
/**
* Pass-through — embeddings come back already parsed from PostgreSQL
*/
deserialize(embedding: number[]): number[] {
return 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,91 @@
/**
* 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'
// Chat
export {
ChatService,
chatService,
type ChatResponse
} from './chat.service'
// Scrape
export {
ScrapeService,
scrapeService,
type ScrapedContent
} from './scrape.service'
// Tool Registry
export {
toolRegistry,
type ToolContext,
type RegisteredTool
} from '../tools'

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,623 @@
import { getAIProvider } from '../factory'
import { cosineSimilarity } from '@/lib/utils'
import { getSystemConfig } from '@/lib/config'
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
/**
* Generate embeddings for notes that don't have one yet
*/
private async ensureEmbeddings(userId: string): Promise<void> {
const notesWithoutEmbeddings = await prisma.note.findMany({
where: {
userId,
isArchived: false,
trashedAt: null,
noteEmbedding: { is: null }
},
select: { id: true, content: true }
})
if (notesWithoutEmbeddings.length === 0) return
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
for (const note of notesWithoutEmbeddings) {
if (!note.content || note.content.trim().length === 0) continue
try {
const embedding = await provider.getEmbeddings(note.content)
if (embedding && embedding.length > 0) {
await prisma.noteEmbedding.upsert({
where: { noteId: note.id },
create: { noteId: note.id, embedding: JSON.stringify(embedding) },
update: { embedding: JSON.stringify(embedding) }
})
}
} catch {
// Skip this note, continue with others
}
}
} catch {
// Provider not configured — nothing we can do
}
}
/**
* Find meaningful connections between user's notes
*/
async findConnections(userId: string, demoMode: boolean = false): Promise<NoteConnection[]> {
// Ensure all notes have embeddings before searching for connections
await this.ensureEmbeddings(userId)
// Get all user's notes with embeddings
const notes = await prisma.note.findMany({
where: {
userId,
isArchived: false,
trashedAt: null,
noteEmbedding: { isNot: null } // Only notes with embeddings
},
select: {
id: true,
title: true,
content: true,
noteEmbedding: true,
createdAt: true
},
orderBy: { createdAt: 'desc' }
})
if (notes.length < 2) {
return [] // Need at least 2 notes to find connections
}
// Parse embeddings (already native Json from PostgreSQL)
const notesWithEmbeddings = notes
.map(note => ({
...note,
embedding: note.noteEmbedding?.embedding ? JSON.parse(note.noteEmbedding.embedding) as number[] : 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
// Load user feedback to adjust thresholds per note
const feedbackInsights = await prisma.memoryEchoInsight.findMany({
where: { userId, feedback: { not: null } },
select: { note1Id: true, note2Id: true, feedback: true }
})
const notePenalty = new Map<string, number>() // positive = higher threshold (penalty), negative = lower (boost)
for (const fi of feedbackInsights) {
if (fi.feedback === 'thumbs_down') {
notePenalty.set(fi.note1Id, (notePenalty.get(fi.note1Id) || 0) + 0.15)
notePenalty.set(fi.note2Id, (notePenalty.get(fi.note2Id) || 0) + 0.15)
} else if (fi.feedback === 'thumbs_up') {
notePenalty.set(fi.note1Id, (notePenalty.get(fi.note1Id) || 0) - 0.05)
notePenalty.set(fi.note2Id, (notePenalty.get(fi.note2Id) || 0) - 0.05)
}
}
// 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 (adjusted by feedback)
const adjustedThreshold = similarityThreshold
+ (notePenalty.get(note1.id) || 0)
+ (notePenalty.get(note2.id) || 0)
if (similarity >= adjustedThreshold) {
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 getSystemConfig()
const provider = getAIProvider(config)
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
// In demo mode, add milliseconds offset to avoid @@unique([userId, insightDate]) collision
const insightDateValue = demoMode
? new Date(Date.now() + Math.floor(Math.random() * 1000))
: new Date()
const insight = await prisma.memoryEchoInsight.create({
data: {
userId,
note1Id: newConnection.note1.id,
note2Id: newConnection.note2.id,
similarityScore: newConnection.similarityScore,
insight: insightText,
insightDate: insightDateValue,
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: {
timestamp: new Date().toISOString()
} as any
}
})
}
}
/**
* Get all connections for a specific note
*/
async getConnectionsForNote(noteId: string, userId: string): Promise<NoteConnection[]> {
// Ensure all notes have embeddings before searching
await this.ensureEmbeddings(userId)
// Get the note with embedding
const targetNote = await prisma.note.findUnique({
where: { id: noteId },
select: {
id: true,
title: true,
content: true,
noteEmbedding: true,
createdAt: true,
userId: true
}
})
if (!targetNote || targetNote.userId !== userId) {
return [] // Note not found or doesn't belong to user
}
if (!targetNote.noteEmbedding) {
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 },
isArchived: false,
trashedAt: null,
noteEmbedding: { isNot: null }
},
select: {
id: true,
title: true,
content: true,
noteEmbedding: true,
createdAt: true
},
orderBy: { createdAt: 'desc' }
})
if (otherNotes.length === 0) {
return []
}
// Target note embedding (already native Json from PostgreSQL)
const targetEmbedding = targetNote.noteEmbedding?.embedding ? JSON.parse(targetNote.noteEmbedding.embedding) as number[] : null
if (!targetEmbedding) return []
// 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
// Load user feedback to adjust thresholds
const feedbackInsights = await prisma.memoryEchoInsight.findMany({
where: { userId, feedback: { not: null } },
select: { note1Id: true, note2Id: true, feedback: true }
})
const notePenalty = new Map<string, number>()
for (const fi of feedbackInsights) {
if (fi.feedback === 'thumbs_down') {
notePenalty.set(fi.note1Id, (notePenalty.get(fi.note1Id) || 0) + 0.15)
notePenalty.set(fi.note2Id, (notePenalty.get(fi.note2Id) || 0) + 0.15)
} else if (fi.feedback === 'thumbs_up') {
notePenalty.set(fi.note1Id, (notePenalty.get(fi.note1Id) || 0) - 0.05)
notePenalty.set(fi.note2Id, (notePenalty.get(fi.note2Id) || 0) - 0.05)
}
}
const connections: NoteConnection[] = []
// Compare target note with all other notes
for (const otherNote of otherNotes) {
if (!otherNote.noteEmbedding) continue
const otherEmbedding = otherNote.noteEmbedding?.embedding ? JSON.parse(otherNote.noteEmbedding.embedding) as number[] : null
if (!otherEmbedding) continue
// 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 (adjusted by feedback)
const adjustedThreshold = similarityThreshold
+ (notePenalty.get(targetNote.id) || 0)
+ (notePenalty.get(otherNote.id) || 0)
if (similarity >= adjustedThreshold) {
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,381 @@
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,
trashedAt: null,
},
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,92 @@
/**
* RSS/Atom Feed Service
* Parses RSS and Atom feeds and returns structured article entries.
* Used by the scraper pipeline to get individual article URLs from feeds.
*/
import Parser from 'rss-parser'
export interface FeedArticle {
title: string
link: string
pubDate?: string
contentSnippet?: string
content?: string
creator?: string
}
export interface ParsedFeed {
title: string
description?: string
link?: string
articles: FeedArticle[]
}
const parser = new Parser({
timeout: 15000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/rss+xml, application/xml, text/xml, application/atom+xml, text/html;q=0.9',
},
})
const MAX_ARTICLES_PER_FEED = 8
export class RssService {
/**
* Detect if a URL looks like an RSS/Atom feed
*/
isFeedUrl(url: string): boolean {
const feedPatterns = [
'/feed', '/rss', '/atom', '/feed/', '/rss/',
'.xml', '.rss', '.atom',
'/feed/json',
]
const lower = url.toLowerCase()
return feedPatterns.some(p => lower.includes(p))
}
/**
* Try to parse a URL as an RSS/Atom feed.
* Returns null if the URL is not a valid feed.
*/
async parseFeed(feedUrl: string): Promise<ParsedFeed | null> {
try {
const result = await parser.parseURL(feedUrl)
return {
title: result.title || feedUrl,
description: result.description,
link: result.link,
articles: (result.items || [])
.slice(0, MAX_ARTICLES_PER_FEED)
.map(item => ({
title: item.title || 'Sans titre',
link: item.link || '',
pubDate: item.pubDate || item.isoDate,
contentSnippet: (item.contentSnippet || '').substring(0, 500),
content: item['content:encoded'] || item.content || '',
creator: item.creator || item.dc?.creator,
}))
.filter(a => a.link), // Only keep entries with a link
}
} catch {
// Not a valid feed or fetch failed
return null
}
}
/**
* Fetch an RSS feed and return only the article URLs for scraping.
* Useful when you want to scrape articles individually.
*/
async getArticleUrls(feedUrl: string): Promise<{ feedTitle: string; urls: string[] }> {
const feed = await this.parseFeed(feedUrl)
if (!feed) return { feedTitle: '', urls: [] }
return {
feedTitle: feed.title,
urls: feed.articles.map(a => a.link),
}
}
}
export const rssService = new RssService()

View File

@@ -0,0 +1,68 @@
/**
* Scrape Service
* Advanced content extraction using Readability and jsdom
*/
import { JSDOM } from 'jsdom'
import { Readability } from '@mozilla/readability'
export interface ScrapedContent {
title: string
content: string // Markdown or clean text
textContent: string
excerpt: string
byline: string
siteName: string
url: string
}
export class ScrapeService {
async scrapeUrl(url: string): Promise<ScrapedContent | null> {
try {
// Add protocol if missing
let targetUrl = url
if (!url.startsWith('http://') && !url.startsWith('https://')) {
targetUrl = 'https://' + url
}
console.log(`[ScrapeService] Fetching ${targetUrl}...`)
const response = await fetch(targetUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
},
next: { revalidate: 3600 }
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const html = await response.text()
const dom = new JSDOM(html, { url: targetUrl })
const reader = new Readability(dom.window.document)
const article = reader.parse()
if (!article) {
return null
}
return {
title: article.title,
content: article.content, // HTML fragment from readability
textContent: article.textContent, // Clean text
excerpt: article.excerpt,
byline: article.byline,
siteName: article.siteName,
url: targetUrl
}
} catch (error) {
console.error(`[ScrapeService] Error scraping ${url}:`, error)
return null
}
}
}
export const scrapeService = new ScrapeService()

View File

@@ -0,0 +1,388 @@
/**
* 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)
defaultTitle?: string // Optional default title for untitled notes (i18n)
}
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)
defaultTitle = 'Untitled' // Default title for i18n
} = 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,
title: result.title || defaultTitle,
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 }, trashedAt: null },
select: {
id: true,
title: true,
content: true,
language: true
}
})
return notes.map(note => ({
noteId: note.id,
title: note.title || defaultTitle,
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 }>> {
// Extract keywords (words with > 3 characters) to avoid entire sentence matching failing
const stopWords = new Set(['comment', 'pourquoi', 'lequel', 'laquelle', 'avec', 'pour', 'dans', 'sur', 'est-ce']);
const keywords = query.toLowerCase()
.split(/[^a-z0-9àáâäçéèêëíìîïñóòôöúùûü]/i)
.filter(w => w.length > 3 && !stopWords.has(w));
// If no good keywords found, fallback to the original query but it'll likely fail
const searchTerms = keywords.length > 0 ? keywords : [query];
// Build Prisma OR clauses for each keyword
const searchConditions = searchTerms.flatMap(term => [
{ title: { contains: term } },
{ content: { contains: term } }
]);
const notes = await prisma.note.findMany({
where: {
...(userId ? { userId } : {}),
...(notebookId !== undefined ? { notebookId } : {}), // NEW: Notebook filter
trashedAt: null,
OR: searchConditions
},
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 } : {}),
trashedAt: null,
noteEmbedding: { isNot: null }
},
select: {
id: true,
noteEmbedding: true
}
})
if (notes.length === 0) {
return []
}
// Calculate similarities for all notes
const similarities = notes.map(note => {
const noteEmbedding = note.noteEmbedding?.embedding ? JSON.parse(note.noteEmbedding.embedding) as number[] : []
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 }, trashedAt: null },
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, noteEmbedding: true, lastAiAnalysis: true }
})
if (!note) {
throw new Error('Note not found')
}
// Check if embedding needs regeneration
const shouldRegenerate = embeddingService.shouldRegenerateEmbedding(
note.content,
note.noteEmbedding?.embedding as any,
note.lastAiAnalysis
)
if (!shouldRegenerate) {
return
}
// Generate new embedding
const { embedding } = await embeddingService.generateEmbedding(note.content)
// Save to database
await prisma.noteEmbedding.upsert({
where: { noteId: noteId },
create: { noteId: noteId, embedding: embeddingService.serialize(embedding) as any },
update: { embedding: embeddingService.serialize(embedding) as any }
})
await prisma.note.update({
where: { id: noteId },
data: {
lastAiAnalysis: new Date()
}
})
} catch (error) {
console.error(`Error indexing note ${noteId}:`, error)
throw error
}
}
/**
* Search as a specific user (no auth() call).
* Used by agent tools that run server-side without HTTP session.
*/
async searchAsUser(
userId: string,
query: string,
options: SearchOptions = {}
): Promise<SearchResult[]> {
const {
limit = this.DEFAULT_LIMIT,
threshold = this.DEFAULT_THRESHOLD,
includeExactMatches = true,
notebookId,
defaultTitle = 'Untitled'
} = options
if (!query || query.trim().length < 2) {
return []
}
try {
const keywordResults = await this.keywordSearch(query, userId, notebookId)
const semanticResults = await this.semanticVectorSearch(query, userId, threshold, notebookId)
const fusedResults = await this.reciprocalRankFusion(keywordResults, semanticResults)
return fusedResults
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(result => ({
...result,
title: result.title || defaultTitle,
matchType: result.score > 0.8 ? 'exact' : 'related'
}))
} catch (error) {
console.error('Error in searchAsUser:', error)
return []
}
}
/**
* 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,167 @@
/**
* Image Extraction Utility
* Extracts image URLs from web pages using Cheerio.
* Downloads and saves images locally for agent note attachment.
*/
import * as cheerio from 'cheerio'
import { promises as fs } from 'fs'
import path from 'path'
import { randomUUID } from 'crypto'
import sharp from 'sharp'
const UPLOADS_DIR = 'public/uploads/notes'
const URL_PREFIX = '/uploads/notes'
const MAX_IMAGES_PER_PAGE = 3
const MIN_IMAGE_SIZE = 200 // px -- skip icons, spacers, tracking pixels
const MAX_IMAGE_WIDTH = 600 // px -- resize for note-friendly display
export interface ExtractedImage {
url: string
localPath?: string
}
/**
* Extract image URLs from an HTML page.
* Prioritizes og:image, then article images with size filtering.
*/
export function extractImageUrlsFromHtml(html: string, pageUrl: string): string[] {
const $ = cheerio.load(html)
const images: string[] = []
const seen = new Set<string>()
// 1. Open Graph image
const ogImage = $('meta[property="og:image"]').attr('content')
if (ogImage) {
const resolved = resolveUrl(ogImage, pageUrl)
if (resolved && !seen.has(resolved)) {
images.push(resolved)
seen.add(resolved)
}
}
// 2. Twitter card image
const twitterImage = $('meta[name="twitter:image"]').attr('content')
if (twitterImage) {
const resolved = resolveUrl(twitterImage, pageUrl)
if (resolved && !seen.has(resolved)) {
images.push(resolved)
seen.add(resolved)
}
}
// 3. Article body images (filter by size and relevance)
$('article img, main img, .content img, .post-content img, .entry-content img, .article-body img').each((_, el) => {
if (images.length >= MAX_IMAGES_PER_PAGE) return false
const src = $(el).attr('src') || $(el).attr('data-src')
if (!src) return
const width = parseInt($(el).attr('width') || '0', 10)
const height = parseInt($(el).attr('height') || '0', 10)
// Skip if explicitly sized too small
if ((width > 0 && width < MIN_IMAGE_SIZE) || (height > 0 && height < MIN_IMAGE_SIZE)) return
// Skip common non-content patterns
if (src.includes('avatar') || src.includes('icon') || src.includes('logo') || src.includes('badge') || src.includes('spinner')) return
const resolved = resolveUrl(src, pageUrl)
if (resolved && !seen.has(resolved)) {
images.push(resolved)
seen.add(resolved)
}
})
// 4. Fallback: any large images in the page if we still have room
if (images.length < MAX_IMAGES_PER_PAGE) {
$('img').each((_, el) => {
if (images.length >= MAX_IMAGES_PER_PAGE) return false
const src = $(el).attr('src') || $(el).attr('data-src')
if (!src) return
const width = parseInt($(el).attr('width') || '0', 10)
const height = parseInt($(el).attr('height') || '0', 10)
if ((width > 0 && width < MIN_IMAGE_SIZE) || (height > 0 && height < MIN_IMAGE_SIZE)) return
if (src.includes('avatar') || src.includes('icon') || src.includes('logo') || src.includes('badge') || src.includes('spinner') || src.includes('pixel') || src.includes('tracking')) return
const resolved = resolveUrl(src, pageUrl)
if (resolved && !seen.has(resolved)) {
images.push(resolved)
seen.add(resolved)
}
})
}
return images.slice(0, MAX_IMAGES_PER_PAGE)
}
/**
* Download an image and save it locally.
*/
export async function downloadImage(imageUrl: string): Promise<string | null> {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10000)
const response = await fetch(imageUrl, {
signal: controller.signal,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; KeepBot/1.0)' },
})
clearTimeout(timeout)
if (!response.ok) return null
const contentType = response.headers.get('content-type') || ''
if (!contentType.startsWith('image/')) return null
const buffer = Buffer.from(await response.arrayBuffer())
if (buffer.length < 1024) return null // Skip tiny files
const ext = contentType.split('/')[1]?.replace('jpeg', 'jpg') || 'jpg'
const filename = `${randomUUID()}.${ext}`
await fs.mkdir(path.join(process.cwd(), UPLOADS_DIR), { recursive: true })
// Resize to max width for note-friendly display
try {
await sharp(buffer)
.resize(MAX_IMAGE_WIDTH, null, { withoutEnlargement: true })
.jpeg({ quality: 80 })
.toFile(path.join(process.cwd(), UPLOADS_DIR, filename.replace(/\.\w+$/, '.jpg')))
} catch {
// Sharp failed (e.g. SVG, WebP unsupported) — save raw buffer
await fs.writeFile(path.join(process.cwd(), UPLOADS_DIR, filename), buffer)
}
// Always reference as .jpg since sharp converts to jpeg
return `${URL_PREFIX}/${filename.replace(/\.\w+$/, '.jpg')}`
} catch {
return null
}
}
/**
* Extract and download images from a web page.
* Returns local URLs for successfully downloaded images.
*/
export async function extractAndDownloadImages(html: string, pageUrl: string): Promise<string[]> {
const imageUrls = extractImageUrlsFromHtml(html, pageUrl)
const localUrls: string[] = []
for (const url of imageUrls) {
const localPath = await downloadImage(url)
if (localPath) {
localUrls.push(localPath)
}
}
return localUrls
}
function resolveUrl(src: string, pageUrl: string): string | null {
try {
if (src.startsWith('//')) return `https:${src}`
if (src.startsWith('http://') || src.startsWith('https://')) return src
if (src.startsWith('/') || src.startsWith('./')) {
const base = new URL(pageUrl)
return new URL(src, base.origin).href
}
return new URL(src, pageUrl).href
} catch {
return null
}
}

View File

@@ -0,0 +1,15 @@
/**
* Tools Index
* Side-effect imports register all tools into the registry.
*/
// Import all tools (side-effect registration)
import './web-search.tool'
import './note-search.tool'
import './note-crud.tool'
import './web-scrape.tool'
import './url-fetch.tool'
import './memory.tool'
// Re-export registry
export { toolRegistry, type ToolContext, type RegisteredTool } from './registry'

View File

@@ -0,0 +1,62 @@
/**
* Memory Search Tool
* Searches past AgentActions (logs, toolLogs, inputs) for context.
*/
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
toolRegistry.register({
name: 'memory_search',
description: 'Search past agent execution history for relevant information. Looks through previous logs, tool traces, and inputs.',
isInternal: true,
buildTool: (ctx) =>
tool({
description: 'Search past agent executions for context. Searches through logs and tool traces from previous runs.',
inputSchema: z.object({
query: z.string().describe('What to search for in past executions'),
limit: z.number().optional().describe('Max results (default 5)').default(5),
}),
execute: async ({ query, limit = 5 }) => {
try {
// Get past actions for this agent
const actions = await prisma.agentAction.findMany({
where: {
agentId: ctx.agentId,
status: 'success',
},
orderBy: { createdAt: 'desc' },
take: limit * 2,
select: { id: true, log: true, input: true, toolLog: true, createdAt: true },
})
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2)
const results = actions
.map(a => {
const searchable = `${a.log || ''} ${a.input || ''} ${a.toolLog || ''}`.toLowerCase()
const score = keywords.reduce((acc, kw) => acc + (searchable.includes(kw) ? 1 : 0), 0)
return { ...a, score }
})
.filter(r => r.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
if (results.length === 0) {
return { message: 'No matching past executions found.', query }
}
return results.map(r => ({
actionId: r.id,
date: r.createdAt.toISOString(),
log: (r.log || '').substring(0, 800),
input: r.input ? (r.input).substring(0, 500) : null,
}))
} catch (e: any) {
return { error: `Memory search failed: ${e.message}` }
}
},
}),
})

View File

@@ -0,0 +1,104 @@
/**
* Note CRUD Tools
* note_create, note_read, note_update
*/
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
// --- note_read ---
toolRegistry.register({
name: 'note_read',
description: 'Read a specific note by its ID. Returns the full note content.',
isInternal: true,
buildTool: (ctx) =>
tool({
description: 'Read a specific note by ID. Returns the full content.',
inputSchema: z.object({
noteId: z.string().describe('The ID of the note to read'),
}),
execute: async ({ noteId }) => {
try {
const note = await prisma.note.findFirst({
where: { id: noteId, userId: ctx.userId },
select: { id: true, title: true, content: true, isMarkdown: true, createdAt: true, updatedAt: true },
})
if (!note) return { error: 'Note not found' }
return note
} catch (e: any) {
return { error: `Read note failed: ${e.message}` }
}
},
}),
})
// --- note_create ---
toolRegistry.register({
name: 'note_create',
description: 'Create a new note with a title and content.',
isInternal: true,
buildTool: (ctx) =>
tool({
description: 'Create a new note.',
inputSchema: z.object({
title: z.string().describe('Title for the note'),
content: z.string().describe('Content of the note (markdown supported)'),
notebookId: z.string().optional().describe('Optional notebook ID to place the note in'),
images: z.array(z.string()).optional().describe('Optional array of local image URL paths to attach to the note (e.g. ["/uploads/notes/abc.jpg"])'),
}),
execute: async ({ title, content, notebookId, images }) => {
try {
const note = await prisma.note.create({
data: {
title,
content,
isMarkdown: true,
autoGenerated: true,
userId: ctx.userId,
notebookId: notebookId || null,
images: images && images.length > 0 ? JSON.stringify(images) : null,
},
select: { id: true, title: true },
})
return { success: true, noteId: note.id, title: note.title }
} catch (e: any) {
return { error: `Create note failed: ${e.message}` }
}
},
}),
})
// --- note_update ---
toolRegistry.register({
name: 'note_update',
description: 'Update an existing note\'s content.',
isInternal: true,
buildTool: (ctx) =>
tool({
description: 'Update an existing note.',
inputSchema: z.object({
noteId: z.string().describe('The ID of the note to update'),
title: z.string().optional().describe('New title (optional)'),
content: z.string().optional().describe('New content (optional)'),
}),
execute: async ({ noteId, title, content }) => {
try {
const existing = await prisma.note.findFirst({
where: { id: noteId, userId: ctx.userId },
})
if (!existing) return { error: 'Note not found' }
const data: Record<string, any> = {}
if (title !== undefined) data.title = title
if (content !== undefined) data.content = content
await prisma.note.update({ where: { id: noteId }, data })
return { success: true, noteId }
} catch (e: any) {
return { error: `Update note failed: ${e.message}` }
}
},
}),
})

View File

@@ -0,0 +1,56 @@
/**
* Note Search Tool
* Wraps semanticSearchService.searchAsUser()
*/
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
toolRegistry.register({
name: 'note_search',
description: 'Search the user\'s notes using semantic search. Returns matching notes with titles and content excerpts.',
isInternal: true,
buildTool: (ctx) =>
tool({
description: 'Search the user\'s notes by keyword or semantic meaning. Returns matching notes with titles and content excerpts. Optionally restrict to a specific notebook.',
inputSchema: z.object({
query: z.string().describe('The search query'),
limit: z.number().optional().describe('Max results to return (default 5)').default(5),
notebookId: z.string().optional().describe('Optional notebook ID to restrict search to a specific notebook'),
}),
execute: async ({ query, limit = 5, notebookId }) => {
try {
// Keyword fallback search using Prisma
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2)
const conditions = keywords.flatMap(term => [
{ title: { contains: term } },
{ content: { contains: term } }
])
const notes = await prisma.note.findMany({
where: {
userId: ctx.userId,
...(notebookId ? { notebookId } : {}),
...(conditions.length > 0 ? { OR: conditions } : {}),
isArchived: false,
trashedAt: null,
},
select: { id: true, title: true, content: true, createdAt: true },
take: limit,
orderBy: { createdAt: 'desc' },
})
return notes.map(n => ({
id: n.id,
title: n.title || 'Untitled',
excerpt: n.content.substring(0, 300),
createdAt: n.createdAt.toISOString(),
}))
} catch (e: any) {
return { error: `Note search failed: ${e.message}` }
}
},
}),
})

View File

@@ -0,0 +1,82 @@
/**
* Tool Registry
* Central registry for all agent tools.
* Tools self-register on import via side-effect in index.ts.
*/
import { tool } from 'ai'
import { z } from 'zod'
export interface ToolContext {
userId: string
agentId?: string
actionId?: string
conversationId?: string
notebookId?: string
webSearch?: boolean
config: Record<string, string>
}
export interface RegisteredTool {
name: string
description: string
buildTool: (ctx: ToolContext) => any // Returns an AI SDK tool() synchronously
isInternal: boolean // true = no API key needed
}
class ToolRegistry {
private tools: Map<string, RegisteredTool> = new Map()
register(tool: RegisteredTool): void {
this.tools.set(tool.name, tool)
}
get(name: string): RegisteredTool | undefined {
return this.tools.get(name)
}
buildToolsForAgent(toolNames: string[], ctx: ToolContext): Record<string, any> {
const built: Record<string, any> = {}
for (const name of toolNames) {
const registered = this.tools.get(name)
if (registered) {
built[name] = registered.buildTool(ctx)
}
}
return built
}
/**
* Build tools for the chat endpoint.
* Includes internal tools (note_search, note_read) and web tools when configured.
*/
buildToolsForChat(ctx: ToolContext): Record<string, any> {
const toolNames: string[] = ['note_search', 'note_read']
// Add web tools only when user toggled web search AND config is present
if (ctx.webSearch) {
const hasWebSearch = ctx.config.WEB_SEARCH_PROVIDER || ctx.config.BRAVE_SEARCH_API_KEY || ctx.config.SEARXNG_URL
if (hasWebSearch) {
toolNames.push('web_search')
}
const hasWebScrape = ctx.config.JINA_API_KEY
if (hasWebScrape) {
toolNames.push('web_scrape')
}
}
return this.buildToolsForAgent(toolNames, ctx)
}
getAvailableTools(): Array<{ name: string; description: string; isInternal: boolean }> {
return Array.from(this.tools.values()).map(t => ({
name: t.name,
description: t.description,
isInternal: t.isInternal,
}))
}
}
// Singleton
export const toolRegistry = new ToolRegistry()

View File

@@ -0,0 +1,55 @@
/**
* URL Fetch Tool
* Fetches a URL and returns parsed content (JSON, CSV, or text).
* Max 5MB response.
*/
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
toolRegistry.register({
name: 'url_fetch',
description: 'Fetch a URL and return its content. Supports JSON, CSV, and plain text responses. Max 5MB.',
isInternal: true,
buildTool: (_ctx) =>
tool({
description: 'Fetch a URL and return its parsed content. Supports JSON, CSV, and text.',
inputSchema: z.object({
url: z.string().describe('The URL to fetch'),
method: z.enum(['GET', 'POST']).optional().describe('HTTP method (default GET)').default('GET'),
}),
execute: async ({ url, method = 'GET' }) => {
try {
const response = await fetch(url, { method })
if (!response.ok) return { error: `HTTP ${response.status}: ${response.statusText}` }
const contentLength = parseInt(response.headers.get('content-length') || '0')
if (contentLength > MAX_SIZE) return { error: 'Response too large (max 5MB)' }
const contentType = response.headers.get('content-type') || ''
const text = await response.text()
if (text.length > MAX_SIZE) return { error: 'Response too large (max 5MB)' }
if (contentType.includes('application/json')) {
try {
return { type: 'json', data: JSON.parse(text) }
} catch {
return { type: 'text', content: text.substring(0, 10000) }
}
}
if (contentType.includes('text/csv')) {
return { type: 'csv', content: text.substring(0, 10000) }
}
return { type: 'text', content: text.substring(0, 10000) }
} catch (e: any) {
return { error: `Fetch failed: ${e.message}` }
}
},
}),
})

View File

@@ -0,0 +1,88 @@
/**
* Web Scrape Tool
* Uses Jina Reader API (r.jina.ai) to scrape a URL into markdown.
* Falls back to basic fetch on error.
* Supports RSS/Atom feeds: parses the feed and scrapes top articles.
*/
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { rssService } from '../services/rss.service'
const MAX_ARTICLE_CONTENT = 4000
const MAX_TOTAL_CONTENT = 15000
const MAX_ARTICLES_FROM_FEED = 5
async function scrapeSingleUrl(url: string, jinaKey?: string): Promise<{ content: string; url: string }> {
const headers: Record<string, string> = { 'Accept': 'text/markdown' }
if (jinaKey) {
headers['Authorization'] = `Bearer ${jinaKey}`
}
const response = await fetch(`https://r.jina.ai/${url}`, { headers })
if (!response.ok) {
const fallback = await fetch(url)
if (!fallback.ok) return { content: `Failed to fetch ${url}: ${fallback.status}`, url }
const text = await fallback.text()
return { content: text.substring(0, 10000), url }
}
const markdown = await response.text()
return { content: markdown.substring(0, MAX_TOTAL_CONTENT), url }
}
toolRegistry.register({
name: 'web_scrape',
description: 'Scrape a web page and return its content as markdown. Supports RSS/Atom feeds — will automatically parse feeds and scrape individual articles.',
isInternal: false,
buildTool: (ctx) =>
tool({
description: 'Scrape a web page URL and return its content as clean markdown text. If the URL is an RSS/Atom feed, it will parse the feed and scrape the latest articles automatically.',
inputSchema: z.object({
url: z.string().describe('The URL to scrape. Can be a regular web page or an RSS/Atom feed URL.'),
}),
execute: async ({ url }) => {
try {
// Try RSS feed detection first
if (rssService.isFeedUrl(url)) {
const feed = await rssService.parseFeed(url)
if (feed && feed.articles.length > 0) {
const jinaKey = ctx.config.JINA_API_KEY
const articlesToScrape = feed.articles.slice(0, MAX_ARTICLES_FROM_FEED)
const results = await Promise.allSettled(
articlesToScrape.map(article => scrapeSingleUrl(article.link, jinaKey))
)
const parts: string[] = []
parts.push(`# ${feed.title}\n_Flux RSS: ${url}${feed.articles.length} articles disponibles, ${articlesToScrape.length} scrapés_\n`)
let totalLen = 0
for (let i = 0; i < results.length; i++) {
const r = results[i]
if (r.status === 'fulfilled' && r.value.content) {
const article = articlesToScrape[i]
const header = `\n---\n\n## ${article.title}\n_Source: ${article.link}_${article.pubDate ? `${new Date(article.pubDate).toISOString().split('T')[0]}` : ''}\n\n`
const content = r.value.content.substring(0, MAX_ARTICLE_CONTENT)
if (totalLen + header.length + content.length > MAX_TOTAL_CONTENT) break
parts.push(header + content)
totalLen += header.length + content.length
}
}
return { content: parts.join(''), url, feedTitle: feed.title, articlesScraped: articlesToScrape.length }
}
// If feed parsing failed, fall through to normal scraping
}
// Normal web page scraping
const result = await scrapeSingleUrl(url, ctx.config.JINA_API_KEY)
return result
} catch (e: any) {
return { error: `Scrape failed: ${e.message}` }
}
},
}),
})

View File

@@ -0,0 +1,65 @@
/**
* Web Search Tool
* Uses SearXNG or Brave Search API.
*/
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
async function searchSearXNG(query: string, searxngUrl: string): Promise<any> {
const url = `${searxngUrl.replace(/\/+$/, '')}/search?q=${encodeURIComponent(query)}&format=json`
const response = await fetch(url, { headers: { 'Accept': 'application/json' } })
if (!response.ok) throw new Error(`SearXNG error: ${response.status}`)
const data = await response.json()
return (data.results || []).slice(0, 8).map((r: any) => ({
title: r.title,
url: r.url,
snippet: r.content || '',
}))
}
async function searchBrave(query: string, apiKey: string): Promise<any> {
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=8`
const response = await fetch(url, {
headers: { 'Accept': 'application/json', 'X-Subscription-Token': apiKey }
})
if (!response.ok) throw new Error(`Brave error: ${response.status}`)
const data = await response.json()
return (data.web?.results || []).map((r: any) => ({
title: r.title,
url: r.url,
snippet: r.description || '',
}))
}
toolRegistry.register({
name: 'web_search',
description: 'Search the web for information. Returns a list of results with titles, URLs and snippets.',
isInternal: false,
buildTool: (ctx) =>
tool({
description: 'Search the web for information. Returns results with titles, URLs and snippets.',
inputSchema: z.object({
query: z.string().describe('The search query'),
}),
execute: async ({ query }) => {
try {
const provider = ctx.config.WEB_SEARCH_PROVIDER || 'searxng'
if (provider === 'brave' || provider === 'both') {
const apiKey = ctx.config.BRAVE_SEARCH_API_KEY
if (apiKey) {
return await searchBrave(query, apiKey)
}
}
// Default: SearXNG
const searxngUrl = ctx.config.SEARXNG_URL || 'http://localhost:8080'
return await searchSearXNG(query, searxngUrl)
} catch (e: any) {
return { error: `Web search failed: ${e.message}` }
}
},
}),
})

View File

@@ -0,0 +1,75 @@
export interface TagSuggestion {
tag: string;
confidence: number;
}
export interface TitleSuggestion {
title: string;
confidence: number;
}
export interface ToolUseOptions {
tools: Record<string, any> // AI SDK tool() objects
maxSteps?: number
systemPrompt?: string
messages?: any[]
prompt?: string
}
export interface ToolCallResult {
toolCalls: Array<{ toolName: string; input: any }>
toolResults: Array<{ toolName: string; input: any; output: any }>
text: string
steps: Array<{
text: string
toolCalls: Array<{ toolName: string; input: any }>
toolResults: Array<{ toolName: string; input: any; output: any }>
}>
}
export interface AIProvider {
/**
* Analyse le contenu et suggère des tags pertinents.
*/
generateTags(content: string, language?: 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>;
/**
* Fournit une réponse de chat (utilisé pour le système agentique)
*/
chat(messages: any[], systemPrompt?: string): Promise<any>;
/**
* Retourne le modèle AI SDK pour le streaming direct (utilisé par l'API route)
*/
getModel(): any;
/**
* Generate text with tool-use support (multi-step agent loop)
*/
generateWithTools(options: ToolUseOptions): Promise<ToolCallResult>;
}
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
* Approche contemporaine des couleurs de notes
*
*/
// =============================================================================
// 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,56 @@
import prisma from './prisma'
import { unstable_cache } from 'next/cache'
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,37 @@
// Cache with TTL for 15 minutes
const CACHE_TTL = 15 * 60 * 1000 // 15 minutes
interface CacheEntry {
count: number
timestamp: number
}
const cache = new Map<string, CacheEntry>()
export async function getConnectionsCount(noteId: string): Promise<number> {
const now = Date.now()
const cached = cache.get(noteId)
if (cached && (now - cached.timestamp) < CACHE_TTL) {
return cached.count
}
try {
const res = await fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=1`)
if (!res.ok) {
throw new Error('Failed to fetch connections')
}
const data = await res.json()
const count = data.pagination?.total || 0
// Update cache for future calls
if (count > 0) {
cache.set(noteId, { count, timestamp: Date.now() })
}
return count
} catch (error) {
console.error('[ConnectionsCache] Failed to fetch connections:', error)
return 0
}
}

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,113 @@
'use client'
import { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react'
import type { ReactNode } from 'react'
import { SupportedLanguage, loadTranslations, getTranslationValue, Translations } from './load-translations'
// Static imports for SSR-safe initial translations (prevents hydration mismatch)
import enTranslations from '@/locales/en.json'
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)
const RTL_LANGUAGES: SupportedLanguage[] = ['ar', 'fa']
const SUPPORTED_LANGS: SupportedLanguage[] = ['en', 'fr', 'es', 'de', 'fa', 'it', 'pt', 'ru', 'zh', 'ja', 'ko', 'ar', 'hi', 'nl', 'pl']
function updateDocumentDirection(lang: SupportedLanguage) {
document.documentElement.lang = lang
document.documentElement.dir = RTL_LANGUAGES.includes(lang) ? 'rtl' : 'ltr'
}
/**
* Resolve the actual language to use:
* 1. If localStorage has a saved preference, use that (client only)
* 2. Otherwise fall back to the server-detected initialLanguage
*/
function resolveLanguage(fallback: SupportedLanguage): SupportedLanguage {
if (typeof window !== 'undefined') {
try {
const saved = localStorage.getItem('user-language') as SupportedLanguage
if (saved && SUPPORTED_LANGS.includes(saved)) return saved
} catch {}
}
return fallback
}
export function LanguageProvider({ children, initialLanguage = 'en', initialTranslations }: {
children: ReactNode
initialLanguage?: SupportedLanguage
initialTranslations?: Translations
}) {
// Resolve language synchronously from localStorage BEFORE any effect runs.
// This prevents the flash where initialLanguage ('en') overrides RTL.
const [language, setLanguageState] = useState<SupportedLanguage>(() => resolveLanguage(initialLanguage))
// Start with server-provided translations or English fallback
const [translations, setTranslations] = useState<Translations>(
(initialTranslations || enTranslations) as unknown as Translations
)
const cacheRef = useRef<Map<SupportedLanguage, Translations>>(new Map())
const isFirstRender = useRef(true)
// Load translations when language changes (with caching)
// On first render, skip updateDocumentDirection since the inline script already set it.
useEffect(() => {
const cached = cacheRef.current.get(language)
if (cached) {
setTranslations(cached)
if (!isFirstRender.current) updateDocumentDirection(language)
isFirstRender.current = false
return
}
const loadLang = async () => {
const loaded = await loadTranslations(language)
cacheRef.current.set(language, loaded)
setTranslations(loaded)
if (!isFirstRender.current) updateDocumentDirection(language)
isFirstRender.current = false
}
loadLang()
}, [language])
const setLanguage = useCallback((lang: SupportedLanguage) => {
setLanguageState(lang)
localStorage.setItem('user-language', lang)
updateDocumentDirection(lang)
}, [])
const t = useCallback((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 && typeof value === 'string') {
Object.entries(params).forEach(([param, paramValue]) => {
value = value.replace(`{${param}}`, String(paramValue))
})
}
return typeof value === 'string' ? value : key
}, [translations])
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,54 @@
/**
* Detect user's preferred language from their existing notes
* Uses a single DB-level GROUP BY query — no note content is loaded
*/
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { unstable_cache } from 'next/cache'
import { SupportedLanguage } from './load-translations'
const SUPPORTED_LANGUAGES = new Set(['en', 'fr', 'es', 'de', 'fa', 'it', 'pt', 'ru', 'zh', 'ja', 'ko', 'ar', 'hi', 'nl', 'pl'])
const getCachedUserLanguage = unstable_cache(
async (userId: string): Promise<SupportedLanguage> => {
try {
// Single aggregated query — no notes are fetched, only language counts
const result = await prisma.note.groupBy({
by: ['language'],
where: {
userId,
language: { not: null }
},
_sum: { languageConfidence: true },
_count: true,
orderBy: { _sum: { languageConfidence: 'desc' } },
take: 1,
})
if (result.length > 0 && result[0].language) {
const topLanguage = result[0].language as SupportedLanguage
if (SUPPORTED_LANGUAGES.has(topLanguage)) {
return topLanguage
}
}
return 'en'
} catch (error) {
console.error('Error detecting user language:', error)
return 'en'
}
},
['user-language'],
{ tags: ['user-language'] }
)
export async function detectUserLanguage(): Promise<SupportedLanguage> {
const session = await auth()
if (!session?.user?.id) {
return 'en'
}
return getCachedUserLanguage(session.user.id)
}

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,938 @@
/**
* 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
newNoteTabs: string
newNoteTabsHint: 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
emptyStateTabs: 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
viewCards: string
viewTabs: string
viewCardsTooltip: string
viewTabsTooltip: string
viewModeGroup: string
reorderTabs: 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
notesViewDescription: string
notesViewLabel: string
notesViewTabs: string
notesViewMasonry: 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 @@
/**
* Image Cleanup Utility
* Safely deletes orphaned image files from disk.
* Checks database references before deleting to avoid breaking shared images.
*/
import { promises as fs } from 'fs'
import path from 'path'
import { prisma } from '@/lib/prisma'
const UPLOADS_DIR = 'public/uploads/notes'
/**
* Delete an image file from disk only if no other note references it.
* @param imageUrl - The relative URL path (e.g. "/uploads/notes/abc.jpg")
* @param excludeNoteId - Note ID to exclude from reference check (the note being deleted)
*/
export async function deleteImageFileSafely(imageUrl: string, excludeNoteId?: string): Promise<void> {
if (!imageUrl || !imageUrl.startsWith('/uploads/notes/')) return
try {
const notes = await prisma.note.findMany({
where: { images: { contains: imageUrl } },
select: { id: true },
})
const otherRefs = notes.filter(n => n.id !== excludeNoteId)
if (otherRefs.length > 0) return // File still referenced elsewhere
const filePath = path.join(process.cwd(), imageUrl)
await fs.unlink(filePath)
} catch {
// File already gone or unreadable -- silently skip
}
}
/**
* Delete all image files associated with a note.
* Checks that each image is not referenced by any other note before deleting.
*/
export async function cleanupNoteImages(noteId: string, imageUrls: string[]): Promise<void> {
for (const url of imageUrls) {
await deleteImageFileSafely(url, noteId)
}
}
/**
* Parse the images JSON field from a note record.
*/
export function parseImageUrls(imagesJson: string | null): string[] {
if (!imagesJson) return []
try {
const parsed = JSON.parse(imagesJson)
return Array.isArray(parsed) ? parsed.filter((u: unknown) => typeof u === 'string') : []
} catch {
return []
}
}

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)
}
}

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

@@ -0,0 +1,147 @@
import nodemailer from 'nodemailer';
import { getSystemConfig } from './config';
export interface InlineAttachment {
filename: string
content: Buffer
cid: string
}
interface MailOptions {
to: string;
subject: string;
html: string;
attachments?: InlineAttachment[];
}
interface MailResult {
success: boolean;
messageId?: string;
error?: string;
}
type EmailProvider = 'auto' | 'resend' | 'smtp';
/**
* Send email.
* - 'auto': try Resend first (if key set), fall back to SMTP on failure
* - 'smtp': force SMTP only
* - 'resend': force Resend only
* Supports inline image attachments via cid: references in HTML.
*/
export async function sendEmail({ to, subject, html, attachments }: MailOptions, provider: EmailProvider = 'auto'): Promise<MailResult> {
const config = await getSystemConfig();
const resendKey = config.RESEND_API_KEY || process.env.RESEND_API_KEY;
// Force SMTP
if (provider === 'smtp') {
return sendViaSMTP(config, { to, subject, html, attachments });
}
// Force Resend (no fallback)
if (provider === 'resend') {
if (!resendKey) return { success: false, error: 'No Resend API key configured' };
return sendViaResend(resendKey, { to, subject, html, attachments });
}
// Auto: try Resend, fall back to SMTP
if (resendKey) {
const result = await sendViaResend(resendKey, { to, subject, html, attachments });
if (result.success) return result;
console.warn('[Mail] Resend failed, falling back to SMTP:', result.error);
return sendViaSMTP(config, { to, subject, html, attachments });
}
return sendViaSMTP(config, { to, subject, html, attachments });
}
async function sendViaResend(apiKey: string, { to, subject, html, attachments }: MailOptions): Promise<MailResult> {
try {
const { Resend } = await import('resend');
const resend = new Resend(apiKey);
const from = process.env.NEXTAUTH_URL
? `Memento <noreply@${new URL(process.env.NEXTAUTH_URL).hostname}>`
: 'Memento <onboarding@resend.dev>';
// Resend supports attachments with inline content
const resendAttachments = attachments?.map(att => ({
filename: att.filename,
content: att.content.toString('base64'),
content_type: att.filename.endsWith('.png') ? 'image/png' : 'image/jpeg',
disposition: 'inline' as const,
content_id: att.cid,
}));
const { data, error } = await resend.emails.send({
from,
to,
subject,
html,
attachments: resendAttachments,
});
if (error) {
return { success: false, error: error.message };
}
return { success: true, messageId: data?.id };
} catch (error: any) {
return { success: false, error: `Resend: ${error.message}` };
}
}
async function sendViaSMTP(config: Record<string, string>, { to, subject, html, attachments }: MailOptions): Promise<MailResult> {
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';
if (!host) {
return { success: false, error: 'SMTP host is not configured' };
}
const forceSecure = config.SMTP_SECURE === 'true';
const isPort465 = port === 465;
const secure = forceSecure || isPort465;
const ignoreCerts = config.SMTP_IGNORE_CERT === 'true';
const transporter = nodemailer.createTransport({
host,
port,
secure,
auth: { user, pass },
family: 4,
authMethod: 'LOGIN',
connectionTimeout: 10000,
tls: {
rejectUnauthorized: !ignoreCerts,
ciphers: ignoreCerts ? 'SSLv3' : undefined
}
} as any);
try {
await transporter.verify();
// Build nodemailer inline attachments with cid
const smtpAttachments = attachments?.map(att => ({
filename: att.filename,
content: att.content,
cid: att.cid,
}));
const info = await transporter.sendMail({
from: `"Memento" <${from}>`,
to,
subject,
html,
attachments: smtpAttachments,
});
return { success: true, messageId: info.messageId };
} catch (error: any) {
console.error('SMTP error:', error);
return { success: false, error: `SMTP: ${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,33 @@
/**
* Plain-text preview for list view (light markdown stripping).
*/
export function stripMarkdownPreview(raw: string, maxLen = 180): string {
if (!raw?.trim()) return ''
let t = raw
.replace(/^#{1,6}\s+/gm, '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\[(.+?)]\([^)]+\)/g, '$1')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/^\s*\d+\.\s+/gm, '')
.replace(/\n+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (t.length > maxLen) {
return `${t.slice(0, maxLen).trim()}`
}
return t
}
export function getNoteDisplayTitle(note: { title: string | null; content: string; type: string }, untitled: string): string {
const title = note.title?.trim()
if (title) return title
if (note.type === 'checklist') {
const line = note.content?.split('\n').find((l) => l.trim())
if (line) return stripMarkdownPreview(line, 80) || untitled
}
const preview = stripMarkdownPreview(note.content || '', 100)
return preview || untitled
}

View File

@@ -0,0 +1,36 @@
import {
Folder,
Briefcase,
FileText,
Zap,
BarChart3,
Globe,
Sparkles,
Book,
Heart,
Crown,
Music,
Building2,
Plane,
type LucideIcon,
} from 'lucide-react'
const ICON_MAP: Record<string, LucideIcon> = {
'folder': Folder,
'briefcase': Briefcase,
'document': FileText,
'lightning': Zap,
'chart': BarChart3,
'globe': Globe,
'sparkle': Sparkles,
'book': Book,
'heart': Heart,
'crown': Crown,
'music': Music,
'building': Building2,
'flight_takeoff': Plane,
}
export function getNotebookIcon(iconName: string | null | undefined): LucideIcon {
return ICON_MAP[iconName || 'folder'] || Folder
}

View File

@@ -0,0 +1,28 @@
import { PrismaClient } from '@prisma/client'
const prismaClientSingleton = () => {
return new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL || "file:/Users/sepehr/dev/Momento/memento-note/prisma/dev.db",
},
},
})
}
declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
// Log current model keys to verify availability
if (process.env.NODE_ENV !== 'production') {
const models = Object.keys(prisma).filter(k => !k.startsWith('_') && !k.startsWith('$'))
console.log('[Prisma] Models loaded:', models.join(', '))
}
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);
}
})();
`
}

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

@@ -0,0 +1,211 @@
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;
trashedAt?: Date | null;
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;
isReminderDone: boolean;
reminderRecurrence: string | null;
reminderLocation: string | null;
isMarkdown: boolean;
dismissedFromRecent?: 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;
aiProvider?: string | 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';

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

@@ -0,0 +1,264 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { LABEL_COLORS, LabelColorName, QueryType, Note } from "./types"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Deep equality check for two values
* More performant than JSON.stringify for comparison
*/
export function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
if (a === null || b === null) return a === b
if (typeof a !== typeof b) return false
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false
return a.every((item, index) => deepEqual(item, b[index]))
}
if (typeof a === 'object' && typeof b === 'object') {
const keysA = Object.keys(a as Record<string, unknown>)
const keysB = Object.keys(b as Record<string, unknown>)
if (keysA.length !== keysB.length) return false
return keysA.every(key => deepEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key]
))
}
return false
}
/**
* Coerce a Prisma Json value into an array (or return fallback).
* Handles null, undefined, string (legacy JSON), object, etc.
*/
export function asArray<T = unknown>(val: unknown, fallback: T[] = []): T[] {
if (Array.isArray(val)) return val
if (typeof val === 'string') {
try { const p = JSON.parse(val); return Array.isArray(p) ? p : fallback } catch { return fallback }
}
return fallback
}
/**
* Parse a database note object into a typed Note.
* Guarantees array fields are always real arrays or null.
*/
export function parseNote(dbNote: any): Note {
return {
...dbNote,
checkItems: asArray(dbNote.checkItems, null as any) ?? null,
labels: asArray(dbNote.labels) || null,
images: asArray(dbNote.images) || null,
links: asArray(dbNote.links) || null,
embedding: asArray<number>(dbNote.embedding) || null,
sharedWith: asArray(dbNote.sharedWith),
size: dbNote.size || 'small',
}
}
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;
const minLen = Math.min(vecA.length, vecB.length);
let dotProduct = 0;
let mA = 0;
let mB = 0;
for (let i = 0; i < minLen; 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
})
}