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:
@@ -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)
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) : []
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import prisma from './prisma'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
|
||||
export async function getSystemConfig() {
|
||||
try {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user