diff --git a/memento-note/app/actions/notes.ts b/memento-note/app/actions/notes.ts index d73adc7..f4bc508 100644 --- a/memento-note/app/actions/notes.ts +++ b/memento-note/app/actions/notes.ts @@ -5,7 +5,7 @@ import prisma from '@/lib/prisma' import { Note, CheckItem } from '@/lib/types' import { auth } from '@/auth' import { getAIProvider } from '@/lib/ai/factory' -import { parseNote as parseNoteUtil, cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils' +import { parseNote as parseNoteUtil, cosineSimilarity, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils' import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config' import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service' import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup' @@ -50,22 +50,9 @@ const NOTE_LIST_SELECT = { // embedding: false — volontairement omis (économise ~6KB JSON/note) } as const -// Wrapper for parseNote that validates embeddings +// Wrapper for parseNote (embedding validation removed - embeddings are now in NoteEmbedding table) function parseNote(dbNote: any): Note { - const note = parseNoteUtil(dbNote) - - // Validate embedding if present - if (note.embedding && Array.isArray(note.embedding)) { - const validation = validateEmbedding(note.embedding) - if (!validation.valid) { - return { - ...note, - embedding: null - } - } - } - - return note + return parseNoteUtil(dbNote) } // Helper to get hash color for labels (copied from utils) diff --git a/memento-note/app/actions/register.ts b/memento-note/app/actions/register.ts index 1ea3ca6..777db30 100644 --- a/memento-note/app/actions/register.ts +++ b/memento-note/app/actions/register.ts @@ -15,7 +15,7 @@ const RegisterSchema = z.object({ export async function register(prevState: string | undefined, formData: FormData) { // Check if registration is allowed const config = await getSystemConfig(); - const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false'; + const allowRegister = config.ALLOW_REGISTRATION !== 'false' || process.env.ALLOW_REGISTRATION !== 'false'; if (!allowRegister) { return 'Registration is currently disabled by the administrator.'; diff --git a/memento-note/app/actions/scrape.ts b/memento-note/app/actions/scrape.ts index c47d149..f9e9d07 100644 --- a/memento-note/app/actions/scrape.ts +++ b/memento-note/app/actions/scrape.ts @@ -18,6 +18,14 @@ export async function fetchLinkMetadata(url: string): Promise c !== 'gray'); // Exclude gray to force colors diff --git a/memento-note/app/api/admin/sync-labels/route.ts b/memento-note/app/api/admin/sync-labels/route.ts index f4a17c7..65272e7 100644 --- a/memento-note/app/api/admin/sync-labels/route.ts +++ b/memento-note/app/api/admin/sync-labels/route.ts @@ -1,10 +1,15 @@ import { NextResponse } from 'next/server'; import prisma from '@/lib/prisma'; +import { auth } from '@/auth'; export const dynamic = 'force-dynamic'; export async function GET() { try { + const session = await auth() + if (!session?.user?.id || (session.user as any).role !== 'ADMIN') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } // 1. Get all notes const notes = await prisma.note.findMany({ select: { labels: true } diff --git a/memento-note/app/api/ai/title-suggestions/route.ts b/memento-note/app/api/ai/title-suggestions/route.ts index 0d1b00a..901566e 100644 --- a/memento-note/app/api/ai/title-suggestions/route.ts +++ b/memento-note/app/api/ai/title-suggestions/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { getAIProvider } from '@/lib/ai/factory' +import { getTagsProvider } from '@/lib/ai/factory' import { getSystemConfig } from '@/lib/config' import { z } from 'zod' @@ -23,7 +23,7 @@ export async function POST(req: NextRequest) { } const config = await getSystemConfig() - const provider = getAIProvider(config) + const provider = getTagsProvider(config) // Détecter la langue du contenu (simple détection basée sur les caractères et mots) const hasNonLatinChars = /[\u0400-\u04FF\u0600-\u06FF\u4E00-\u9FFF\u0E00-\u0E7F]/.test(content) diff --git a/memento-note/app/api/ai/transform-markdown/route.ts b/memento-note/app/api/ai/transform-markdown/route.ts index 4b2b046..bc1caee 100644 --- a/memento-note/app/api/ai/transform-markdown/route.ts +++ b/memento-note/app/api/ai/transform-markdown/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/auth' -import { getAIProvider } from '@/lib/ai/factory' +import { getChatProvider } from '@/lib/ai/factory' import { getSystemConfig } from '@/lib/config' export async function POST(request: NextRequest) { @@ -35,7 +35,7 @@ export async function POST(request: NextRequest) { } const config = await getSystemConfig() - const provider = getAIProvider(config) + const provider = getChatProvider(config) // Detect language from text const hasFrench = /[àâäéèêëïîôùûüÿç]/i.test(text) diff --git a/memento-note/app/api/notes/[id]/route.ts b/memento-note/app/api/notes/[id]/route.ts index 3a8c392..bef303e 100644 --- a/memento-note/app/api/notes/[id]/route.ts +++ b/memento-note/app/api/notes/[id]/route.ts @@ -94,7 +94,14 @@ export async function PUT( } const body = await request.json() - const updateData: any = { ...body } + // Whitelist allowed fields to prevent mass assignment + const allowedFields = ['title', 'content', 'color', 'isPinned', 'isArchived', 'type', 'isMarkdown', 'size', 'notebookId'] + const updateData: Record = {} + for (const key of allowedFields) { + if (key in body) { + updateData[key] = body[key] + } + } if ('checkItems' in body) { updateData.checkItems = body.checkItems ?? null diff --git a/memento-note/app/api/upload/route.ts b/memento-note/app/api/upload/route.ts index 27cb4a1..cd067de 100644 --- a/memento-note/app/api/upload/route.ts +++ b/memento-note/app/api/upload/route.ts @@ -2,9 +2,18 @@ import { NextRequest, NextResponse } from 'next/server' import { writeFile, mkdir } from 'fs/promises' import path from 'path' import { randomUUID } from 'crypto' +import { auth } from '@/auth' + +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] +const MAX_SIZE = 5 * 1024 * 1024 // 5MB export async function POST(request: NextRequest) { try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const formData = await request.formData() const file = formData.get('file') as File @@ -15,8 +24,20 @@ export async function POST(request: NextRequest) { ) } + if (!ALLOWED_TYPES.includes(file.type)) { + return NextResponse.json({ error: 'Invalid file type' }, { status: 400 }) + } + + if (file.size > MAX_SIZE) { + return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 400 }) + } + const buffer = Buffer.from(await file.arrayBuffer()) - const filename = `${randomUUID()}${path.extname(file.name)}` + const ext = path.extname(file.name).toLowerCase() + if (!['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) { + return NextResponse.json({ error: 'Invalid file extension' }, { status: 400 }) + } + const filename = `${randomUUID()}${ext}` // Ensure directory exists const uploadDir = path.join(process.cwd(), 'public/uploads/notes') diff --git a/memento-note/auth.config.ts b/memento-note/auth.config.ts index 865f49b..b2ecb44 100644 --- a/memento-note/auth.config.ts +++ b/memento-note/auth.config.ts @@ -14,11 +14,17 @@ export const authConfig = { authorized({ auth, request: { nextUrl } }) { const isLoggedIn = !!auth?.user; const isAdmin = (auth?.user as any)?.role === 'ADMIN'; - const isDashboardPage = nextUrl.pathname === '/' || - nextUrl.pathname.startsWith('/reminders') || - nextUrl.pathname.startsWith('/archive') || + const isDashboardPage = nextUrl.pathname === '/' || + nextUrl.pathname.startsWith('/reminders') || + nextUrl.pathname.startsWith('/archive') || nextUrl.pathname.startsWith('/trash') || - nextUrl.pathname.startsWith('/settings'); + nextUrl.pathname.startsWith('/settings') || + nextUrl.pathname.startsWith('/lab') || + nextUrl.pathname.startsWith('/agents') || + nextUrl.pathname.startsWith('/chat') || + nextUrl.pathname.startsWith('/canvas') || + nextUrl.pathname.startsWith('/notebooks') || + nextUrl.pathname.startsWith('/note/'); const isAdminPage = nextUrl.pathname.startsWith('/admin'); if (isAdminPage) { diff --git a/memento-note/components/ghost-tags.tsx b/memento-note/components/ghost-tags.tsx index 2c8d778..1e3a017 100644 --- a/memento-note/components/ghost-tags.tsx +++ b/memento-note/components/ghost-tags.tsx @@ -21,12 +21,6 @@ export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, on // On filtre pour l'affichage conditionnel global, mais on garde les tags ajoutés pour l'affichage visuel "validé" const visibleSuggestions = suggestions; - // Show help message if not analyzing and no suggestions (but don't return null) - const isEmpty = !isAnalyzing && visibleSuggestions.length === 0; - - // FIX: Never return null, always show something (either tags, analyzer, or help message) - // This ensures the help message "Tapez du contenu..." is always shown when needed - return (
@@ -47,7 +41,7 @@ export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, on const isAdded = addedTags.some(t => t.toLowerCase() === suggestion.tag.toLowerCase()); const colorName = getHashColor(suggestion.tag); const colorClasses = LABEL_COLORS[colorName]; - const isNewLabel = (suggestion as any).isNewLabel; // Check if this is a new label suggestion + const isNewLabel = suggestion.isNewLabel; if (isAdded) { // Tag déjà ajouté : on l'affiche en mode "confirmé" statique pour ne pas perdre le focus diff --git a/memento-note/components/note-editor.tsx b/memento-note/components/note-editor.tsx index 32f93fa..2ebdcb1 100644 --- a/memento-note/components/note-editor.tsx +++ b/memento-note/components/note-editor.tsx @@ -36,6 +36,7 @@ import { EditorImages } from './editor-images' import { useAutoTagging } from '@/hooks/use-auto-tagging' import { GhostTags } from './ghost-tags' import { TitleSuggestions } from './title-suggestions' +import type { TitleSuggestion } from '@/hooks/use-title-suggestions' import { EditorConnectionsSection } from './editor-connections-section' import { ComparisonModal } from './comparison-modal' import { FusionModal } from './fusion-modal' @@ -76,12 +77,11 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) setContextNotebookId(note.notebookId || null) }, [note.notebookId, setContextNotebookId]) - // Auto-tagging hook - use note.content from props instead of local state - // This ensures triggering when notebookId changes (e.g., after moving note to notebook) + // Auto-tagging hook - use local state for live suggestions as user types const { suggestions, isAnalyzing } = useAutoTagging({ - content: note.type === 'text' ? (note.content || '') : '', - notebookId: note.notebookId, // Pass notebookId for contextual label suggestions (IA2) - enabled: note.type === 'text' // Auto-tagging only for text notes + content: note.type === 'text' ? content : '', + notebookId: note.notebookId, + enabled: note.type === 'text' }) // Reminder state @@ -95,7 +95,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) const [linkUrl, setLinkUrl] = useState('') // Title suggestions state - const [titleSuggestions, setTitleSuggestions] = useState([]) + const [titleSuggestions, setTitleSuggestions] = useState([]) const [isGeneratingTitles, setIsGeneratingTitles] = useState(false) // Reformulation state diff --git a/memento-note/components/note-inline-editor.tsx b/memento-note/components/note-inline-editor.tsx index f5cb3fe..4056963 100644 --- a/memento-note/components/note-inline-editor.tsx +++ b/memento-note/components/note-inline-editor.tsx @@ -290,26 +290,41 @@ export function NoteInlineEditor({ // ── Quick actions (pin, archive, color, delete) ─────────────────────────── const handleTogglePin = () => { + const prev = note.isPinned startTransition(async () => { - // Optimitistic update - onChange?.(note.id, { isPinned: !note.isPinned }) - // Call with skipRevalidation to avoid server layout refresh interfering with optimistic state - await updateNote(note.id, { isPinned: !note.isPinned }, { skipRevalidation: true }) - toast.success(note.isPinned ? t('notes.unpinned') || 'Désépinglée' : t('notes.pinned') || 'Épinglée') + onChange?.(note.id, { isPinned: !prev }) + try { + await updateNote(note.id, { isPinned: !prev }, { skipRevalidation: true }) + toast.success(prev ? t('notes.unpinned') || 'Désépinglée' : t('notes.pinned') || 'Épinglée') + } catch { + onChange?.(note.id, { isPinned: prev }) + toast.error(t('general.error')) + } }) } const handleToggleArchive = () => { startTransition(async () => { onArchive?.(note.id) - await updateNote(note.id, { isArchived: !note.isArchived }, { skipRevalidation: true }) + try { + await updateNote(note.id, { isArchived: !note.isArchived }, { skipRevalidation: true }) + } catch { + // Cannot easily revert since onArchive removes from list + toast.error(t('general.error')) + } }) } const handleColorChange = (color: string) => { + const prev = color startTransition(async () => { onChange?.(note.id, { color }) - await updateNote(note.id, { color }, { skipRevalidation: true }) + try { + await updateNote(note.id, { color }, { skipRevalidation: true }) + } catch { + onChange?.(note.id, { color: prev }) + toast.error(t('general.error')) + } }) } diff --git a/memento-note/docker-compose.yml b/memento-note/docker-compose.yml index 6ff17ae..890f2f2 100644 --- a/memento-note/docker-compose.yml +++ b/memento-note/docker-compose.yml @@ -3,23 +3,23 @@ version: '3.8' services: postgres: image: postgres:16-alpine - container_name: keep-postgres + container_name: memento-postgres restart: unless-stopped environment: - POSTGRES_USER: keepnotes - POSTGRES_PASSWORD: keepnotes - POSTGRES_DB: keepnotes + POSTGRES_USER: memento + POSTGRES_PASSWORD: memento + POSTGRES_DB: memento volumes: - postgres-data:/var/lib/postgresql/data ports: - "5432:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U keepnotes"] + test: ["CMD-SHELL", "pg_isready -U memento"] interval: 5s timeout: 5s retries: 5 networks: - - keep-network + - memento-network memento-note: build: @@ -32,7 +32,7 @@ services: - "3000:3000" environment: # Database - - DATABASE_URL=postgresql://keepnotes:keepnotes@postgres:5432/keepnotes + - DATABASE_URL=postgresql://memento:memento@postgres:5432/memento - NODE_ENV=production # Application (IMPORTANT: Change these!) @@ -58,7 +58,7 @@ services: postgres: condition: service_healthy networks: - - keep-network + - memento-network # Optional: Resource limits for Proxmox VM deploy: resources: @@ -87,7 +87,7 @@ services: # volumes: # - ollama-data:/root/.ollama # networks: - # - keep-network + # - memento-network # deploy: # resources: # limits: @@ -98,7 +98,7 @@ services: # memory: 4G networks: - keep-network: + memento-network: driver: bridge volumes: diff --git a/memento-note/hooks/use-auto-tagging.ts b/memento-note/hooks/use-auto-tagging.ts index 5604da6..7a1cf82 100644 --- a/memento-note/hooks/use-auto-tagging.ts +++ b/memento-note/hooks/use-auto-tagging.ts @@ -1,5 +1,5 @@ import { useLanguage } from '@/lib/i18n' -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useDebounce } from './use-debounce'; import { TagSuggestion } from '@/lib/ai/types'; @@ -20,13 +20,20 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT // Track previous notebookId to detect when note is moved to a notebook const previousNotebookId = useRef(notebookId); + // AbortController for cancelling in-flight requests + const abortRef = useRef(null); - const analyzeContent = async (contentToAnalyze: string) => { + const analyzeContent = useCallback(async (contentToAnalyze: string, currentNotebookId?: string | null, currentLanguage?: string) => { if (!contentToAnalyze || contentToAnalyze.length < 10) { setSuggestions([]); return; } + // Cancel previous request + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setIsAnalyzing(true); setError(null); @@ -36,23 +43,29 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: contentToAnalyze, - notebookId: notebookId || undefined, - language: language || document.documentElement.lang || 'en', + notebookId: currentNotebookId || undefined, + language: currentLanguage || document.documentElement.lang || 'en', }), + signal: controller.signal, }); + if (controller.signal.aborted) return; + if (!response.ok) { throw new Error('Error during analysis'); } const data = await response.json(); setSuggestions(data.tags || []); - } catch (err) { + } catch (err: any) { + if (err.name === 'AbortError') return; setError('Failed to generate suggestions'); } finally { - setIsAnalyzing(false); + if (!controller.signal.aborted) { + setIsAnalyzing(false); + } } - }; + }, []); // Trigger on content change useEffect(() => { @@ -61,24 +74,27 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT return; } - analyzeContent(debouncedContent); - }, [debouncedContent, enabled]); + analyzeContent(debouncedContent, notebookId, language); + }, [debouncedContent, enabled, notebookId, language, analyzeContent]); - // CRITICAL: Also trigger when notebookId changes from null/undefined to a value (note moved to notebook) + // Trigger when notebookId changes from null/undefined to a value useEffect(() => { if (!enabled) return; const prev = previousNotebookId.current; previousNotebookId.current = notebookId; - // Detect when note is moved FROM "General Notes" (null) TO a notebook const wasMovedToNotebook = (prev === null || prev === undefined) && notebookId; if (wasMovedToNotebook && content && content.length >= 10) { - // Use current content immediately (no debounce) when moving to notebook - analyzeContent(content); + analyzeContent(content, notebookId, language); } - }, [notebookId, content, enabled]); + }, [notebookId, content, enabled, language, analyzeContent]); + + // Cleanup on unmount + useEffect(() => { + return () => { abortRef.current?.abort(); }; + }, []); return { suggestions, diff --git a/memento-note/hooks/use-title-suggestions.ts b/memento-note/hooks/use-title-suggestions.ts index b76fd20..80d2eb7 100644 --- a/memento-note/hooks/use-title-suggestions.ts +++ b/memento-note/hooks/use-title-suggestions.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useDebounce } from './use-debounce' export interface TitleSuggestion { @@ -16,6 +16,7 @@ export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggest const [suggestions, setSuggestions] = useState([]) const [isAnalyzing, setIsAnalyzing] = useState(false) const [error, setError] = useState(null) + const abortRef = useRef(null) // Debounce content by 2s to avoid excessive API calls const debouncedContent = useDebounce(content, 2000) @@ -34,6 +35,10 @@ export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggest return } + // Cancel previous request + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller const generateTitles = async () => { setIsAnalyzing(true) @@ -44,8 +49,10 @@ export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggest method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: debouncedContent }), + signal: controller.signal, }) + if (controller.signal.aborted) return if (!response.ok) { const errorData = await response.json() @@ -54,17 +61,25 @@ export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggest const data = await response.json() setSuggestions(data.suggestions || []) - } catch (err) { - console.error('❌ Title suggestions error:', err) + } catch (err: any) { + if (err.name === 'AbortError') return + console.error('Title suggestions error:', err) setError('Failed to generate title suggestions') } finally { - setIsAnalyzing(false) + if (!controller.signal.aborted) { + setIsAnalyzing(false) + } } } generateTitles() }, [debouncedContent, enabled]) + // Cleanup on unmount + useEffect(() => { + return () => { abortRef.current?.abort(); }; + }, []); + return { suggestions, isAnalyzing, diff --git a/memento-note/hooks/useUndoRedo.ts b/memento-note/hooks/useUndoRedo.ts deleted file mode 100644 index efd2ef6..0000000 --- a/memento-note/hooks/useUndoRedo.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { useState, useCallback, useRef } from 'react' -import { deepEqual } from '@/lib/utils' - -export interface UndoRedoState { - past: T[] - present: T - future: T[] -} - -interface UseUndoRedoReturn { - state: T - setState: (newState: T | ((prev: T) => T)) => void - undo: () => void - redo: () => void - canUndo: boolean - canRedo: boolean - clear: () => void -} - -const MAX_HISTORY_SIZE = 50 - -export function useUndoRedo(initialState: T): UseUndoRedoReturn { - const [history, setHistory] = useState>({ - past: [], - present: initialState, - future: [], - }) - - // Track if we're in an undo/redo operation to prevent adding to history - const isUndoRedoAction = useRef(false) - - const setState = useCallback((newState: T | ((prev: T) => T)) => { - // Skip if this is an undo/redo action - if (isUndoRedoAction.current) { - isUndoRedoAction.current = false - return - } - - setHistory((currentHistory) => { - const resolvedNewState = - typeof newState === 'function' - ? (newState as (prev: T) => T)(currentHistory.present) - : newState - - // Don't add to history if state hasn't changed - if (deepEqual(resolvedNewState, currentHistory.present)) { - return currentHistory - } - - const newPast = [...currentHistory.past, currentHistory.present] - - // Limit history size - if (newPast.length > MAX_HISTORY_SIZE) { - newPast.shift() - } - - return { - past: newPast, - present: resolvedNewState, - future: [], // Clear future on new action - } - }) - }, []) - - const undo = useCallback(() => { - setHistory((currentHistory) => { - if (currentHistory.past.length === 0) return currentHistory - - const previous = currentHistory.past[currentHistory.past.length - 1] - const newPast = currentHistory.past.slice(0, currentHistory.past.length - 1) - - isUndoRedoAction.current = true - - return { - past: newPast, - present: previous, - future: [currentHistory.present, ...currentHistory.future], - } - }) - }, []) - - const redo = useCallback(() => { - setHistory((currentHistory) => { - if (currentHistory.future.length === 0) return currentHistory - - const next = currentHistory.future[0] - const newFuture = currentHistory.future.slice(1) - - isUndoRedoAction.current = true - - return { - past: [...currentHistory.past, currentHistory.present], - present: next, - future: newFuture, - } - }) - }, []) - - const clear = useCallback(() => { - setHistory({ - past: [], - present: initialState, - future: [], - }) - }, [initialState]) - - return { - state: history.present, - setState, - undo, - redo, - canUndo: history.past.length > 0, - canRedo: history.future.length > 0, - clear, - } -} diff --git a/memento-note/lib/ai/factory.ts b/memento-note/lib/ai/factory.ts index 6e761f3..32f5856 100644 --- a/memento-note/lib/ai/factory.ts +++ b/memento-note/lib/ai/factory.ts @@ -3,7 +3,7 @@ import { OllamaProvider } from './providers/ollama'; import { CustomOpenAIProvider } from './providers/custom-openai'; import { AIProvider } from './types'; -type ProviderType = 'ollama' | 'openai' | 'custom'; +type ProviderType = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter'; function createOllamaProvider(config: Record, modelName: string, embeddingModelName: string): OllamaProvider { let baseUrl = config?.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL @@ -50,6 +50,18 @@ function createCustomOpenAIProvider(config: Record, modelName: s return new CustomOpenAIProvider(apiKey, baseUrl, modelName, embeddingModelName); } +function createDeepSeekProvider(config: Record, modelName: string, embeddingModelName: string): CustomOpenAIProvider { + const apiKey = config?.DEEPSEEK_API_KEY || config?.CUSTOM_OPENAI_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.CUSTOM_OPENAI_API_KEY || ''; + if (!apiKey) throw new Error('DEEPSEEK_API_KEY is required when using DeepSeek provider'); + return new CustomOpenAIProvider(apiKey, 'https://api.deepseek.com/v1', modelName, embeddingModelName); +} + +function createOpenRouterProvider(config: Record, modelName: string, embeddingModelName: string): CustomOpenAIProvider { + const apiKey = config?.OPENROUTER_API_KEY || config?.CUSTOM_OPENAI_API_KEY || process.env.OPENROUTER_API_KEY || process.env.CUSTOM_OPENAI_API_KEY || ''; + if (!apiKey) throw new Error('OPENROUTER_API_KEY is required when using OpenRouter provider'); + return new CustomOpenAIProvider(apiKey, 'https://openrouter.ai/api/v1', modelName, embeddingModelName); +} + function getProviderInstance(providerType: ProviderType, config: Record, modelName: string, embeddingModelName: string): AIProvider { switch (providerType) { case 'ollama': @@ -58,6 +70,10 @@ function getProviderInstance(providerType: ProviderType, config: Record = { async function generateTitle(content: string, agentName: string, lang: Lang): Promise { try { const sysConfig = await getSystemConfig() - const provider = getAIProvider(sysConfig) + const provider = getChatProvider(sysConfig) const prompt = `${TITLE_PROMPTS[lang]}${content.substring(0, 800)}` const title = await provider.generateText(prompt) return title.trim().replace(/^["']|["']$/g, '').substring(0, 80) @@ -297,7 +297,7 @@ async function executeScraperAgent( return { success: false, actionId, error: msg } } - const provider = getAIProvider(sysConfig) + const provider = getChatProvider(sysConfig) const combinedContent = scrapedParts.join('\n\n---\n\n') // Extract images BEFORE generating summary so AI can embed them @@ -368,7 +368,7 @@ async function executeResearcherAgent( const topic = agent.description || agent.name const sysConfig = await getSystemConfig() - const provider = getAIProvider(sysConfig) + const provider = getChatProvider(sysConfig) const queryPrompt = lang === 'fr' ? `Tu es un assistant de recherche. Pour le sujet suivant, génère 3 requêtes de recherche web pertinentes (une par ligne, sans numérotation):\n\nSujet: ${topic}` @@ -503,7 +503,7 @@ async function executeMonitorAgent( } const sysConfig = await getSystemConfig() - const provider = getAIProvider(sysConfig) + const provider = getChatProvider(sysConfig) const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US' const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled' @@ -562,7 +562,7 @@ async function executeCustomAgent( lang: Lang ): Promise { const sysConfig = await getSystemConfig() - const provider = getAIProvider(sysConfig) + const provider = getChatProvider(sysConfig) let inputContent = '' const urls: string[] = agent.sourceUrls ? JSON.parse(agent.sourceUrls) : [] diff --git a/memento-note/lib/ai/services/memory-echo.service.ts b/memento-note/lib/ai/services/memory-echo.service.ts index c2fec29..824680a 100644 --- a/memento-note/lib/ai/services/memory-echo.service.ts +++ b/memento-note/lib/ai/services/memory-echo.service.ts @@ -1,4 +1,4 @@ -import { getAIProvider } from '../factory' +import { getAIProvider, getChatProvider } from '../factory' import { cosineSimilarity } from '@/lib/utils' import { getSystemConfig } from '@/lib/config' import prisma from '@/lib/prisma' @@ -216,7 +216,7 @@ export class MemoryEchoService { ): Promise { try { const config = await getSystemConfig() - const provider = getAIProvider(config) + const provider = getChatProvider(config) const note1Desc = note1Title || 'Untitled note' const note2Desc = note2Title || 'Untitled note' diff --git a/memento-note/lib/ai/services/notebook-suggestion.service.ts b/memento-note/lib/ai/services/notebook-suggestion.service.ts index bc5724a..5e71ece 100644 --- a/memento-note/lib/ai/services/notebook-suggestion.service.ts +++ b/memento-note/lib/ai/services/notebook-suggestion.service.ts @@ -1,5 +1,5 @@ import { prisma } from '@/lib/prisma' -import { getAIProvider } from '@/lib/ai/factory' +import { getTagsProvider } from '@/lib/ai/factory' import { getSystemConfig } from '@/lib/config' import type { Notebook } from '@/lib/types' @@ -33,7 +33,7 @@ export class NotebookSuggestionService { // 3. Call AI try { const config = await getSystemConfig() - const provider = getAIProvider(config) + const provider = getTagsProvider(config) const response = await provider.generateText(prompt) diff --git a/memento-note/lib/ai/services/notebook-summary.service.ts b/memento-note/lib/ai/services/notebook-summary.service.ts index dcf6423..5aaaf9e 100644 --- a/memento-note/lib/ai/services/notebook-summary.service.ts +++ b/memento-note/lib/ai/services/notebook-summary.service.ts @@ -1,5 +1,5 @@ import { prisma } from '@/lib/prisma' -import { getAIProvider } from '@/lib/ai/factory' +import { getChatProvider } from '@/lib/ai/factory' import { getSystemConfig } from '@/lib/config' export interface NotebookSummary { @@ -127,7 +127,7 @@ ${content}...` try { const config = await getSystemConfig() - const provider = getAIProvider(config) + const provider = getChatProvider(config) const summary = await provider.generateText(prompt) return summary.trim() } catch (error) { diff --git a/memento-note/lib/ai/services/title-suggestion.service.ts b/memento-note/lib/ai/services/title-suggestion.service.ts index c737aec..1e2a533 100644 --- a/memento-note/lib/ai/services/title-suggestion.service.ts +++ b/memento-note/lib/ai/services/title-suggestion.service.ts @@ -3,20 +3,10 @@ * 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') -} +import { getTagsProvider } from '../factory' +import { getSystemConfig } from '@/lib/config' export interface TitleSuggestion { title: string @@ -40,7 +30,9 @@ export class TitleSuggestionService { const { language: contentLanguage } = await this.languageDetection.detectLanguage(noteContent) try { - const model = getTextGenerationModel() + const config = await getSystemConfig() + const provider = getTagsProvider(config) + const model = provider.getModel() // System prompt - explains what to do const systemPrompt = `You are an expert title generator for a note-taking application. diff --git a/memento-note/lib/ai/types.ts b/memento-note/lib/ai/types.ts index 6b1030c..5c0fc5c 100644 --- a/memento-note/lib/ai/types.ts +++ b/memento-note/lib/ai/types.ts @@ -1,11 +1,14 @@ export interface TagSuggestion { tag: string; confidence: number; + reasoning?: string; + isNewLabel?: boolean; } export interface TitleSuggestion { title: string; confidence: number; + reasoning?: string; } export interface ToolUseOptions { @@ -64,12 +67,4 @@ export interface AIProvider { generateWithTools(options: ToolUseOptions): Promise; } -export type AIProviderType = 'openai' | 'ollama'; - -export interface AIConfig { - provider: AIProviderType; - apiKey?: string; - baseUrl?: string; // Used for Ollama - model?: string; - embeddingModel?: string; -} +export type AIProviderType = 'openai' | 'ollama' | 'custom' | 'deepseek' | 'openrouter'; diff --git a/memento-note/lib/config.ts b/memento-note/lib/config.ts index cad65c2..701004e 100644 --- a/memento-note/lib/config.ts +++ b/memento-note/lib/config.ts @@ -1,5 +1,4 @@ import prisma from './prisma' -import { unstable_cache } from 'next/cache' export async function getSystemConfig() { try { diff --git a/memento-note/lib/prisma.ts b/memento-note/lib/prisma.ts index 8bbf5ab..83b16d9 100644 --- a/memento-note/lib/prisma.ts +++ b/memento-note/lib/prisma.ts @@ -4,7 +4,7 @@ const prismaClientSingleton = () => { return new PrismaClient({ datasources: { db: { - url: process.env.DATABASE_URL || "file:/Users/sepehr/dev/Momento/memento-note/prisma/dev.db", + url: process.env.DATABASE_URL, }, }, }) diff --git a/memento-note/lib/types.ts b/memento-note/lib/types.ts index 9b4a8ab..0b15ea0 100644 --- a/memento-note/lib/types.ts +++ b/memento-note/lib/types.ts @@ -68,10 +68,9 @@ export interface Note { createdAt: Date; updatedAt: Date; contentUpdatedAt: Date; - embedding?: number[] | null; sharedWith?: string[]; userId?: string | null; - // NEW: Notebook relation (optional - null = "Notes générales" / Inbox) + // Notebook relation (optional - null = "General Notes" / Inbox) notebookId?: string | null; notebook?: Notebook | null; autoGenerated?: boolean | null;