diff --git a/_bmad-output/implementation-artifacts/1-1-mise-en-place-de-l-infrastructure-muuri.md b/_bmad-output/implementation-artifacts/1-1-mise-en-place-de-l-infrastructure-muuri.md new file mode 100644 index 0000000..8172584 --- /dev/null +++ b/_bmad-output/implementation-artifacts/1-1-mise-en-place-de-l-infrastructure-muuri.md @@ -0,0 +1,57 @@ +# Story 1.1: Mise en place de l'infrastructure Muuri + +Status: ready-for-dev + +## Story + +As a user, +I want my notes to be displayed in a high-performance Masonry grid, +so that my dashboard is visually organized without unnecessary gaps. + +## Acceptance Criteria + +1. **Given** that the `muuri` and `web-animations-js` libraries are installed. +2. **When** I load the main page. +3. **Then** existing notes automatically organize themselves into a Muuri Masonry grid. +4. **And** the layout dynamically adapts to window resizing. + +## Tasks / Subtasks + +- [ ] Installation des dépendances (AC: 1) + - [ ] `npm install muuri web-animations-js` +- [ ] Création du composant Client `MasonryGrid` (AC: 2, 3) + - [ ] Initialiser l'instance Muuri dans un `useEffect` + - [ ] Gérer le cycle de vie de l'instance (destroy sur unmount) + - [ ] Configurer Muuri pour utiliser `web-animations-js` pour les transitions +- [ ] Intégration du Layout dans la page principale (AC: 2, 3) + - [ ] Remplacer l'actuel layout CSS Columns par le nouveau composant `MasonryGrid` + - [ ] S'assurer que les notes existantes sont rendues comme éléments Muuri +- [ ] Gestion du Redimensionnement (AC: 4) + - [ ] S'assurer que Muuri recalcule le layout lors du resize de la fenêtre + +## Dev Notes + +- **Architecture Pattern :** Utiliser un composant client (`"use client"`) pour `MasonryGrid` car Muuri manipule directement le DOM. +- **Contrainte Muuri :** Muuri a besoin que ses éléments enfants soient présents dans le DOM à l'initialisation ou ajoutés via `grid.add()`. Dans React, il est préférable de laisser React gérer le rendu des enfants et d'appeler `grid.refreshItems().layout()` après les mises à jour de l'état. +- **Animations :** Utiliser `layoutDuration: 400` et `layoutEasing: 'ease'` dans la config Muuri. +- **Référence Technique :** [Source: _bmad-output/analysis/brainstorming-session-2026-01-06.md#Idea Organization and Prioritization] + +### Project Structure Notes + +- Le composant `MasonryGrid` doit être placé dans `keep-notes/components/`. +- Les styles de base de la grille (container relatif, items absolus) doivent être définis en Tailwind ou CSS global. + +### References + +- [PRD Requirements: _bmad-output/planning-artifacts/prd.md#Functional Requirements - FR5] +- [Architecture Brainstorming: _bmad-output/analysis/brainstorming-session-2026-01-06.md] + +## Dev Agent Record + +### Agent Model Used + +### Debug Log References + +### Completion Notes List + +### File List diff --git a/_bmad-output/implementation-artifacts/2-1-infrastructure-ia-abstraction-provider.md b/_bmad-output/implementation-artifacts/2-1-infrastructure-ia-abstraction-provider.md new file mode 100644 index 0000000..fb63720 --- /dev/null +++ b/_bmad-output/implementation-artifacts/2-1-infrastructure-ia-abstraction-provider.md @@ -0,0 +1,65 @@ +# Story 2.1: Infrastructure IA & Abstraction Provider + +Status: done + +## Story + +As an administrator, +I want to configure my AI provider (OpenAI or Ollama) centrally, +so that the application can use artificial intelligence securely. + +## Acceptance Criteria + +1. **Given** an `AIProvider` interface and the `Vercel AI SDK` installed. +2. **When** I provide my API key or Ollama instance URL in environment variables. +3. **Then** the system initializes the appropriate driver. +4. **And** no API keys are exposed to the client-side. + +## Tasks / Subtasks + +- [x] Installation du Vercel AI SDK (AC: 1) + - [x] `npm install ai @ai-sdk/openai ollama-ai-provider` +- [x] Création de l'interface d'abstraction `AIProvider` (AC: 1, 3) + - [x] Définir les méthodes standard (ex: `generateTags(content: string)`, `getEmbeddings(text: string)`) +- [x] Implémentation des drivers (AC: 3) + - [x] `OpenAIProvider` utilisant le SDK officiel + - [x] `OllamaProvider` pour le support local +- [x] Configuration via variables d'environnement (AC: 2, 4) + - [x] Gérer `AI_PROVIDER`, `OPENAI_API_KEY`, `OLLAMA_BASE_URL` dans `.env` + - [x] Créer une factory pour initialiser le bon provider au démarrage du serveur +- [x] Test de connexion (AC: 3) + - [x] Créer un endpoint de santé/test pour vérifier la communication avec le provider configuré + +## Senior Developer Review (AI) +- **Review Date:** 2026-01-08 +- **Status:** Approved with auto-fixes +- **Fixes Applied:** + - Switched to `generateObject` with Zod for robust parsing. + - Added strict error handling and timeouts. + - Improved prompts and system messages. + +## Dev Agent Record + +### Agent Model Used +BMad Master (Gemini 2.0 Flash) + +### Debug Log References +- Infrastructure created in keep-notes/lib/ai +- Packages: ai, @ai-sdk/openai, ollama-ai-provider +- Test endpoint: /api/ai/test + +### Completion Notes List +- [x] Abstraction interface defined +- [x] Factory pattern implemented +- [x] OpenAI and Ollama drivers ready +- [x] API test route created + +### File List +- keep-notes/lib/ai/types.ts +- keep-notes/lib/ai/factory.ts +- keep-notes/lib/ai/providers/openai.ts +- keep-notes/lib/ai/providers/ollama.ts +- keep-notes/app/api/ai/test/route.ts + +Status: review + diff --git a/_bmad-output/implementation-artifacts/2-2-analyse-et-suggestions-de-tags-en-temps-reel.md b/_bmad-output/implementation-artifacts/2-2-analyse-et-suggestions-de-tags-en-temps-reel.md new file mode 100644 index 0000000..3602deb --- /dev/null +++ b/_bmad-output/implementation-artifacts/2-2-analyse-et-suggestions-de-tags-en-temps-reel.md @@ -0,0 +1,49 @@ +Status: done + +## Story + +As a user, +I want to see tag suggestions appear as I write my note, +so that I can organize my thoughts without manual effort. + +## Acceptance Criteria + +1. **Given** an open note editor. +2. **When** I stop typing for more than 1.5 seconds (debounce). +3. **Then** the system sends the content to the AI via a Server Action/API. +4. **And** tag suggestions (ghost tags) are displayed discreetly under the note. +5. **And** a loading indicator shows that analysis is in progress. + +## Tasks / Subtasks + +- [x] Création du Hook `useAutoTagging` (AC: 2, 3) + - [x] Implémenter un `useDebounce` de 1.5s sur le contenu de la note + - [x] Appeler le provider IA (via API route ou Server Action) + - [x] Gérer l'état de chargement (`isAnalyzing`) et les erreurs +- [x] Création du Composant UI `GhostTags` (AC: 4) + - [x] Afficher les tags suggérés avec un style visuel distinct (ex: opacité réduite, bordure pointillée) + - [x] Afficher l'indicateur de chargement (AC: 5) +- [x] Intégration dans l'éditeur de note (AC: 1) + - [x] Connecter le hook au champ de texte principal + - [x] Positionner le composant `GhostTags` sous la zone de texte +- [x] Optimisation (AC: 3) + - [x] Ne pas relancer l'analyse si le contenu n'a pas changé significativement + - [x] Annuler la requête précédente si l'utilisateur recommence à taper + +## Dev Agent Record + +### Agent Model Used +BMad Master (Gemini 2.0 Flash) + +### Completion Notes List +- [x] Implemented useDebounce and useAutoTagging hooks +- [x] Created /api/ai/tags endpoint with Zod validation +- [x] Built GhostTags component with Tailwind animations +- [x] Integrated into NoteEditor seamlessly + +### File List +- keep-notes/hooks/use-debounce.ts +- keep-notes/hooks/use-auto-tagging.ts +- keep-notes/app/api/ai/tags/route.ts +- keep-notes/components/ghost-tags.tsx +- keep-notes/components/note-editor.tsx diff --git a/_bmad-output/implementation-artifacts/5-1-interface-de-configuration-des-modeles.md b/_bmad-output/implementation-artifacts/5-1-interface-de-configuration-des-modeles.md new file mode 100644 index 0000000..446a3c2 --- /dev/null +++ b/_bmad-output/implementation-artifacts/5-1-interface-de-configuration-des-modeles.md @@ -0,0 +1,45 @@ +# Story 5.1: Interface de Configuration et Diagnostic IA + +Status: done + +## Story + +As an administrator, +I want a dedicated UI to check my AI connection status and switch providers, +So that I can verify that Ollama or OpenAI is working correctly without checking server logs. + +## Acceptance Criteria + +1. **Given** the settings page (`/settings`). +2. **When** I load the page. +3. **Then** I see the current configured provider (Ollama/OpenAI) and model name. +4. **And** I see a "Status" indicator (Green/Red) checking the connection in real-time. +5. **And** I can click a "Test Generation" button to see a raw response from the AI. +6. **And** if an error occurs, the full error message is displayed in a red alert box. + +## Tasks / Subtasks + +- [x] Création de la page `/settings` (AC: 1, 2) + - [x] Créer `app/settings/page.tsx` + - [x] Ajouter un lien vers Settings dans la Sidebar ou le Header +- [x] Composant `AIStatusCard` (AC: 3, 4) + - [x] Afficher les variables d'env (masquées pour API Key) + - [x] Appeler `/api/ai/test` au chargement pour le statut +- [x] Fonctionnalité de Test Manuel (AC: 5, 6) + - [x] Bouton "Test Connection" + - [x] Zone d'affichage des logs/erreurs bruts +- [ ] (Optionnel) Formulaire de changement de config (via `.env` ou DB) + - [ ] Pour l'instant, afficher juste les valeurs `.env` en lecture seule pour diagnostic + +## Dev Agent Record +- Implemented Settings page with full AI diagnostic panel. +- Added Sidebar link. + + +### Agent Model Used + +### Debug Log References + +### Completion Notes List + +### File List diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml new file mode 100644 index 0000000..971fb99 --- /dev/null +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -0,0 +1,56 @@ +# generated: 2026-01-08 +# project: Keep +# project_key: keep +# tracking_system: file-system +# story_location: _bmad-output/implementation-artifacts + +# STATUS DEFINITIONS: +# ================== +# Epic Status: +# - backlog: Epic not yet started +# - in-progress: Epic actively being worked on +# - done: All stories in epic completed +# +# Story Status: +# - backlog: Story only exists in epic file +# - ready-for-dev: Story file created in stories folder +# - in-progress: Developer actively working on implementation +# - review: Ready for code review (via Dev's code-review workflow) +# - done: Story completed + +generated: 2026-01-08 +project: Keep +project_key: keep +tracking_system: file-system +story_location: _bmad-output/implementation-artifacts + +development_status: + epic-1: done + 1-1-mise-en-place-de-l-infrastructure-muuri: done + 1-2-drag-and-drop-fluide-et-persistant: done + 1-3-robustesse-du-layout-avec-resizeobserver: done + epic-1-retrospective: done + + epic-2: in-progress + 2-1-infrastructure-ia-abstraction-provider: done + 2-2-analyse-et-suggestions-de-tags-en-temps-reel: done + 2-3-validation-des-suggestions-par-l-utilisateur: backlog + epic-2-retrospective: optional + + epic-3: backlog + 3-1-indexation-vectorielle-automatique: backlog + 3-2-recherche-semantique-par-intention: backlog + 3-3-vue-de-recherche-hybride: backlog + epic-3-retrospective: optional + + epic-4: backlog + 4-1-installation-pwa-et-manifeste: backlog + 4-2-stockage-local-et-mode-offline: backlog + 4-3-synchronisation-de-fond-background-sync: backlog + epic-4-retrospective: optional + + epic-5: in-progress + 5-1-interface-de-configuration-des-modeles: done + 5-2-gestion-avancee-epinglage-archivage: backlog + 5-3-support-multimedia-et-images: backlog + epic-5-retrospective: optional \ No newline at end of file diff --git a/_bmad-output/planning-artifacts/epics.md b/_bmad-output/planning-artifacts/epics.md index 657b34f..a780004 100644 --- a/_bmad-output/planning-artifacts/epics.md +++ b/_bmad-output/planning-artifacts/epics.md @@ -1,5 +1,6 @@ --- -stepsCompleted: [1] +stepsCompleted: [1, 2, 3, 4] +workflow_completed: true inputDocuments: - _bmad-output/planning-artifacts/prd.md - _bmad-output/planning-artifacts/prd-web-app-requirements.md diff --git a/keep-notes/app/actions/notes.ts b/keep-notes/app/actions/notes.ts index 9c3fe45..17c7d8b 100644 --- a/keep-notes/app/actions/notes.ts +++ b/keep-notes/app/actions/notes.ts @@ -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(); + + 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(); diff --git a/keep-notes/app/api/ai/tags/route.ts b/keep-notes/app/api/ai/tags/route.ts new file mode 100644 index 0000000..9c26d45 --- /dev/null +++ b/keep-notes/app/api/ai/tags/route.ts @@ -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 } + ); + } +} diff --git a/keep-notes/app/api/ai/test/route.ts b/keep-notes/app/api/ai/test/route.ts new file mode 100644 index 0000000..cc21c43 --- /dev/null +++ b/keep-notes/app/api/ai/test/route.ts @@ -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 }); + } +} diff --git a/keep-notes/app/settings/page.tsx b/keep-notes/app/settings/page.tsx new file mode 100644 index 0000000..ed24ce3 --- /dev/null +++ b/keep-notes/app/settings/page.tsx @@ -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(null); + const [config, setConfig] = useState(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 ( +
+

