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:
167
memento-note/lib/agent-email-template.ts
Normal file
167
memento-note/lib/agent-email-template.ts
Normal 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  — 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 · <a href="${appUrl}/agents">Gérer mes agents</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return { html, attachments }
|
||||
}
|
||||
158
memento-note/lib/ai/factory.ts
Normal file
158
memento-note/lib/ai/factory.ts
Normal 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);
|
||||
}
|
||||
147
memento-note/lib/ai/providers/custom-openai.ts
Normal file
147
memento-note/lib/ai/providers/custom-openai.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
131
memento-note/lib/ai/providers/deepseek.ts
Normal file
131
memento-note/lib/ai/providers/deepseek.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
222
memento-note/lib/ai/providers/ollama.ts
Normal file
222
memento-note/lib/ai/providers/ollama.ts
Normal 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 })) || []
|
||||
})) || []
|
||||
}
|
||||
}
|
||||
}
|
||||
131
memento-note/lib/ai/providers/openai.ts
Normal file
131
memento-note/lib/ai/providers/openai.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
131
memento-note/lib/ai/providers/openrouter.ts
Normal file
131
memento-note/lib/ai/providers/openrouter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1106
memento-note/lib/ai/services/agent-executor.service.ts
Normal file
1106
memento-note/lib/ai/services/agent-executor.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
491
memento-note/lib/ai/services/auto-label-creation.service.ts
Normal file
491
memento-note/lib/ai/services/auto-label-creation.service.ts
Normal 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()
|
||||
975
memento-note/lib/ai/services/batch-organization.service.ts
Normal file
975
memento-note/lib/ai/services/batch-organization.service.ts
Normal 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()
|
||||
141
memento-note/lib/ai/services/chat.service.ts
Normal file
141
memento-note/lib/ai/services/chat.service.ts
Normal 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()
|
||||
592
memento-note/lib/ai/services/contextual-auto-tag.service.ts
Normal file
592
memento-note/lib/ai/services/contextual-auto-tag.service.ts
Normal 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()
|
||||
218
memento-note/lib/ai/services/embedding.service.ts
Normal file
218
memento-note/lib/ai/services/embedding.service.ts
Normal 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()
|
||||
91
memento-note/lib/ai/services/index.ts
Normal file
91
memento-note/lib/ai/services/index.ts
Normal 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'
|
||||
133
memento-note/lib/ai/services/language-detection.service.ts
Normal file
133
memento-note/lib/ai/services/language-detection.service.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
623
memento-note/lib/ai/services/memory-echo.service.ts
Normal file
623
memento-note/lib/ai/services/memory-echo.service.ts
Normal 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()
|
||||
297
memento-note/lib/ai/services/notebook-suggestion.service.ts
Normal file
297
memento-note/lib/ai/services/notebook-suggestion.service.ts
Normal 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()
|
||||
381
memento-note/lib/ai/services/notebook-summary.service.ts
Normal file
381
memento-note/lib/ai/services/notebook-summary.service.ts
Normal 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()
|
||||
313
memento-note/lib/ai/services/paragraph-refactor.service.ts
Normal file
313
memento-note/lib/ai/services/paragraph-refactor.service.ts
Normal 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()
|
||||
92
memento-note/lib/ai/services/rss.service.ts
Normal file
92
memento-note/lib/ai/services/rss.service.ts
Normal 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()
|
||||
68
memento-note/lib/ai/services/scrape.service.ts
Normal file
68
memento-note/lib/ai/services/scrape.service.ts
Normal 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()
|
||||
388
memento-note/lib/ai/services/semantic-search.service.ts
Normal file
388
memento-note/lib/ai/services/semantic-search.service.ts
Normal 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()
|
||||
182
memento-note/lib/ai/services/title-suggestion.service.ts
Normal file
182
memento-note/lib/ai/services/title-suggestion.service.ts
Normal 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()
|
||||
167
memento-note/lib/ai/tools/extract-images.ts
Normal file
167
memento-note/lib/ai/tools/extract-images.ts
Normal 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
|
||||
}
|
||||
}
|
||||
15
memento-note/lib/ai/tools/index.ts
Normal file
15
memento-note/lib/ai/tools/index.ts
Normal 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'
|
||||
62
memento-note/lib/ai/tools/memory.tool.ts
Normal file
62
memento-note/lib/ai/tools/memory.tool.ts
Normal 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}` }
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
104
memento-note/lib/ai/tools/note-crud.tool.ts
Normal file
104
memento-note/lib/ai/tools/note-crud.tool.ts
Normal 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}` }
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
56
memento-note/lib/ai/tools/note-search.tool.ts
Normal file
56
memento-note/lib/ai/tools/note-search.tool.ts
Normal 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}` }
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
82
memento-note/lib/ai/tools/registry.ts
Normal file
82
memento-note/lib/ai/tools/registry.ts
Normal 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()
|
||||
55
memento-note/lib/ai/tools/url-fetch.tool.ts
Normal file
55
memento-note/lib/ai/tools/url-fetch.tool.ts
Normal 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}` }
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
88
memento-note/lib/ai/tools/web-scrape.tool.ts
Normal file
88
memento-note/lib/ai/tools/web-scrape.tool.ts
Normal 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}` }
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
65
memento-note/lib/ai/tools/web-search.tool.ts
Normal file
65
memento-note/lib/ai/tools/web-search.tool.ts
Normal 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}` }
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
75
memento-note/lib/ai/types.ts
Normal file
75
memento-note/lib/ai/types.ts
Normal 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;
|
||||
}
|
||||
310
memento-note/lib/color-harmony-recommendation.ts
Normal file
310
memento-note/lib/color-harmony-recommendation.ts
Normal 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;
|
||||
56
memento-note/lib/config.ts
Normal file
56
memento-note/lib/config.ts
Normal 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
|
||||
37
memento-note/lib/connections-cache.ts
Normal file
37
memento-note/lib/connections-cache.ts
Normal 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
|
||||
}
|
||||
}
|
||||
35
memento-note/lib/email-template.ts
Normal file
35
memento-note/lib/email-template.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
113
memento-note/lib/i18n/LanguageProvider.tsx
Normal file
113
memento-note/lib/i18n/LanguageProvider.tsx
Normal 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
|
||||
}
|
||||
54
memento-note/lib/i18n/detect-user-language.ts
Normal file
54
memento-note/lib/i18n/detect-user-language.ts
Normal 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)
|
||||
}
|
||||
8
memento-note/lib/i18n/index.ts
Normal file
8
memento-note/lib/i18n/index.ts
Normal 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'
|
||||
938
memento-note/lib/i18n/load-translations.ts
Normal file
938
memento-note/lib/i18n/load-translations.ts
Normal 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
|
||||
}
|
||||
57
memento-note/lib/image-cleanup.ts
Normal file
57
memento-note/lib/image-cleanup.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
57
memento-note/lib/label-storage.ts
Normal file
57
memento-note/lib/label-storage.ts
Normal 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
147
memento-note/lib/mail.ts
Normal 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})` };
|
||||
}
|
||||
}
|
||||
307
memento-note/lib/modern-color-options.ts
Normal file
307
memento-note/lib/modern-color-options.ts
Normal 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;
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
33
memento-note/lib/note-preview.ts
Normal file
33
memento-note/lib/note-preview.ts
Normal 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
|
||||
}
|
||||
36
memento-note/lib/notebook-icon.tsx
Normal file
36
memento-note/lib/notebook-icon.tsx
Normal 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
|
||||
}
|
||||
28
memento-note/lib/prisma.ts
Normal file
28
memento-note/lib/prisma.ts
Normal 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
|
||||
31
memento-note/lib/theme-script.ts
Normal file
31
memento-note/lib/theme-script.ts
Normal 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
211
memento-note/lib/types.ts
Normal 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
264
memento-note/lib/utils.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
69
memento-note/lib/utils/date.ts
Normal file
69
memento-note/lib/utils/date.ts
Normal 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user