fix: comprehensive security, consistency, and dead code cleanup

Security:
- Add auth + file type/size validation to upload API
- Add admin auth to /api/admin/ endpoints
- Add SSRF protection to scrape action
- Whitelist fields in PUT /api/notes/[id] to prevent mass assignment
- Protect /lab, /agents, /chat, /canvas, /notebooks routes in middleware

AI provider fixes:
- Add deepseek/openrouter to factory ProviderType (was silently falling back to ollama)
- Fix title-suggestion.service.ts to use factory instead of hardcoded OpenAI
- Fix getAIProvider→getChatProvider in memory-echo, notebook-summary, agent-executor
- Fix getAIProvider→getTagsProvider in notebook-suggestion, title-suggestions, transform-markdown

Functional bugs:
- Fix ALLOW_REGISTRATION AND→OR logic
- Fix note-editor.tsx passing stale props to useAutoTagging instead of local state
- Fix stale Note.embedding type (migrated to NoteEmbedding table)
- Remove hardcoded SQLite path from prisma.ts

Frontend:
- Add AbortController to useAutoTagging and useTitleSuggestions hooks
- Add error rollback to optimistic UI in note-inline-editor
- Remove stale closure over notebookId/language in useAutoTagging

Cleanup:
- Rename docker-compose from keepnotes→memento
- Remove unused unstable_cache import from config.ts
- Remove dead useUndoRedo hook
- Fix TagSuggestion type (add isNewLabel, reasoning)
- Remove dead AIConfig/AIProviderType types
- Fix ghost-tags unused isEmpty var and as any cast
- Fix note-editor titleSuggestions typed as any[]

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-04-21 21:39:10 +02:00
parent 3c8e347576
commit 1c659ce42f
27 changed files with 194 additions and 230 deletions

View File

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

View File

@@ -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.';

View File

@@ -18,6 +18,14 @@ export async function fetchLinkMetadata(url: string): Promise<LinkMetadata | nul
targetUrl = 'https://' + url;
}
// SSRF protection: block internal/private IPs
const parsed = new URL(targetUrl)
const hostname = parsed.hostname.toLowerCase()
const blockedHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1', '169.254.169.254']
if (blockedHosts.includes(hostname)) return null
if (hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('192.168.') || hostname.startsWith('fc') || hostname.startsWith('fd')) return null
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
const response = await fetch(targetUrl, {
headers: {
// Use a real browser User-Agent to avoid 403 Forbidden from strict sites

View File

@@ -1,11 +1,16 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { LABEL_COLORS } from '@/lib/types';
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 })
}
const labels = await prisma.label.findMany();
const colors = Object.keys(LABEL_COLORS).filter(c => c !== 'gray'); // Exclude gray to force colors

View File

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

View File

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

View File

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

View File

@@ -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<string, any> = {}
for (const key of allowedFields) {
if (key in body) {
updateData[key] = body[key]
}
}
if ('checkItems' in body) {
updateData.checkItems = body.checkItems ?? null

View File

@@ -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')

View File

@@ -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) {

View File

@@ -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 (
<div className={cn("flex flex-wrap items-center gap-2 mt-2 min-h-[24px] transition-all duration-500", className)}>
@@ -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

View File

@@ -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<any[]>([])
const [titleSuggestions, setTitleSuggestions] = useState<TitleSuggestion[]>([])
const [isGeneratingTitles, setIsGeneratingTitles] = useState(false)
// Reformulation state

View File

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

View File

@@ -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:

View File

@@ -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<string | null | undefined>(notebookId);
// AbortController for cancelling in-flight requests
const abortRef = useRef<AbortController | null>(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,

View File

@@ -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<TitleSuggestion[]>([])
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [error, setError] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(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,

View File

@@ -1,116 +0,0 @@
import { useState, useCallback, useRef } from 'react'
import { deepEqual } from '@/lib/utils'
export interface UndoRedoState<T> {
past: T[]
present: T
future: T[]
}
interface UseUndoRedoReturn<T> {
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<T>(initialState: T): UseUndoRedoReturn<T> {
const [history, setHistory] = useState<UndoRedoState<T>>({
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,
}
}

View File

@@ -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<string, string>, modelName: string, embeddingModelName: string): OllamaProvider {
let baseUrl = config?.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL
@@ -50,6 +50,18 @@ function createCustomOpenAIProvider(config: Record<string, string>, modelName: s
return new CustomOpenAIProvider(apiKey, baseUrl, modelName, embeddingModelName);
}
function createDeepSeekProvider(config: Record<string, string>, 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<string, string>, 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<string, string>, modelName: string, embeddingModelName: string): AIProvider {
switch (providerType) {
case 'ollama':
@@ -58,6 +70,10 @@ function getProviderInstance(providerType: ProviderType, config: Record<string,
return createOpenAIProvider(config, modelName, embeddingModelName);
case 'custom':
return createCustomOpenAIProvider(config, modelName, embeddingModelName);
case 'deepseek':
return createDeepSeekProvider(config, modelName, embeddingModelName);
case 'openrouter':
return createOpenRouterProvider(config, modelName, embeddingModelName);
default:
return createOllamaProvider(config, modelName, embeddingModelName);
}

View File

@@ -8,7 +8,7 @@
import { prisma } from '@/lib/prisma'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider, getAIProvider } from '@/lib/ai/factory'
import { getChatProvider } from '@/lib/ai/factory'
import { rssService } from './rss.service'
import { toolRegistry } from '../tools'
import { sendEmail } from '@/lib/mail'
@@ -56,7 +56,7 @@ const TITLE_PROMPTS: Record<Lang, string> = {
async function generateTitle(content: string, agentName: string, lang: Lang): Promise<string> {
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<AgentExecutionResult> {
const sysConfig = await getSystemConfig()
const provider = getAIProvider(sysConfig)
const provider = getChatProvider(sysConfig)
let inputContent = ''
const urls: string[] = agent.sourceUrls ? JSON.parse(agent.sourceUrls) : []

View File

@@ -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<string> {
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
const provider = getChatProvider(config)
const note1Desc = note1Title || 'Untitled note'
const note2Desc = note2Title || 'Untitled note'

View File

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

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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<ToolCallResult>;
}
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';

View File

@@ -1,5 +1,4 @@
import prisma from './prisma'
import { unstable_cache } from 'next/cache'
export async function getSystemConfig() {
try {

View File

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

View File

@@ -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;