fix: improve note interactions and markdown LaTeX support

## Bug Fixes

### Note Card Actions
- Fix broken size change functionality (missing state declaration)
- Implement React 19 useOptimistic for instant UI feedback
- Add startTransition for non-blocking updates
- Ensure smooth animations without page refresh
- All note actions now work: pin, archive, color, size, checklist

### Markdown LaTeX Rendering
- Add remark-math and rehype-katex plugins
- Support inline equations with dollar sign syntax
- Support block equations with double dollar sign syntax
- Import KaTeX CSS for proper styling
- Equations now render correctly instead of showing raw LaTeX

## Technical Details

- Replace undefined currentNote references with optimistic state
- Add optimistic updates before server actions for instant feedback
- Use router.refresh() in transitions for smart cache invalidation
- Install remark-math, rehype-katex, and katex packages

## Testing

- Build passes successfully with no TypeScript errors
- Dev server hot-reloads changes correctly
This commit is contained in:
2026-01-09 22:13:49 +01:00
parent 3c4b9d6176
commit 640fcb26f7
218 changed files with 51363 additions and 902 deletions

View File

@@ -2,24 +2,29 @@ import { OpenAIProvider } from './providers/openai';
import { OllamaProvider } from './providers/ollama';
import { AIProvider } from './types';
export function getAIProvider(): AIProvider {
const providerType = process.env.AI_PROVIDER || 'ollama'; // Default to ollama for local dev
export function getAIProvider(config?: Record<string, string>): AIProvider {
const providerType = config?.AI_PROVIDER || process.env.AI_PROVIDER || 'ollama';
switch (providerType.toLowerCase()) {
case 'ollama':
console.log('Using Ollama Provider with model:', process.env.OLLAMA_MODEL || 'granite4:latest');
return new OllamaProvider(
process.env.OLLAMA_BASE_URL || 'http://localhost:11434/api',
process.env.OLLAMA_MODEL || 'granite4:latest'
);
let baseUrl = config?.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
const model = config?.AI_MODEL_TAGS || process.env.OLLAMA_MODEL || 'granite4:latest';
const embedModel = config?.AI_MODEL_EMBEDDING || process.env.OLLAMA_EMBEDDING_MODEL || 'embeddinggemma:latest';
// 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, model, embedModel);
case 'openai':
default:
if (!process.env.OPENAI_API_KEY) {
console.warn('OPENAI_API_KEY non configurée. Les fonctions IA pourraient échouer.');
const apiKey = config?.OPENAI_API_KEY || process.env.OPENAI_API_KEY || '';
const aiModel = config?.AI_MODEL_TAGS || process.env.OPENAI_MODEL || 'gpt-4o-mini';
if (!apiKey && providerType.toLowerCase() === 'openai') {
console.warn('OPENAI_API_KEY non configurée.');
}
return new OpenAIProvider(
process.env.OPENAI_API_KEY || '',
process.env.OPENAI_MODEL || 'gpt-4o-mini'
);
return new OpenAIProvider(apiKey, aiModel);
}
}

View File

@@ -3,10 +3,13 @@ import { AIProvider, TagSuggestion } from '../types';
export class OllamaProvider implements AIProvider {
private baseUrl: string;
private modelName: string;
private embeddingModelName: string;
constructor(baseUrl: string = 'http://localhost:11434/api', modelName: string = 'llama3') {
this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
constructor(baseUrl: string = 'http://localhost:11434', modelName: string = 'llama3', embeddingModelName?: string) {
// Ensure baseUrl ends with /api for Ollama API
this.baseUrl = baseUrl.endsWith('/api') ? baseUrl : `${baseUrl}/api`;
this.modelName = modelName;
this.embeddingModelName = embeddingModelName || modelName;
}
async generateTags(content: string): Promise<TagSuggestion[]> {
@@ -16,7 +19,7 @@ export class OllamaProvider implements AIProvider {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
prompt: `Analyse la note suivante et extrais les concepts clés sous forme de tags courts (1-3 mots max).
prompt: `Analyse la note suivante et extrais les concepts clés sous forme de tags courts (1-3 mots max).
Règles:
- Pas de mots de liaison (le, la, pour, et...).
@@ -36,13 +39,13 @@ export class OllamaProvider implements AIProvider {
const data = await response.json();
const text = data.response;
const jsonMatch = text.match(/\[\s*\{.*\}\s*\]/s);
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);
const objectMatch = text.match(/\{\s*"tags"\s*:\s*(\[[\s\S]*\])\s*\}/);
if (objectMatch && objectMatch[1]) {
return JSON.parse(objectMatch[1]);
}
@@ -60,7 +63,7 @@ export class OllamaProvider implements AIProvider {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
model: this.embeddingModelName,
prompt: text,
}),
});
@@ -74,4 +77,4 @@ export class OllamaProvider implements AIProvider {
return [];
}
}
}
}

View File

@@ -22,4 +22,5 @@ export interface AIConfig {
apiKey?: string;
baseUrl?: string; // Utile pour Ollama
model?: string;
embeddingModel?: string;
}

55
keep-notes/lib/config.ts Normal file
View File

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

View File

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

65
keep-notes/lib/mail.ts Normal file
View File

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

View File

@@ -1,4 +1,5 @@
import { PrismaClient } from '@prisma/client'
// @ts-ignore - Generated client
import { PrismaClient } from '../prisma/client-generated'
const prismaClientSingleton = () => {
return new PrismaClient({

View File

@@ -37,11 +37,17 @@ export interface Note {
reminderRecurrence: string | null;
reminderLocation: string | null;
isMarkdown: boolean;
size: NoteSize;
order: number;
createdAt: Date;
updatedAt: Date;
embedding?: number[] | null;
sharedWith?: string[];
userId?: string | null;
}
export type NoteSize = 'small' | 'medium' | 'large';
export interface LabelWithColor {
name: string;
color: LabelColorName;
@@ -160,3 +166,11 @@ export const NOTE_COLORS = {
} 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';

View File

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