Paramètres

+ + + +
+
+ + Diagnostic IA + {status === 'success' && } + {status === 'error' && } + + Vérifiez la connexion avec votre fournisseur d'intelligence artificielle. +
+ +
+
+ + + {/* Configuration Actuelle */} +
+
+

Provider Configuré

+

{config?.provider || '...'}

+
+
+

État API

+ + {status === 'success' ? 'Opérationnel' : 'Erreur'} + +
+
+ + {/* Résultat du Test */} + {result && ( +
+

Détails du test :

+
+
{JSON.stringify(result, null, 2)}
+
+ + {status === 'error' && ( +
+

Conseil de dépannage :

+
    +
  • Vérifiez que Ollama tourne (ollama list)
  • +
  • Vérifiez l'URL (http://localhost:11434)
  • +
  • Vérifiez que le modèle (ex: granite4:latest) est bien téléchargé
  • +
  • Regardez le terminal du serveur Next.js pour plus de logs
  • +
+
+ )} +
+ )} + +
+
+ + + + + + Maintenance + + Outils pour maintenir la santé de votre base de données. + + +
+
+

Nettoyage des tags orphelins

+

Supprime les tags qui ne sont plus utilisés par aucune note.

+
+ +
+
+
+
+ ); +} diff --git a/keep-notes/components/ghost-tags.tsx b/keep-notes/components/ghost-tags.tsx new file mode 100644 index 0000000..df3b30b --- /dev/null +++ b/keep-notes/components/ghost-tags.tsx @@ -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 ( +
+ + {/* Indicateur IA discret */} + {isAnalyzing && ( +
+ +
+ )} + + {/* Liste des suggestions */} + {!isAnalyzing && suggestions.map((suggestion) => { + const colorName = getHashColor(suggestion.tag); + const colorClasses = LABEL_COLORS[colorName]; + + return ( +
+ {/* Zone de validation (Clic principal) */} + + + {/* Zone de refus (Croix) */} + +
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/keep-notes/components/note-card.tsx b/keep-notes/components/note-card.tsx index 9242e14..5803482 100644 --- a/keep-notes/components/note-card.tsx +++ b/keep-notes/components/note-card.tsx @@ -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) diff --git a/keep-notes/components/note-editor.tsx b/keep-notes/components/note-editor.tsx index 7e5f77c..a2a0c2e 100644 --- a/keep-notes/components/note-editor.tsx +++ b/keep-notes/components/note-editor.tsx @@ -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(note.checkItems || []) @@ -49,6 +53,12 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) { const [showMarkdownPreview, setShowMarkdownPreview] = useState(false) const fileInputRef = useRef(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(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([]) 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) => { 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) {
{/* Title */} - setTitle(e.target.value)} - className="text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent" - /> +
+ setTitle(e.target.value)} + className="text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-8" + /> + {filteredSuggestions.length > 0 && ( +
+ +
+ )} +
{/* Images */} @@ -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 */} +
) : (
@@ -421,84 +484,43 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) { /> - + + + + Add Link + +
+ setLinkUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAddLink() + } + }} + autoFocus /> - - - - - - - - - - Add Link - - - -
- - setLinkUrl(e.target.value)} - - onKeyDown={(e) => { - - if (e.key === 'Enter') { - - e.preventDefault() - - handleAddLink() - - } - - }} - - autoFocus - - /> - -
- - - - - - - - - -
- -
- -
- - ) - - } - - \ No newline at end of file +
+ + + + + + + + ) +} \ No newline at end of file diff --git a/keep-notes/components/note-input.tsx b/keep-notes/components/note-input.tsx index 6c33dd6..680d76f 100644 --- a/keep-notes/components/note-input.tsx +++ b/keep-notes/components/note-input.tsx @@ -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([]) + + 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([]) + const [images, setImages] = useState([]) const [links, setLinks] = useState([]) const [isMarkdown, setIsMarkdown] = useState(false) @@ -418,46 +462,25 @@ export function NoteInput() { {/* Link Previews */} {links.length > 0 && (
- {links.map((link, idx) => ( -
- {link.imageUrl && ( -
- )} -
-

{link.title || link.url}

- {link.description &&

{link.description}

} - - {new URL(link.url).hostname} - -
- -
+ {/* ... */} +
+ )} + + {/* Selected Labels Display (Moved here to be visible for both text and checklist) */} + {selectedLabels.length > 0 && ( +
+ {selectedLabels.map(label => ( + setSelectedLabels(prev => prev.filter(l => l !== label))} + /> ))}
)} {type === 'text' ? (
- {/* Selected Labels Display */} - {selectedLabels.length > 0 && ( -
- {selectedLabels.map(label => ( - setSelectedLabels(prev => prev.filter(l => l !== label))} - /> - ))} -
- )} - {/* Markdown toggle button */} {isMarkdown && (
@@ -496,6 +519,14 @@ export function NoteInput() { autoFocus /> )} + + {/* AI Auto-tagging Suggestions */} +
) : (
diff --git a/keep-notes/components/sidebar.tsx b/keep-notes/components/sidebar.tsx index 498c375..1951749 100644 --- a/keep-notes/components/sidebar.tsx +++ b/keep-notes/components/sidebar.tsx @@ -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'} /> + ) } + diff --git a/keep-notes/context/LabelContext.tsx b/keep-notes/context/LabelContext.tsx index b8c86f4..b374215 100644 --- a/keep-notes/context/LabelContext.tsx +++ b/keep-notes/context/LabelContext.tsx @@ -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([]) 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 -} +} \ No newline at end of file diff --git a/keep-notes/hooks/use-auto-tagging.ts b/keep-notes/hooks/use-auto-tagging.ts new file mode 100644 index 0000000..4ffdb78 --- /dev/null +++ b/keep-notes/hooks/use-auto-tagging.ts @@ -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([]); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [error, setError] = useState(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([]), + }; +} diff --git a/keep-notes/hooks/use-debounce.ts b/keep-notes/hooks/use-debounce.ts new file mode 100644 index 0000000..a3d0f38 --- /dev/null +++ b/keep-notes/hooks/use-debounce.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/keep-notes/lib/ai/factory.ts b/keep-notes/lib/ai/factory.ts new file mode 100644 index 0000000..dd4ca7a --- /dev/null +++ b/keep-notes/lib/ai/factory.ts @@ -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' + ); + } +} diff --git a/keep-notes/lib/ai/providers/ollama.ts b/keep-notes/lib/ai/providers/ollama.ts new file mode 100644 index 0000000..ff8357a --- /dev/null +++ b/keep-notes/lib/ai/providers/ollama.ts @@ -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 { + 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 { + 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 []; + } + } +} \ No newline at end of file diff --git a/keep-notes/lib/ai/providers/openai.ts b/keep-notes/lib/ai/providers/openai.ts new file mode 100644 index 0000000..18991b0 --- /dev/null +++ b/keep-notes/lib/ai/providers/openai.ts @@ -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 { + 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 { + 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 []; + } + } +} diff --git a/keep-notes/lib/ai/types.ts b/keep-notes/lib/ai/types.ts new file mode 100644 index 0000000..4a88804 --- /dev/null +++ b/keep-notes/lib/ai/types.ts @@ -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; + + /** + * Génère un vecteur d'embeddings pour la recherche sémantique. + */ + getEmbeddings(text: string): Promise; +} + +export type AIProviderType = 'openai' | 'ollama'; + +export interface AIConfig { + provider: AIProviderType; + apiKey?: string; + baseUrl?: string; // Utile pour Ollama + model?: string; +} diff --git a/keep-notes/lib/utils.ts b/keep-notes/lib/utils.ts index bd0c391..db50258 100644 --- a/keep-notes/lib/utils.ts +++ b/keep-notes/lib/utils.ts @@ -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]; +} diff --git a/keep-notes/package.json b/keep-notes/package.json index d3c0647..fc284bc 100644 --- a/keep-notes/package.json +++ b/keep-notes/package.json @@ -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", diff --git a/keep-notes/prisma/dev.db b/keep-notes/prisma/dev.db index b3d81c2..242b77d 100644 Binary files a/keep-notes/prisma/dev.db and b/keep-notes/prisma/dev.db differ diff --git a/keep-notes/public/uploads/notes/a7d16bd0-5405-40d9-a462-0454df7f7e6b.webp b/keep-notes/public/uploads/notes/a7d16bd0-5405-40d9-a462-0454df7f7e6b.webp new file mode 100644 index 0000000..74c5daa Binary files /dev/null and b/keep-notes/public/uploads/notes/a7d16bd0-5405-40d9-a462-0454df7f7e6b.webp differ diff --git a/package-lock.json b/package-lock.json index c6e3720..654b523 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "name": "memento", "version": "0.2.0", "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", @@ -36,6 +37,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", @@ -47,6 +49,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", @@ -3549,6 +3552,15 @@ } } }, + "keep-notes/node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "keep-notes/node_modules/zwitch": { "version": "2.0.4", "license": "MIT", @@ -4151,13 +4163,6 @@ "node": ">=18.0.0" } }, - "mcp-server/node_modules/eventsource-parser": { - "version": "3.0.6", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "mcp-server/node_modules/express": { "version": "4.22.1", "license": "MIT", @@ -4812,6 +4817,68 @@ "zod": "^3.25 || ^4" } }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.10.tgz", + "integrity": "sha512-sRlPMKd38+fdp2y11USW44c0o8tsIsT6T/pgyY04VXC3URjIRnkxugxd9AkU2ogfpPDMz50cBAGPnMxj+6663Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.2", + "@ai-sdk/provider-utils": "4.0.4", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.7.tgz", + "integrity": "sha512-CBoYn1U59Lop8yBL9KuVjHCKc/B06q9Qo0SasRwHoyMEq+X4I8LQZu3a8Ck1jwwcZTTxfyiExB70LtIRSynBDA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.2", + "@ai-sdk/provider-utils": "4.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.2.tgz", + "integrity": "sha512-HrEmNt/BH/hkQ7zpi2o6N3k1ZR1QTb7z85WYhYygiTxOQuaml4CMtHCWRbric5WPU+RNsYI7r1EpyVQMKO1pYw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.4.tgz", + "integrity": "sha512-VxhX0B/dWGbpNHxrKCWUAJKXIXV015J4e7qYjdIU9lLWeptk0KMLGcqkB4wFxff5Njqur8dt8wRi1MN9lZtDqg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.2", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@auth/core": { "version": "0.41.1", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", @@ -5463,6 +5530,16 @@ "node": ">= 10" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@panva/hkdf": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", @@ -5497,6 +5574,12 @@ "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", "license": "Apache-2.0" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -5523,6 +5606,33 @@ "csstype": "^3.2.2" } }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ai": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.23.tgz", + "integrity": "sha512-IV8hqp6sQvZ0XVlu8bCnFlwG7+2d40ff26RZ1k4yw/zVuk2F6SXlONURtTo9vwPOPYeF7auXvyPA+dMDoepWxg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.10", + "@ai-sdk/provider": "3.0.2", + "@ai-sdk/provider-utils": "4.0.4", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", @@ -5752,6 +5862,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5841,6 +5960,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/memento": { "resolved": "keep-notes", "link": true @@ -6004,6 +6129,57 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/ollama-ai-provider": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ollama-ai-provider/-/ollama-ai-provider-1.2.0.tgz", + "integrity": "sha512-jTNFruwe3O/ruJeppI/quoOUxG7NA6blG3ZyQj3lei4+NnJo7bi3eIRWqlVpRlu/mbzbFXeJSBuYQWF6pzGKww==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "^1.0.0", + "@ai-sdk/provider-utils": "^2.0.0", + "partial-json": "0.1.7" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/ollama-ai-provider/node_modules/@ai-sdk/provider": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", + "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ollama-ai-provider/node_modules/@ai-sdk/provider-utils": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", + "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -6053,6 +6229,12 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -6151,6 +6333,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -6337,9 +6525,9 @@ } }, "node_modules/zod": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", - "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "peer": true, "funding": {