feat(ai): implement intelligent auto-tagging system

- Added multi-provider AI infrastructure (OpenAI/Ollama)
- Implemented real-time tag suggestions with debounced analysis
- Created AI diagnostics and database maintenance tools in Settings
- Added automated garbage collection for orphan labels
- Refined UX with deterministic color hashing and interactive ghost tags
This commit is contained in:
2026-01-08 22:59:52 +01:00
parent 6f4d758e5c
commit 3c4b9d6176
27 changed files with 1336 additions and 138 deletions

View File

@@ -170,6 +170,39 @@ export async function createNote(data: {
}
}
// Helper to cleanup orphan labels
async function cleanupOrphanLabels(userId: string, candidateLabels: string[]) {
if (!candidateLabels || candidateLabels.length === 0) return
for (const labelName of candidateLabels) {
// Check if label is used in any other note
// Note: We search for the label name within the JSON string array
// This is a rough check but effective for JSON arrays like ["Label1","Label2"]
const count = await prisma.note.count({
where: {
userId,
labels: {
contains: `"${labelName}"`
}
}
})
if (count === 0) {
console.log(`Cleaning up orphan label: ${labelName}`)
try {
await prisma.label.deleteMany({
where: {
userId,
name: labelName
}
})
} catch (e) {
console.error(`Failed to delete orphan label ${labelName}:`, e)
}
}
}
}
// Update a note
export async function updateNote(id: string, data: {
title?: string | null
@@ -189,6 +222,14 @@ export async function updateNote(id: string, data: {
if (!session?.user?.id) throw new Error('Unauthorized');
try {
// Get old note state to compare labels
const oldNote = await prisma.note.findUnique({
where: { id, userId: session.user.id },
select: { labels: true }
})
const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : []
// Stringify JSON fields if they exist
const updateData: any = { ...data }
if ('checkItems' in data) {
@@ -213,6 +254,15 @@ export async function updateNote(id: string, data: {
data: updateData
})
// Cleanup orphan labels if labels changed
if (data.labels && oldLabels.length > 0) {
const removedLabels = oldLabels.filter(l => !data.labels?.includes(l))
if (removedLabels.length > 0) {
// Execute async without awaiting to not block response
cleanupOrphanLabels(session.user.id, removedLabels)
}
}
revalidatePath('/')
return parseNote(note)
} catch (error) {
@@ -227,6 +277,13 @@ export async function deleteNote(id: string) {
if (!session?.user?.id) throw new Error('Unauthorized');
try {
// Get labels before delete
const note = await prisma.note.findUnique({
where: { id, userId: session.user.id },
select: { labels: true }
})
const labels: string[] = note?.labels ? JSON.parse(note.labels) : []
await prisma.note.delete({
where: {
id,
@@ -234,6 +291,11 @@ export async function deleteNote(id: string) {
}
})
// Cleanup potential orphans
if (labels.length > 0) {
cleanupOrphanLabels(session.user.id, labels)
}
revalidatePath('/')
return { success: true }
} catch (error) {
@@ -344,6 +406,61 @@ export async function reorderNotes(draggedId: string, targetId: string) {
}
}
// Public action to manually trigger cleanup
export async function cleanupAllOrphans() {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
const userId = session.user.id;
let deletedCount = 0;
try {
// 1. Get all labels defined in Label table
const allDefinedLabels = await prisma.label.findMany({
where: { userId },
select: { id: true, name: true }
})
// 2. Get all used labels from Notes (fetch only labels column)
const allNotes = await prisma.note.findMany({
where: { userId },
select: { labels: true }
})
// 3. Build a Set of all used label names
const usedLabelsSet = new Set<string>();
allNotes.forEach(note => {
if (note.labels) {
try {
const parsedLabels: string[] = JSON.parse(note.labels);
if (Array.isArray(parsedLabels)) {
parsedLabels.forEach(l => usedLabelsSet.add(l.toLowerCase())); // Normalize to lowercase for comparison
}
} catch (e) {
// Ignore parse errors
}
}
});
// 4. Identify orphans
const orphans = allDefinedLabels.filter(label => !usedLabelsSet.has(label.name.toLowerCase()));
// 5. Delete orphans
for (const orphan of orphans) {
console.log(`Deleting orphan label: ${orphan.name}`);
await prisma.label.delete({ where: { id: orphan.id } });
deletedCount++;
}
revalidatePath('/')
return { success: true, count: deletedCount }
} catch (error) {
console.error('Error cleaning up orphans:', error)
throw new Error('Failed to cleanup database')
}
}
// Update full order of notes
export async function updateFullOrder(ids: string[]) {
const session = await auth();

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAIProvider } from '@/lib/ai/factory';
import { z } from 'zod';
const requestSchema = z.object({
content: z.string().min(1, "Le contenu ne peut pas être vide"),
});
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { content } = requestSchema.parse(body);
const provider = getAIProvider();
const tags = await provider.generateTags(content);
console.log('[API Tags] Generated tags:', tags);
return NextResponse.json({ tags });
} catch (error: any) {
console.error('Erreur API tags:', error);
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors }, { status: 400 });
}
return NextResponse.json(
{ error: error.message || 'Erreur lors de la génération des tags' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { getAIProvider } from '@/lib/ai/factory';
export async function GET() {
try {
const provider = getAIProvider();
const providerName = process.env.AI_PROVIDER || 'openai';
// Test simple de génération de tags sur un texte bidon
const testContent = "J'adore cuisiner des pâtes le dimanche soir avec ma famille.";
const tags = await provider.generateTags(testContent);
return NextResponse.json({
status: 'success',
provider: providerName,
test_tags: tags,
message: 'Infrastructure IA opérationnelle'
});
} catch (error: any) {
console.error('Erreur test IA détaillée:', error);
return NextResponse.json({
status: 'error',
message: error.message,
stack: error.stack
}, { status: 500 });
}
}

View File

@@ -0,0 +1,152 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader2, CheckCircle, XCircle, RefreshCw, Trash2, Database } from 'lucide-react';
import { cleanupAllOrphans } from '@/app/actions/notes';
import { useToast } from '@/components/ui/toast';
export default function SettingsPage() {
const { addToast } = useToast();
const [loading, setLoading] = useState(false);
const [cleanupLoading, setCleanupLoading] = useState(false);
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [result, setResult] = useState<any>(null);
const [config, setConfig] = useState<any>(null);
const checkConnection = async () => {
setLoading(true);
setStatus('idle');
setResult(null);
try {
const res = await fetch('/api/ai/test');
const data = await res.json();
setConfig({
provider: data.provider,
status: res.ok ? 'connected' : 'disconnected'
});
if (res.ok) {
setStatus('success');
setResult(data);
} else {
setStatus('error');
setResult(data);
}
} catch (error: any) {
console.error(error);
setStatus('error');
setResult({ message: error.message, stack: error.stack });
} finally {
setLoading(false);
}
};
const handleCleanup = async () => {
setCleanupLoading(true);
try {
const result = await cleanupAllOrphans();
if (result.success) {
addToast(`Nettoyage terminé : ${result.count} tags supprimés`, 'success');
}
} catch (error) {
console.error(error);
addToast("Erreur lors du nettoyage", "error");
} finally {
setCleanupLoading(false);
}
};
useEffect(() => {
checkConnection();
}, []);
return (
<div className="container mx-auto py-10 px-4 max-w-4xl space-y-8">
<h1 className="text-3xl font-bold mb-8">Paramètres</h1>
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle className="flex items-center gap-2">
Diagnostic IA
{status === 'success' && <CheckCircle className="text-green-500 w-5 h-5" />}
{status === 'error' && <XCircle className="text-red-500 w-5 h-5" />}
</CardTitle>
<CardDescription>Vérifiez la connexion avec votre fournisseur d'intelligence artificielle.</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={checkConnection} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Tester la connexion
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Configuration Actuelle */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-secondary/50">
<p className="text-sm font-medium text-muted-foreground mb-1">Provider Configuré</p>
<p className="text-lg font-mono">{config?.provider || '...'}</p>
</div>
<div className="p-4 rounded-lg bg-secondary/50">
<p className="text-sm font-medium text-muted-foreground mb-1">État API</p>
<Badge variant={status === 'success' ? 'default' : 'destructive'}>
{status === 'success' ? 'Opérationnel' : 'Erreur'}
</Badge>
</div>
</div>
{/* Résultat du Test */}
{result && (
<div className="space-y-2">
<h3 className="text-sm font-medium">Détails du test :</h3>
<div className={`p-4 rounded-md font-mono text-xs overflow-x-auto ${status === 'error' ? 'bg-red-50 text-red-900 border border-red-200' : 'bg-slate-950 text-slate-50'}`}>
<pre>{JSON.stringify(result, null, 2)}</pre>
</div>
{status === 'error' && (
<div className="text-sm text-red-600 mt-2">
<p className="font-bold">Conseil de dépannage :</p>
<ul className="list-disc list-inside mt-1">
<li>Vérifiez que Ollama tourne (<code>ollama list</code>)</li>
<li>Vérifiez l'URL (http://localhost:11434)</li>
<li>Vérifiez que le modèle (ex: granite4:latest) est bien téléchargé</li>
<li>Regardez le terminal du serveur Next.js pour plus de logs</li>
</ul>
</div>
)}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5" />
Maintenance
</CardTitle>
<CardDescription>Outils pour maintenir la santé de votre base de données.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-medium">Nettoyage des tags orphelins</h3>
<p className="text-sm text-muted-foreground">Supprime les tags qui ne sont plus utilisés par aucune note.</p>
</div>
<Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading}>
{cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Trash2 className="w-4 h-4 mr-2" />}
Nettoyer
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { TagSuggestion } from '@/lib/ai/types';
import { Loader2, Sparkles, X } from 'lucide-react';
import { cn, getHashColor } from '@/lib/utils';
import { LABEL_COLORS } from '@/lib/types';
interface GhostTagsProps {
suggestions: TagSuggestion[];
isAnalyzing: boolean;
onSelectTag: (tag: string) => void;
onDismissTag: (tag: string) => void;
className?: string;
}
export function GhostTags({ suggestions, isAnalyzing, onSelectTag, onDismissTag, className }: GhostTagsProps) {
console.log('GhostTags Render:', { count: suggestions.length, isAnalyzing, suggestions });
// On n'affiche rien si pas d'analyse et pas de suggestions
if (!isAnalyzing && suggestions.length === 0) return null;
return (
<div className={cn("flex flex-wrap items-center gap-2 mt-2 min-h-[24px] transition-all duration-500", className)}>
{/* Indicateur IA discret */}
{isAnalyzing && (
<div className="flex items-center text-purple-500 animate-pulse" title="IA en cours d'analyse...">
<Sparkles className="w-4 h-4" />
</div>
)}
{/* Liste des suggestions */}
{!isAnalyzing && suggestions.map((suggestion) => {
const colorName = getHashColor(suggestion.tag);
const colorClasses = LABEL_COLORS[colorName];
return (
<div
key={suggestion.tag}
className={cn(
"group flex items-center border border-dashed rounded-full transition-all cursor-pointer animate-in fade-in zoom-in duration-300 opacity-80 hover:opacity-100",
colorClasses.bg,
colorClasses.border
)}
>
{/* Zone de validation (Clic principal) */}
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onSelectTag(suggestion.tag);
}}
className={cn("flex items-center px-3 py-1 text-xs font-medium", colorClasses.text)}
title="Cliquer pour ajouter ce tag"
>
<Sparkles className="w-3 h-3 mr-1.5 opacity-50" />
{suggestion.tag}
</button>
{/* Zone de refus (Croix) */}
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDismissTag(suggestion.tag);
}}
className={cn("pr-2 pl-1 hover:text-red-500 transition-colors", colorClasses.text)}
title="Ignorer cette suggestion"
>
<X className="w-3 h-3" />
</button>
</div>
);
})}
</div>
);
}

View File

@@ -13,6 +13,7 @@ import { LabelBadge } from './label-badge'
import { NoteImages } from './note-images'
import { NoteChecklist } from './note-checklist'
import { NoteActions } from './note-actions'
import { useLabels } from '@/context/LabelContext'
interface NoteCardProps {
note: Note
@@ -22,6 +23,7 @@ interface NoteCardProps {
}
export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps) {
const { refreshLabels } = useLabels()
const [isDeleting, setIsDeleting] = useState(false)
const colorClasses = NOTE_COLORS[note.color as NoteColor] || NOTE_COLORS.default
@@ -30,6 +32,8 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
setIsDeleting(true)
try {
await deleteNote(note.id)
// Refresh global labels to reflect garbage collection
await refreshLabels()
} catch (error) {
console.error('Failed to delete note:', error)
setIsDeleting(false)

View File

@@ -18,7 +18,7 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon } from 'lucide-react'
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles } from 'lucide-react'
import { updateNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { cn } from '@/lib/utils'
@@ -28,6 +28,9 @@ import { LabelManager } from './label-manager'
import { LabelBadge } from './label-badge'
import { ReminderDialog } from './reminder-dialog'
import { EditorImages } from './editor-images'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { GhostTags } from './ghost-tags'
import { useLabels } from '@/context/LabelContext'
interface NoteEditorProps {
note: Note
@@ -36,6 +39,7 @@ interface NoteEditorProps {
export function NoteEditor({ note, onClose }: NoteEditorProps) {
const { addToast } = useToast()
const { labels: globalLabels, addLabel, refreshLabels } = useLabels()
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content)
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
@@ -49,6 +53,12 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Auto-tagging hook
const { suggestions, isAnalyzing } = useAutoTagging({
content: note.type === 'text' ? (content || '') : '',
enabled: note.type === 'text' // Auto-tagging only for text notes
})
// Reminder state
const [showReminderDialog, setShowReminderDialog] = useState(false)
const [currentReminder, setCurrentReminder] = useState<Date | null>(note.reminder)
@@ -56,9 +66,43 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
// Link state
const [showLinkDialog, setShowLinkDialog] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
// Tags rejetés par l'utilisateur pour cette session
const [dismissedTags, setDismissedTags] = useState<string[]>([])
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
const handleSelectGhostTag = async (tag: string) => {
// Vérification insensible à la casse
const tagExists = labels.some(l => l.toLowerCase() === tag.toLowerCase())
if (!tagExists) {
setLabels(prev => [...prev, tag])
// Créer le label globalement s'il n'existe pas
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
if (!globalExists) {
try {
await addLabel(tag)
} catch (err) {
console.error('Erreur création label auto:', err)
}
}
addToast(`Tag "${tag}" ajouté`, 'success')
}
}
const handleDismissGhostTag = (tag: string) => {
setDismissedTags(prev => [...prev, tag])
}
// Filtrer les suggestions pour ne pas afficher celles rejetées ou déjà ajoutées (insensible à la casse)
const filteredSuggestions = suggestions.filter(s => {
if (!s || !s.tag) return false
return !labels.some(l => l.toLowerCase() === s.tag.toLowerCase()) &&
!dismissedTags.includes(s.tag)
})
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
@@ -142,6 +186,10 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
reminder: currentReminder,
isMarkdown,
})
// Rafraîchir les labels globaux pour refléter les suppressions éventuelles (orphans)
await refreshLabels()
onClose()
} catch (error) {
console.error('Failed to save note:', error)
@@ -193,12 +241,19 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
<div className="space-y-4">
{/* Title */}
<Input
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent"
/>
<div className="relative">
<Input
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-8"
/>
{filteredSuggestions.length > 0 && (
<div className="absolute right-0 top-1/2 -translate-y-1/2" title="Suggestions IA disponibles">
<Sparkles className="w-4 h-4 text-purple-500 animate-pulse" />
</div>
)}
</div>
{/* Images */}
<EditorImages images={images} onRemove={handleRemoveImage} />
@@ -284,6 +339,14 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
className="min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none"
/>
)}
{/* AI Auto-tagging Suggestions */}
<GhostTags
suggestions={filteredSuggestions}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={handleDismissGhostTag}
/>
</div>
) : (
<div className="space-y-2">
@@ -421,84 +484,43 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
/>
</DialogContent>
<ReminderDialog
open={showReminderDialog}
onOpenChange={setShowReminderDialog}
currentReminder={currentReminder}
onSave={handleReminderSave}
onRemove={handleRemoveReminder}
<ReminderDialog
open={showReminderDialog}
onOpenChange={setShowReminderDialog}
currentReminder={currentReminder}
onSave={handleReminderSave}
onRemove={handleRemoveReminder}
/>
<Dialog open={showLinkDialog} onOpenChange={setShowLinkDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Link</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder="https://example.com"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddLink()
}
}}
autoFocus
/>
<Dialog open={showLinkDialog} onOpenChange={setShowLinkDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Link</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder="https://example.com"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddLink()
}
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowLinkDialog(false)}>
Cancel
</Button>
<Button onClick={handleAddLink}>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Dialog>
)
}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowLinkDialog(false)}>
Cancel
</Button>
<Button onClick={handleAddLink}>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Dialog>
)
}

