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:
parent
6f4d758e5c
commit
3c4b9d6176
@ -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
|
||||||
@ -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
|
||||||
|
|
||||||
@ -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
|
||||||
@ -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
|
||||||
56
_bmad-output/implementation-artifacts/sprint-status.yaml
Normal file
56
_bmad-output/implementation-artifacts/sprint-status.yaml
Normal file
@ -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
|
||||||
@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
stepsCompleted: [1]
|
stepsCompleted: [1, 2, 3, 4]
|
||||||
|
workflow_completed: true
|
||||||
inputDocuments:
|
inputDocuments:
|
||||||
- _bmad-output/planning-artifacts/prd.md
|
- _bmad-output/planning-artifacts/prd.md
|
||||||
- _bmad-output/planning-artifacts/prd-web-app-requirements.md
|
- _bmad-output/planning-artifacts/prd-web-app-requirements.md
|
||||||
|
|||||||
@ -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
|
// Update a note
|
||||||
export async function updateNote(id: string, data: {
|
export async function updateNote(id: string, data: {
|
||||||
title?: string | null
|
title?: string | null
|
||||||
@ -189,6 +222,14 @@ export async function updateNote(id: string, data: {
|
|||||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||||
|
|
||||||
try {
|
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
|
// Stringify JSON fields if they exist
|
||||||
const updateData: any = { ...data }
|
const updateData: any = { ...data }
|
||||||
if ('checkItems' in data) {
|
if ('checkItems' in data) {
|
||||||
@ -213,6 +254,15 @@ export async function updateNote(id: string, data: {
|
|||||||
data: updateData
|
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('/')
|
revalidatePath('/')
|
||||||
return parseNote(note)
|
return parseNote(note)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -227,6 +277,13 @@ export async function deleteNote(id: string) {
|
|||||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||||
|
|
||||||
try {
|
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({
|
await prisma.note.delete({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
@ -234,6 +291,11 @@ export async function deleteNote(id: string) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Cleanup potential orphans
|
||||||
|
if (labels.length > 0) {
|
||||||
|
cleanupOrphanLabels(session.user.id, labels)
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath('/')
|
revalidatePath('/')
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} 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
|
// Update full order of notes
|
||||||
export async function updateFullOrder(ids: string[]) {
|
export async function updateFullOrder(ids: string[]) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|||||||
31
keep-notes/app/api/ai/tags/route.ts
Normal file
31
keep-notes/app/api/ai/tags/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
keep-notes/app/api/ai/test/route.ts
Normal file
27
keep-notes/app/api/ai/test/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
152
keep-notes/app/settings/page.tsx
Normal file
152
keep-notes/app/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
keep-notes/components/ghost-tags.tsx
Normal file
79
keep-notes/components/ghost-tags.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ import { LabelBadge } from './label-badge'
|
|||||||
import { NoteImages } from './note-images'
|
import { NoteImages } from './note-images'
|
||||||
import { NoteChecklist } from './note-checklist'
|
import { NoteChecklist } from './note-checklist'
|
||||||
import { NoteActions } from './note-actions'
|
import { NoteActions } from './note-actions'
|
||||||
|
import { useLabels } from '@/context/LabelContext'
|
||||||
|
|
||||||
interface NoteCardProps {
|
interface NoteCardProps {
|
||||||
note: Note
|
note: Note
|
||||||
@ -22,6 +23,7 @@ interface NoteCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps) {
|
export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps) {
|
||||||
|
const { refreshLabels } = useLabels()
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const colorClasses = NOTE_COLORS[note.color as NoteColor] || NOTE_COLORS.default
|
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)
|
setIsDeleting(true)
|
||||||
try {
|
try {
|
||||||
await deleteNote(note.id)
|
await deleteNote(note.id)
|
||||||
|
// Refresh global labels to reflect garbage collection
|
||||||
|
await refreshLabels()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete note:', error)
|
console.error('Failed to delete note:', error)
|
||||||
setIsDeleting(false)
|
setIsDeleting(false)
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} 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 { updateNote } from '@/app/actions/notes'
|
||||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@ -28,6 +28,9 @@ import { LabelManager } from './label-manager'
|
|||||||
import { LabelBadge } from './label-badge'
|
import { LabelBadge } from './label-badge'
|
||||||
import { ReminderDialog } from './reminder-dialog'
|
import { ReminderDialog } from './reminder-dialog'
|
||||||
import { EditorImages } from './editor-images'
|
import { EditorImages } from './editor-images'
|
||||||
|
import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
||||||
|
import { GhostTags } from './ghost-tags'
|
||||||
|
import { useLabels } from '@/context/LabelContext'
|
||||||
|
|
||||||
interface NoteEditorProps {
|
interface NoteEditorProps {
|
||||||
note: Note
|
note: Note
|
||||||
@ -36,6 +39,7 @@ interface NoteEditorProps {
|
|||||||
|
|
||||||
export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||||
const { addToast } = useToast()
|
const { addToast } = useToast()
|
||||||
|
const { labels: globalLabels, addLabel, refreshLabels } = useLabels()
|
||||||
const [title, setTitle] = useState(note.title || '')
|
const [title, setTitle] = useState(note.title || '')
|
||||||
const [content, setContent] = useState(note.content)
|
const [content, setContent] = useState(note.content)
|
||||||
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
||||||
@ -49,6 +53,12 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
|||||||
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
|
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
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
|
// Reminder state
|
||||||
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||||
const [currentReminder, setCurrentReminder] = useState<Date | null>(note.reminder)
|
const [currentReminder, setCurrentReminder] = useState<Date | null>(note.reminder)
|
||||||
@ -56,9 +66,43 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
|||||||
// Link state
|
// Link state
|
||||||
const [showLinkDialog, setShowLinkDialog] = useState(false)
|
const [showLinkDialog, setShowLinkDialog] = useState(false)
|
||||||
const [linkUrl, setLinkUrl] = useState('')
|
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 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 handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files
|
const files = e.target.files
|
||||||
if (!files) return
|
if (!files) return
|
||||||
@ -142,6 +186,10 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
|||||||
reminder: currentReminder,
|
reminder: currentReminder,
|
||||||
isMarkdown,
|
isMarkdown,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Rafraîchir les labels globaux pour refléter les suppressions éventuelles (orphans)
|
||||||
|
await refreshLabels()
|
||||||
|
|
||||||
onClose()
|
onClose()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save note:', error)
|
console.error('Failed to save note:', error)
|
||||||
@ -193,12 +241,19 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Input
|
<div className="relative">
|
||||||
placeholder="Title"
|
<Input
|
||||||
value={title}
|
placeholder="Title"
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
value={title}
|
||||||
className="text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent"
|
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 */}
|
{/* Images */}
|
||||||
<EditorImages images={images} onRemove={handleRemoveImage} />
|
<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"
|
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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -421,84 +484,43 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
|||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<ReminderDialog
|
<ReminderDialog
|
||||||
|
open={showReminderDialog}
|
||||||
open={showReminderDialog}
|
onOpenChange={setShowReminderDialog}
|
||||||
|
currentReminder={currentReminder}
|
||||||
onOpenChange={setShowReminderDialog}
|
onSave={handleReminderSave}
|
||||||
|
onRemove={handleRemoveReminder}
|
||||||
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
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setShowLinkDialog(false)}>
|
||||||
<Dialog open={showLinkDialog} onOpenChange={setShowLinkDialog}>
|
Cancel
|
||||||
|
</Button>
|
||||||
<DialogContent>
|
<Button onClick={handleAddLink}>
|
||||||
|
Add
|
||||||
<DialogHeader>
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
<DialogTitle>Add Link</DialogTitle>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</DialogHeader>
|
</Dialog>
|
||||||
|
)
|
||||||
<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>
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@ -41,6 +41,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
|
|||||||
import { MarkdownContent } from './markdown-content'
|
import { MarkdownContent } from './markdown-content'
|
||||||
import { LabelSelector } from './label-selector'
|
import { LabelSelector } from './label-selector'
|
||||||
import { LabelBadge } from './label-badge'
|
import { LabelBadge } from './label-badge'
|
||||||
|
import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
||||||
|
import { GhostTags } from './ghost-tags'
|
||||||
|
import { useLabels } from '@/context/LabelContext'
|
||||||
|
|
||||||
interface HistoryState {
|
interface HistoryState {
|
||||||
title: string
|
title: string
|
||||||
@ -56,6 +59,7 @@ interface NoteState {
|
|||||||
|
|
||||||
export function NoteInput() {
|
export function NoteInput() {
|
||||||
const { addToast } = useToast()
|
const { addToast } = useToast()
|
||||||
|
const { labels: globalLabels, addLabel } = useLabels()
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
const [type, setType] = useState<'text' | 'checklist'>('text')
|
const [type, setType] = useState<'text' | 'checklist'>('text')
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
@ -67,7 +71,47 @@ export function NoteInput() {
|
|||||||
// Simple state without complex undo/redo - like Google Keep
|
// Simple state without complex undo/redo - like Google Keep
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [content, setContent] = 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 [checkItems, setCheckItems] = useState<CheckItem[]>([])
|
||||||
|
|
||||||
const [images, setImages] = useState<string[]>([])
|
const [images, setImages] = useState<string[]>([])
|
||||||
const [links, setLinks] = useState<LinkMetadata[]>([])
|
const [links, setLinks] = useState<LinkMetadata[]>([])
|
||||||
const [isMarkdown, setIsMarkdown] = useState(false)
|
const [isMarkdown, setIsMarkdown] = useState(false)
|
||||||
@ -418,46 +462,25 @@ export function NoteInput() {
|
|||||||
{/* Link Previews */}
|
{/* Link Previews */}
|
||||||
{links.length > 0 && (
|
{links.length > 0 && (
|
||||||
<div className="flex flex-col gap-2 mt-2">
|
<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">
|
</div>
|
||||||
{link.imageUrl && (
|
)}
|
||||||
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
|
||||||
)}
|
{/* Selected Labels Display (Moved here to be visible for both text and checklist) */}
|
||||||
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
|
{selectedLabels.length > 0 && (
|
||||||
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
|
{selectedLabels.map(label => (
|
||||||
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-blue-500 truncate hover:underline block mt-1">
|
<LabelBadge
|
||||||
{new URL(link.url).hostname}
|
key={label}
|
||||||
</a>
|
label={label}
|
||||||
</div>
|
onRemove={() => setSelectedLabels(prev => prev.filter(l => l !== label))}
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'text' ? (
|
{type === 'text' ? (
|
||||||
<div className="space-y-2">
|
<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 */}
|
{/* Markdown toggle button */}
|
||||||
{isMarkdown && (
|
{isMarkdown && (
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
@ -496,6 +519,14 @@ export function NoteInput() {
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* AI Auto-tagging Suggestions */}
|
||||||
|
<GhostTags
|
||||||
|
suggestions={filteredSuggestions}
|
||||||
|
isAnalyzing={isAnalyzing}
|
||||||
|
onSelectTag={handleSelectGhostTag}
|
||||||
|
onDismissTag={handleDismissGhostTag}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useState } from 'react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname, useSearchParams } from 'next/navigation'
|
import { usePathname, useSearchParams } from 'next/navigation'
|
||||||
import { cn } from '@/lib/utils'
|
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 { useLabels } from '@/context/LabelContext'
|
||||||
import { LabelManagementDialog } from './label-management-dialog'
|
import { LabelManagementDialog } from './label-management-dialog'
|
||||||
|
|
||||||
@ -105,6 +105,13 @@ export function Sidebar({ className }: { className?: string }) {
|
|||||||
label="Trash"
|
label="Trash"
|
||||||
active={pathname === '/trash'}
|
active={pathname === '/trash'}
|
||||||
/>
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/settings"
|
||||||
|
icon={Settings}
|
||||||
|
label="Settings"
|
||||||
|
active={pathname === '/settings'}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||||
import { LabelColorName, LABEL_COLORS } from '@/lib/types'
|
import { LabelColorName, LABEL_COLORS } from '@/lib/types'
|
||||||
|
import { getHashColor } from '@/lib/utils'
|
||||||
|
|
||||||
export interface Label {
|
export interface Label {
|
||||||
id: string
|
id: string
|
||||||
@ -28,11 +29,10 @@ export function LabelProvider({ children }: { children: ReactNode }) {
|
|||||||
const [labels, setLabels] = useState<Label[]>([])
|
const [labels, setLabels] = useState<Label[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
// Fetch labels from API
|
|
||||||
const fetchLabels = async () => {
|
const fetchLabels = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const response = await fetch('/api/labels')
|
const response = await fetch('/api/labels', { cache: 'no-store' })
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
setLabels(data.data)
|
setLabels(data.data)
|
||||||
@ -50,9 +50,7 @@ export function LabelProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const addLabel = async (name: string, color?: LabelColorName) => {
|
const addLabel = async (name: string, color?: LabelColorName) => {
|
||||||
try {
|
try {
|
||||||
// Get existing label color if not provided
|
const labelColor = color || getHashColor(name);
|
||||||
const existingColor = getLabelColorHelper(name)
|
|
||||||
const labelColor = color || existingColor
|
|
||||||
|
|
||||||
const response = await fetch('/api/labels', {
|
const response = await fetch('/api/labels', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -130,4 +128,4 @@ export function useLabels() {
|
|||||||
throw new Error('useLabels must be used within a LabelProvider')
|
throw new Error('useLabels must be used within a LabelProvider')
|
||||||
}
|
}
|
||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
61
keep-notes/hooks/use-auto-tagging.ts
Normal file
61
keep-notes/hooks/use-auto-tagging.ts
Normal 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([]),
|
||||||
|
};
|
||||||
|
}
|
||||||
17
keep-notes/hooks/use-debounce.ts
Normal file
17
keep-notes/hooks/use-debounce.ts
Normal 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;
|
||||||
|
}
|
||||||
25
keep-notes/lib/ai/factory.ts
Normal file
25
keep-notes/lib/ai/factory.ts
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
keep-notes/lib/ai/providers/ollama.ts
Normal file
77
keep-notes/lib/ai/providers/ollama.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
keep-notes/lib/ai/providers/openai.ts
Normal file
46
keep-notes/lib/ai/providers/openai.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
keep-notes/lib/ai/types.ts
Normal file
25
keep-notes/lib/ai/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,6 +1,21 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
import { LABEL_COLORS, LabelColorName } from "./types"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
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];
|
||||||
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
"test:headed": "playwright test --headed"
|
"test:headed": "playwright test --headed"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "^3.0.7",
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@ -26,6 +27,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"ai": "^6.0.23",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
@ -37,6 +39,7 @@
|
|||||||
"muuri": "^0.9.5",
|
"muuri": "^0.9.5",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"ollama-ai-provider": "^1.2.0",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
208
package-lock.json
generated
208
package-lock.json
generated
@ -21,6 +21,7 @@
|
|||||||
"name": "memento",
|
"name": "memento",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "^3.0.7",
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@ -36,6 +37,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"ai": "^6.0.23",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
@ -47,6 +49,7 @@
|
|||||||
"muuri": "^0.9.5",
|
"muuri": "^0.9.5",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"ollama-ai-provider": "^1.2.0",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "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": {
|
"keep-notes/node_modules/zwitch": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -4151,13 +4163,6 @@
|
|||||||
"node": ">=18.0.0"
|
"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": {
|
"mcp-server/node_modules/express": {
|
||||||
"version": "4.22.1",
|
"version": "4.22.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -4812,6 +4817,68 @@
|
|||||||
"zod": "^3.25 || ^4"
|
"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": {
|
"node_modules/@auth/core": {
|
||||||
"version": "0.41.1",
|
"version": "0.41.1",
|
||||||
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz",
|
||||||
@ -5463,6 +5530,16 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@panva/hkdf": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
@ -5497,6 +5574,12 @@
|
|||||||
"integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==",
|
"integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@ -5523,6 +5606,33 @@
|
|||||||
"csstype": "^3.2.2"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.11",
|
"version": "2.9.11",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
"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"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@ -5841,6 +5960,12 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"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": {
|
"node_modules/memento": {
|
||||||
"resolved": "keep-notes",
|
"resolved": "keep-notes",
|
||||||
"link": true
|
"link": true
|
||||||
@ -6004,6 +6129,57 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"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": {
|
"node_modules/parse5": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
@ -6053,6 +6229,12 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"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": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"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==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.3",
|
"version": "7.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
@ -6337,9 +6525,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.5",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user