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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
55
keep-notes/lib/config.ts
Normal 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;
|
||||
35
keep-notes/lib/email-template.ts
Normal file
35
keep-notes/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>
|
||||
`;
|
||||
}
|
||||
65
keep-notes/lib/mail.ts
Normal file
65
keep-notes/lib/mail.ts
Normal 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})`
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
// @ts-ignore - Generated client
|
||||
import { PrismaClient } from '../prisma/client-generated'
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
return new PrismaClient({
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user