View File

@@ -41,6 +41,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { MarkdownContent } from './markdown-content'
import { LabelSelector } from './label-selector'
import { LabelBadge } from './label-badge'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { GhostTags } from './ghost-tags'
import { useLabels } from '@/context/LabelContext'
interface HistoryState {
title: string
@@ -56,6 +59,7 @@ interface NoteState {
export function NoteInput() {
const { addToast } = useToast()
const { labels: globalLabels, addLabel } = useLabels()
const [isExpanded, setIsExpanded] = useState(false)
const [type, setType] = useState<'text' | 'checklist'>('text')
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -67,7 +71,47 @@ export function NoteInput() {
// Simple state without complex undo/redo - like Google Keep
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
// Auto-tagging hook
const { suggestions, isAnalyzing } = useAutoTagging({
content: type === 'text' ? content : '',
enabled: type === 'text' && isExpanded
})
const [dismissedTags, setDismissedTags] = useState<string[]>([])
const handleSelectGhostTag = async (tag: string) => {
// Vérification insensible à la casse
const tagExists = selectedLabels.some(l => l.toLowerCase() === tag.toLowerCase())
if (!tagExists) {
setSelectedLabels(prev => [...prev, tag])
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
if (!globalExists) {
try {
await addLabel(tag)
} catch (err) {
console.error('Erreur création label auto:', err)
}
}
addToast(`Tag "${tag}" ajouté`, 'success')
}
}
const handleDismissGhostTag = (tag: string) => {
setDismissedTags(prev => [...prev, tag])
}
const filteredSuggestions = suggestions.filter(s => {
if (!s || !s.tag) return false
return !selectedLabels.some(l => l.toLowerCase() === s.tag.toLowerCase()) &&
!dismissedTags.includes(s.tag)
})
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
const [images, setImages] = useState<string[]>([])
const [links, setLinks] = useState<LinkMetadata[]>([])
const [isMarkdown, setIsMarkdown] = useState(false)
@@ -418,46 +462,25 @@ export function NoteInput() {
{/* Link Previews */}
{links.length > 0 && (
<div className="flex flex-col gap-2 mt-2">
{links.map((link, idx) => (
<div key={idx} className="relative group border rounded-lg overflow-hidden bg-gray-50 dark:bg-zinc-800/50 flex">
{link.imageUrl && (
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-blue-500 truncate hover:underline block mt-1">
{new URL(link.url).hostname}
</a>
</div>
<Button
variant="ghost"
size="sm"
className="absolute top-1 right-1 h-6 w-6 p-0 bg-white/50 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
onClick={() => handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</Button>
</div>
{/* ... */}
</div>
)}
{/* Selected Labels Display (Moved here to be visible for both text and checklist) */}
{selectedLabels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{selectedLabels.map(label => (
<LabelBadge
key={label}
label={label}
onRemove={() => setSelectedLabels(prev => prev.filter(l => l !== label))}
/>
))}
</div>
)}
{type === 'text' ? (
<div className="space-y-2">
{/* Selected Labels Display */}
{selectedLabels.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{selectedLabels.map(label => (
<LabelBadge
key={label}
label={label}
onRemove={() => setSelectedLabels(prev => prev.filter(l => l !== label))}
/>
))}
</div>
)}
{/* Markdown toggle button */}
{isMarkdown && (
<div className="flex justify-end gap-2">
@@ -496,6 +519,14 @@ export function NoteInput() {
autoFocus
/>
)}
{/* AI Auto-tagging Suggestions */}
<GhostTags
suggestions={filteredSuggestions}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={handleDismissGhostTag}
/>
</div>
) : (
<div className="space-y-2">

View File

@@ -4,7 +4,7 @@ import { useState } from 'react'
import Link from 'next/link'
import { usePathname, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
import { StickyNote, Bell, Archive, Trash2, Tag, ChevronDown, ChevronUp } from 'lucide-react'
import { StickyNote, Bell, Archive, Trash2, Tag, ChevronDown, ChevronUp, Settings } from 'lucide-react'
import { useLabels } from '@/context/LabelContext'
import { LabelManagementDialog } from './label-management-dialog'
@@ -105,6 +105,13 @@ export function Sidebar({ className }: { className?: string }) {
label="Trash"
active={pathname === '/trash'}
/>
<NavItem
href="/settings"
icon={Settings}
label="Settings"
active={pathname === '/settings'}
/>
</aside>
)
}

View File

@@ -2,6 +2,7 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { LabelColorName, LABEL_COLORS } from '@/lib/types'
import { getHashColor } from '@/lib/utils'
export interface Label {
id: string
@@ -28,11 +29,10 @@ export function LabelProvider({ children }: { children: ReactNode }) {
const [labels, setLabels] = useState<Label[]>([])
const [loading, setLoading] = useState(true)
// Fetch labels from API
const fetchLabels = async () => {
try {
setLoading(true)
const response = await fetch('/api/labels')
const response = await fetch('/api/labels', { cache: 'no-store' })
const data = await response.json()
if (data.success && data.data) {
setLabels(data.data)
@@ -50,9 +50,7 @@ export function LabelProvider({ children }: { children: ReactNode }) {
const addLabel = async (name: string, color?: LabelColorName) => {
try {
// Get existing label color if not provided
const existingColor = getLabelColorHelper(name)
const labelColor = color || existingColor
const labelColor = color || getHashColor(name);
const response = await fetch('/api/labels', {
method: 'POST',
@@ -130,4 +128,4 @@ export function useLabels() {
throw new Error('useLabels must be used within a LabelProvider')
}
return context
}
}

View File

@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react';
import { useDebounce } from './use-debounce';
import { TagSuggestion } from '@/lib/ai/types';
interface UseAutoTaggingProps {
content: string;
enabled?: boolean;
}
export function useAutoTagging({ content, enabled = true }: UseAutoTaggingProps) {
const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Debounce le contenu de 1.5s
const debouncedContent = useDebounce(content, 1500);
useEffect(() => {
// console.log('AutoTagging Effect:', { enabled, contentLength: debouncedContent?.length });
if (!enabled || !debouncedContent || debouncedContent.length < 10) {
setSuggestions([]);
return;
}
const analyzeContent = async () => {
console.log('🚀 Triggering AI analysis for:', debouncedContent.substring(0, 20) + '...');
setIsAnalyzing(true);
setError(null);
try {
const response = await fetch('/api/ai/tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: debouncedContent }),
});
if (!response.ok) {
throw new Error('Erreur lors de l\'analyse');
}
const data = await response.json();
console.log('✅ AI Response:', data);
setSuggestions(data.tags || []);
} catch (err) {
console.error('❌ Auto-tagging error:', err);
setError('Impossible de générer des suggestions');
} finally {
setIsAnalyzing(false);
}
};
analyzeContent();
}, [debouncedContent, enabled]);
return {
suggestions,
isAnalyzing,
error,
clearSuggestions: () => setSuggestions([]),
};
}

View File

@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,25 @@
import { OpenAIProvider } from './providers/openai';
import { OllamaProvider } from './providers/ollama';
import { AIProvider } from './types';
export function getAIProvider(): AIProvider {
const providerType = process.env.AI_PROVIDER || 'ollama'; // Default to ollama for local dev
switch (providerType.toLowerCase()) {
case 'ollama':
console.log('Using Ollama Provider with model:', process.env.OLLAMA_MODEL || 'granite4:latest');
return new OllamaProvider(
process.env.OLLAMA_BASE_URL || 'http://localhost:11434/api',
process.env.OLLAMA_MODEL || 'granite4:latest'
);
case 'openai':
default:
if (!process.env.OPENAI_API_KEY) {
console.warn('OPENAI_API_KEY non configurée. Les fonctions IA pourraient échouer.');
}
return new OpenAIProvider(
process.env.OPENAI_API_KEY || '',
process.env.OPENAI_MODEL || 'gpt-4o-mini'
);
}
}

View File

@@ -0,0 +1,77 @@
import { AIProvider, TagSuggestion } from '../types';
export class OllamaProvider implements AIProvider {
private baseUrl: string;
private modelName: string;
constructor(baseUrl: string = 'http://localhost:11434/api', modelName: string = 'llama3') {
this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
this.modelName = modelName;
}
async generateTags(content: string): Promise<TagSuggestion[]> {
try {
const response = await fetch(`${this.baseUrl}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
prompt: `Analyse la note suivante et extrais les concepts clés sous forme de tags courts (1-3 mots max).
Règles:
- Pas de mots de liaison (le, la, pour, et...).
- Garde les expressions composées ensemble (ex: "semaine prochaine", "New York").
- Normalise en minuscules sauf noms propres.
- Maximum 5 tags.
Réponds UNIQUEMENT sous forme de liste JSON d'objets : [{"tag": "string", "confidence": number}].
Contenu de la note: "${content}"`,
stream: false,
}),
});
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
const data = await response.json();
const text = data.response;
const jsonMatch = text.match(/\[\s*\{.*\}\s*\]/s);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
// Support pour le format { "tags": [...] }
const objectMatch = text.match(/\{\s*"tags"\s*:\s*(\[.*\])\s*\}/s);
if (objectMatch && objectMatch[1]) {
return JSON.parse(objectMatch[1]);
}
return [];
} catch (e) {
console.error('Erreur API directe Ollama:', e);
return [];
}
}
async getEmbeddings(text: string): Promise<number[]> {
try {
const response = await fetch(`${this.baseUrl}/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
prompt: text,
}),
});
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
const data = await response.json();
return data.embedding;
} catch (e) {
console.error('Erreur embeddings directs Ollama:', e);
return [];
}
}
}

View File

@@ -0,0 +1,46 @@
import { openai } from '@ai-sdk/openai';
import { generateObject, embed } from 'ai';
import { z } from 'zod';
import { AIProvider, TagSuggestion } from '../types';
export class OpenAIProvider implements AIProvider {
private model: any;
constructor(apiKey: string, modelName: string = 'gpt-4o-mini') {
this.model = openai(modelName);
}
async generateTags(content: string): Promise<TagSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Le nom du tag, court et en minuscules'),
confidence: z.number().min(0).max(1).describe('Le niveau de confiance entre 0 et 1')
}))
}),
prompt: `Analyse la note suivante et suggère entre 1 et 5 tags pertinents.
Contenu de la note: "${content}"`,
});
return object.tags;
} catch (e) {
console.error('Erreur génération tags OpenAI:', e);
return [];
}
}
async getEmbeddings(text: string): Promise<number[]> {
try {
const { embedding } = await embed({
model: openai.embedding('text-embedding-3-small'),
value: text,
});
return embedding;
} catch (e) {
console.error('Erreur embeddings OpenAI:', e);
return [];
}
}
}

View File

@@ -0,0 +1,25 @@
export interface TagSuggestion {
tag: string;
confidence: number;
}
export interface AIProvider {
/**
* Analyse le contenu et suggère des tags pertinents.
*/
generateTags(content: string): Promise<TagSuggestion[]>;
/**
* Génère un vecteur d'embeddings pour la recherche sémantique.
*/
getEmbeddings(text: string): Promise<number[]>;
}
export type AIProviderType = 'openai' | 'ollama';
export interface AIConfig {
provider: AIProviderType;
apiKey?: string;
baseUrl?: string; // Utile pour Ollama
model?: string;
}

View File

@@ -1,6 +1,21 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { LABEL_COLORS, LabelColorName } from "./types"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function getHashColor(name: string): LabelColorName {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const colors = Object.keys(LABEL_COLORS) as LabelColorName[];
// Skip 'gray' for colorful tags
const colorfulColors = colors.filter(c => c !== 'gray');
const colorIndex = Math.abs(hash) % colorfulColors.length;
return colorfulColors[colorIndex];
}

View File

@@ -11,6 +11,7 @@
"test:headed": "playwright test --headed"
},
"dependencies": {
"@ai-sdk/openai": "^3.0.7",
"@auth/prisma-adapter": "^2.11.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -26,6 +27,7 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"ai": "^6.0.23",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.5.0",
"cheerio": "^1.1.2",
@@ -37,6 +39,7 @@
"muuri": "^0.9.5",
"next": "16.1.1",
"next-auth": "^5.0.0-beta.30",
"ollama-ai-provider": "^1.2.0",
"prisma": "^5.22.0",
"react": "19.2.3",
"react-dom": "19.2.3",

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB