fix: make paragraph refactor service use configured AI provider

The paragraph-refactor service was using OLLAMA_BASE_URL directly from
environment variables instead of using the configured AI provider from
the database. This caused "OLLAMA error" even when OpenAI was configured
in the admin interface.

Changes:
- paragraph-refactor.service.ts: Now uses getSystemConfig() and
  getTagsProvider() from factory instead of direct Ollama calls
- factory.ts: Added proper error messages when API keys are missing
- .env.docker.example: Updated with new provider configuration
  variables (AI_PROVIDER_TAGS, AI_PROVIDER_EMBEDDING)

This fixes the issue where AI reformulation features (Clarify, Shorten,
Improve Style) would fail with OLLAMA errors even when OpenAI was
properly configured in the admin settings.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
sepehr 2026-01-12 22:51:24 +01:00
parent 58e486c68e
commit 5d315a6bdd
10 changed files with 3025 additions and 3072 deletions

View File

@ -14,19 +14,37 @@ NEXTAUTH_URL="http://YOUR_SERVER_IP:3000"
# ============================================ # ============================================
# AI Provider Configuration # AI Provider Configuration
# ============================================ # ============================================
# You can configure separate providers for tags and embeddings
# Options: ollama, openai, custom
# For local Ollama in Docker (service name): # For local Ollama in Docker (service name):
AI_PROVIDER=ollama # AI_PROVIDER_TAGS=ollama
OLLAMA_BASE_URL="http://ollama:11434" # AI_PROVIDER_EMBEDDING=ollama
OLLAMA_MODEL="granite4:latest" # OLLAMA_BASE_URL="http://ollama:11434"
# AI_MODEL_TAGS="granite4:latest"
# AI_MODEL_EMBEDDING="embeddinggemma:latest"
# For external Ollama (on host or different server): # For external Ollama (on host or different server):
# AI_PROVIDER=ollama # AI_PROVIDER_TAGS=ollama
# AI_PROVIDER_EMBEDDING=ollama
# OLLAMA_BASE_URL="http://YOUR_SERVER_IP:11434" # OLLAMA_BASE_URL="http://YOUR_SERVER_IP:11434"
# OLLAMA_MODEL="granite4:latest" # AI_MODEL_TAGS="granite4:latest"
# AI_MODEL_EMBEDDING="embeddinggemma:latest"
# For OpenAI: # For OpenAI (recommended for production):
# AI_PROVIDER=openai # AI_PROVIDER_TAGS=openai
# AI_PROVIDER_EMBEDDING=openai
# OPENAI_API_KEY="sk-..." # OPENAI_API_KEY="sk-..."
# AI_MODEL_TAGS="gpt-4o-mini"
# AI_MODEL_EMBEDDING="text-embedding-3-small"
# Mixed setup (e.g., Ollama for tags, OpenAI for embeddings):
# AI_PROVIDER_TAGS=ollama
# AI_PROVIDER_EMBEDDING=openai
# OLLAMA_BASE_URL="http://ollama:11434"
# OPENAI_API_KEY="sk-..."
# AI_MODEL_TAGS="granite4:latest"
# AI_MODEL_EMBEDDING="text-embedding-3-small"
# ============================================ # ============================================
# Email Configuration (Optional) # Email Configuration (Optional)
@ -37,8 +55,3 @@ OLLAMA_MODEL="granite4:latest"
# SMTP_USER="your-email@gmail.com" # SMTP_USER="your-email@gmail.com"
# SMTP_PASS="your-app-password" # SMTP_PASS="your-app-password"
# SMTP_FROM="noreply@memento.app" # SMTP_FROM="noreply@memento.app"
# ============================================
# OpenAI (Optional - for GPT models)
# ============================================
# OPENAI_API_KEY="sk-..."

View File

@ -29,6 +29,7 @@ function createOpenAIProvider(config: Record<string, string>, modelName: string,
const apiKey = config?.OPENAI_API_KEY || process.env.OPENAI_API_KEY || ''; const apiKey = config?.OPENAI_API_KEY || process.env.OPENAI_API_KEY || '';
if (!apiKey) { if (!apiKey) {
throw new Error('OPENAI_API_KEY is required when using OpenAI provider');
} }
return new OpenAIProvider(apiKey, modelName, embeddingModelName); return new OpenAIProvider(apiKey, modelName, embeddingModelName);
@ -39,9 +40,11 @@ function createCustomOpenAIProvider(config: Record<string, string>, modelName: s
const baseUrl = config?.CUSTOM_OPENAI_BASE_URL || process.env.CUSTOM_OPENAI_BASE_URL || ''; const baseUrl = config?.CUSTOM_OPENAI_BASE_URL || process.env.CUSTOM_OPENAI_BASE_URL || '';
if (!apiKey) { if (!apiKey) {
throw new Error('CUSTOM_OPENAI_API_KEY is required when using Custom OpenAI provider');
} }
if (!baseUrl) { if (!baseUrl) {
throw new Error('CUSTOM_OPENAI_BASE_URL is required when using Custom OpenAI provider');
} }
return new CustomOpenAIProvider(apiKey, baseUrl, modelName, embeddingModelName); return new CustomOpenAIProvider(apiKey, baseUrl, modelName, embeddingModelName);

View File

@ -7,6 +7,8 @@
*/ */
import { LanguageDetectionService } from './language-detection.service' import { LanguageDetectionService } from './language-detection.service'
import { getTagsProvider } from '../factory'
import { getSystemConfig } from '@/lib/config'
export type RefactorMode = 'clarify' | 'shorten' | 'improveStyle' export type RefactorMode = 'clarify' | 'shorten' | 'improveStyle'
@ -83,37 +85,13 @@ export class ParagraphRefactorService {
const systemPrompt = this.getSystemPrompt(mode) const systemPrompt = this.getSystemPrompt(mode)
const userPrompt = this.getUserPrompt(mode, content, language) const userPrompt = this.getUserPrompt(mode, content, language)
// Get AI provider response using fetch // Get AI provider from factory
let baseUrl = process.env.OLLAMA_BASE_URL const config = await getSystemConfig()
const provider = getTagsProvider(config)
if (!baseUrl) { // Use provider's generateText method
throw new Error('OLLAMA_BASE_URL environment variable is required') const fullPrompt = `${systemPrompt}\n\n${userPrompt}`
} const refactored = await provider.generateText(fullPrompt)
// Remove /api suffix if present to avoid double /api/api/...
if (baseUrl.endsWith('/api')) {
baseUrl = baseUrl.slice(0, -4)
}
const modelName = process.env.OLLAMA_MODEL || 'granite4:latest'
const response = await fetch(`${baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: modelName,
system: systemPrompt,
prompt: userPrompt,
stream: false,
}),
})
if (!response.ok) {
throw new Error(`Provider error: ${response.statusText}`)
}
const data = await response.json()
const refactored = this.extractRefactoredText(data.response)
// Calculate word count change // Calculate word count change
const refactoredWordCount = refactored.split(/\s+/).length const refactoredWordCount = refactored.split(/\s+/).length
@ -189,38 +167,16 @@ ${content}
Original language: ${language} Original language: ${language}
IMPORTANT: Provide all 3 versions in ${language}. No English, no explanations.` IMPORTANT: Provide all 3 versions in ${language}. No English, no explanations.`
// Get AI provider response using fetch // Get AI provider from factory
let baseUrl = process.env.OLLAMA_BASE_URL const config = await getSystemConfig()
const provider = getTagsProvider(config)
if (!baseUrl) { // Use provider's generateText method
throw new Error('OLLAMA_BASE_URL environment variable is required') const fullPrompt = `${systemPrompt}\n\n${userPrompt}`
} const response = await provider.generateText(fullPrompt)
// Remove /api suffix if present to avoid double /api/api/...
if (baseUrl.endsWith('/api')) {
baseUrl = baseUrl.slice(0, -4)
}
const modelName = process.env.OLLAMA_MODEL || 'granite4:latest'
const response = await fetch(`${baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: modelName,
system: systemPrompt,
prompt: userPrompt,
stream: false,
}),
})
if (!response.ok) {
throw new Error(`Provider error: ${response.statusText}`)
}
const data = await response.json()
// Parse JSON response // Parse JSON response
const jsonResponse = JSON.parse(data.response) const jsonResponse = JSON.parse(response)
const modes: RefactorMode[] = ['clarify', 'shorten', 'improveStyle'] const modes: RefactorMode[] = ['clarify', 'shorten', 'improveStyle']
const results: RefactorResult[] = [] const results: RefactorResult[] = []

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{ {
"name": "prisma-client-62aaff0ff1302a5b1470021854f34bb9a9c1219fe39a7ee39aa626bd83e22eae", "name": "prisma-client-46efe72656f1c393bbd99fdd6d2d34037b30f693f014757faf08aec1d9319858",
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "index-browser.js", "browser": "index-browser.js",

View File

@ -1,6 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
output = "./client-generated" output = "./client-generated"
@ -17,27 +14,24 @@ model User {
name String? name String?
email String @unique email String @unique
emailVerified DateTime? emailVerified DateTime?
password String? // Hashed password password String?
role String @default("USER") // "USER" or "ADMIN" role String @default("USER")
image String? image String?
theme String @default("light") theme String @default("light")
resetToken String? @unique resetToken String? @unique
resetTokenExpiry DateTime? resetTokenExpiry DateTime?
accounts Account[]
sessions Session[]
notes Note[]
labels Label[]
notebooks Notebook[] // NEW: Relation to notebooks
receivedShares NoteShare[] @relation("ReceivedShares")
sentShares NoteShare[] @relation("SentShares")
// Phase 1 AI Relations
aiFeedback AiFeedback[]
aiSettings UserAISettings?
memoryEchoInsights MemoryEchoInsight[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
accounts Account[]
aiFeedback AiFeedback[]
labels Label[]
memoryEchoInsights MemoryEchoInsight[]
notes Note[]
sentShares NoteShare[] @relation("SentShares")
receivedShares NoteShare[] @relation("ReceivedShares")
notebooks Notebook[]
sessions Session[]
aiSettings UserAISettings?
} }
model Account { model Account {
@ -52,10 +46,8 @@ model Account {
scope String? scope String?
id_token String? id_token String?
session_state String? session_state String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([provider, providerAccountId]) @@id([provider, providerAccountId])
@ -65,10 +57,9 @@ model Session {
sessionToken String @unique sessionToken String @unique
userId String userId String
expires DateTime expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model VerificationToken { model VerificationToken {
@ -79,19 +70,18 @@ model VerificationToken {
@@id([identifier, token]) @@id([identifier, token])
} }
// NEW: Notebook model for organizing notes
model Notebook { model Notebook {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
icon String? // Emoji or icon name icon String?
color String? // Hex color for personalization color String?
order Int // Manual order for drag & drop order Int
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
notes Note[] // Notes can belong to a notebook
labels Label[] // Labels are contextual to this notebook
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
labels Label[]
notes Note[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, order]) @@index([userId, order])
@@index([userId]) @@index([userId])
@ -101,17 +91,17 @@ model Label {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
color String @default("gray") color String @default("gray")
notebookId String? // TEMPORARY: Optional for migration, will be required later notebookId String?
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: Cascade) userId String?
notes Note[] // NEW: Many-to-many relation with notes
userId String? // DEPRECATED: Kept for migration, will be removed after migration
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: Cascade)
notes Note[] @relation("LabelToNote")
@@unique([notebookId, name]) // NEW: Labels are unique within a notebook (ignored if notebookId is null) @@unique([notebookId, name])
@@index([notebookId]) @@index([notebookId])
@@index([userId]) // DEPRECATED: Keep for now, remove after migration @@index([userId])
} }
model Note { model Note {
@ -121,69 +111,60 @@ model Note {
color String @default("default") color String @default("default")
isPinned Boolean @default(false) isPinned Boolean @default(false)
isArchived Boolean @default(false) isArchived Boolean @default(false)
type String @default("text") // "text" or "checklist" type String @default("text")
checkItems String? // For checklist items stored as JSON string checkItems String?
labels String? // Array of label names stored as JSON string (DEPRECATED) labels String?
images String? // Array of image URLs stored as JSON string images String?
links String? // Array of link metadata stored as JSON string links String?
reminder DateTime? // Reminder date and time reminder DateTime?
isReminderDone Boolean @default(false) isReminderDone Boolean @default(false)
reminderRecurrence String? // "none", "daily", "weekly", "monthly", "custom" reminderRecurrence String?
reminderLocation String? // Location for location-based reminders reminderLocation String?
isMarkdown Boolean @default(false) // Whether content uses Markdown isMarkdown Boolean @default(false)
size String @default("small") // "small", "medium", "large" size String @default("small")
embedding String? // Vector embeddings stored as JSON string for semantic search embedding String?
sharedWith String? // Array of user IDs (collaborators) stored as JSON string sharedWith String?
userId String? // Owner of the note userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
shares NoteShare[] // All share records for this note
order Int @default(0) order Int @default(0)
notebookId String?
// NEW: Notebook relation (optional - null = "Notes générales" / Inbox)
notebookId String? // NULL = note is in general notes
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: SetNull)
// NEW: Many-to-many relation with labels
labelRelations Label[] // Uses implicit _NoteToLabel junction table
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
autoGenerated Boolean?
// Phase 1 AI Extensions aiProvider String?
autoGenerated Boolean? // True if title/content was AI-generated aiConfidence Int?
aiProvider String? // 'openai' | 'ollama' language String?
aiConfidence Int? // 0-100 (collected Phase 1, UI Phase 3) languageConfidence Float?
language String? // ISO 639-1: 'fr', 'en', 'es', 'de', 'fa' lastAiAnalysis DateTime?
languageConfidence Float? // 0.0-1.0
lastAiAnalysis DateTime? // Timestamp of last AI analysis
// Relations for Phase 1 AI
aiFeedback AiFeedback[] aiFeedback AiFeedback[]
memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2") memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2")
memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
notebook Notebook? @relation(fields: [notebookId], references: [id])
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
shares NoteShare[]
labelRelations Label[] @relation("LabelToNote")
@@index([isPinned]) @@index([isPinned])
@@index([isArchived]) @@index([isArchived])
@@index([order]) @@index([order])
@@index([reminder]) @@index([reminder])
@@index([userId]) @@index([userId])
@@index([userId, notebookId]) // NEW: For filtering notes by notebook @@index([userId, notebookId])
} }
model NoteShare { model NoteShare {
id String @id @default(cuid()) id String @id @default(cuid())
noteId String noteId String
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
userId String userId String
user User @relation("ReceivedShares", fields: [userId], references: [id], onDelete: Cascade) sharedBy String
sharedBy String // User ID who shared the note status String @default("pending")
sharer User @relation("SentShares", fields: [sharedBy], references: [id], onDelete: Cascade) permission String @default("view")
status String @default("pending") // "pending", "accepted", "declined", "removed"
permission String @default("view") // "view", "comment", "edit"
notifiedAt DateTime? notifiedAt DateTime?
respondedAt DateTime? respondedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sharer User @relation("SentShares", fields: [sharedBy], references: [id], onDelete: Cascade)
user User @relation("ReceivedShares", fields: [userId], references: [id], onDelete: Cascade)
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
@@unique([noteId, userId]) @@unique([noteId, userId])
@@index([userId]) @@index([userId])
@ -196,21 +177,18 @@ model SystemConfig {
value String value String
} }
// Phase 1 MVP AI Models
model AiFeedback { model AiFeedback {
id String @id @default(cuid()) id String @id @default(cuid())
noteId String noteId String
userId String? userId String?
feedbackType String // 'thumbs_up' | 'thumbs_down' | 'correction' feedbackType String
feature String // 'title_suggestion' | 'memory_echo' | 'semantic_search' | 'paragraph_refactor' feature String
originalContent String // JSON string of AI-generated content originalContent String
correctedContent String? // User's modified version correctedContent String?
metadata String? // JSON string for additional data (provider, model, timestamp) metadata String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
@@index([noteId]) @@index([noteId])
@@index([userId]) @@index([userId])
@ -223,41 +201,33 @@ model MemoryEchoInsight {
note1Id String note1Id String
note2Id String note2Id String
similarityScore Float similarityScore Float
insight String // AI-generated explanation of the connection insight String
insightDate DateTime @default(now()) insightDate DateTime @default(now())
viewed Boolean @default(false) viewed Boolean @default(false)
feedback String? // 'thumbs_up' | 'thumbs_down' feedback String?
dismissed Boolean @default(false) // User dismissed this connection dismissed Boolean @default(false)
note1 Note @relation("EchoNote1", fields: [note1Id], references: [id], onDelete: Cascade)
note2 Note @relation("EchoNote2", fields: [note2Id], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
note2 Note @relation("EchoNote2", fields: [note2Id], references: [id], onDelete: Cascade)
note1 Note @relation("EchoNote1", fields: [note1Id], references: [id], onDelete: Cascade)
@@unique([userId, insightDate]) @@unique([userId, insightDate])
@@index([userId, insightDate]) @@index([userId, insightDate])
@@index([userId, dismissed]) // For filtering dismissed connections @@index([userId, dismissed])
} }
model UserAISettings { model UserAISettings {
userId String @id userId String @id
// Feature Flags (granular ON/OFF)
titleSuggestions Boolean @default(true) titleSuggestions Boolean @default(true)
semanticSearch Boolean @default(true) semanticSearch Boolean @default(true)
paragraphRefactor Boolean @default(true) paragraphRefactor Boolean @default(true)
memoryEcho Boolean @default(true) memoryEcho Boolean @default(true)
memoryEchoFrequency String @default("daily")
// Configuration aiProvider String @default("auto")
memoryEchoFrequency String @default("daily") // 'daily' | 'weekly' | 'custom' preferredLanguage String @default("auto")
aiProvider String @default("auto") // 'auto' | 'openai' | 'ollama' fontSize String @default("medium")
preferredLanguage String @default("auto") // 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl' demoMode Boolean @default(false)
fontSize String @default("medium") // 'small' | 'medium' | 'large' | 'extra-large'
demoMode Boolean @default(false) // Demo mode for testing Memory Echo
// Relation
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Indexes for analytics
@@index([memoryEcho]) @@index([memoryEcho])
@@index([aiProvider]) @@index([aiProvider])
@@index([memoryEchoFrequency]) @@index([memoryEchoFrequency])

View File

@ -1,6 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
output = "./client-generated" output = "./client-generated"
@ -17,27 +14,24 @@ model User {
name String? name String?
email String @unique email String @unique
emailVerified DateTime? emailVerified DateTime?
password String? // Hashed password password String?
role String @default("USER") // "USER" or "ADMIN" role String @default("USER")
image String? image String?
theme String @default("light") theme String @default("light")
resetToken String? @unique resetToken String? @unique
resetTokenExpiry DateTime? resetTokenExpiry DateTime?
accounts Account[]
sessions Session[]
notes Note[]
labels Label[]
notebooks Notebook[] // NEW: Relation to notebooks
receivedShares NoteShare[] @relation("ReceivedShares")
sentShares NoteShare[] @relation("SentShares")
// Phase 1 AI Relations
aiFeedback AiFeedback[]
aiSettings UserAISettings?
memoryEchoInsights MemoryEchoInsight[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
accounts Account[]
aiFeedback AiFeedback[]
labels Label[]
memoryEchoInsights MemoryEchoInsight[]
notes Note[]
sentShares NoteShare[] @relation("SentShares")
receivedShares NoteShare[] @relation("ReceivedShares")
notebooks Notebook[]
sessions Session[]
aiSettings UserAISettings?
} }
model Account { model Account {
@ -52,10 +46,8 @@ model Account {
scope String? scope String?
id_token String? id_token String?
session_state String? session_state String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([provider, providerAccountId]) @@id([provider, providerAccountId])
@ -65,10 +57,9 @@ model Session {
sessionToken String @unique sessionToken String @unique
userId String userId String
expires DateTime expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model VerificationToken { model VerificationToken {
@ -79,19 +70,18 @@ model VerificationToken {
@@id([identifier, token]) @@id([identifier, token])
} }
// NEW: Notebook model for organizing notes
model Notebook { model Notebook {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
icon String? // Emoji or icon name icon String?
color String? // Hex color for personalization color String?
order Int // Manual order for drag & drop order Int
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
notes Note[] // Notes can belong to a notebook
labels Label[] // Labels are contextual to this notebook
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
labels Label[]
notes Note[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, order]) @@index([userId, order])
@@index([userId]) @@index([userId])
@ -101,17 +91,17 @@ model Label {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
color String @default("gray") color String @default("gray")
notebookId String? // TEMPORARY: Optional for migration, will be required later notebookId String?
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: Cascade) userId String?
notes Note[] // NEW: Many-to-many relation with notes
userId String? // DEPRECATED: Kept for migration, will be removed after migration
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: Cascade)
notes Note[] @relation("LabelToNote")
@@unique([notebookId, name]) // NEW: Labels are unique within a notebook (ignored if notebookId is null) @@unique([notebookId, name])
@@index([notebookId]) @@index([notebookId])
@@index([userId]) // DEPRECATED: Keep for now, remove after migration @@index([userId])
} }
model Note { model Note {
@ -121,69 +111,60 @@ model Note {
color String @default("default") color String @default("default")
isPinned Boolean @default(false) isPinned Boolean @default(false)
isArchived Boolean @default(false) isArchived Boolean @default(false)
type String @default("text") // "text" or "checklist" type String @default("text")
checkItems String? // For checklist items stored as JSON string checkItems String?
labels String? // Array of label names stored as JSON string (DEPRECATED) labels String?
images String? // Array of image URLs stored as JSON string images String?
links String? // Array of link metadata stored as JSON string links String?
reminder DateTime? // Reminder date and time reminder DateTime?
isReminderDone Boolean @default(false) isReminderDone Boolean @default(false)
reminderRecurrence String? // "none", "daily", "weekly", "monthly", "custom" reminderRecurrence String?
reminderLocation String? // Location for location-based reminders reminderLocation String?
isMarkdown Boolean @default(false) // Whether content uses Markdown isMarkdown Boolean @default(false)
size String @default("small") // "small", "medium", "large" size String @default("small")
embedding String? // Vector embeddings stored as JSON string for semantic search embedding String?
sharedWith String? // Array of user IDs (collaborators) stored as JSON string sharedWith String?
userId String? // Owner of the note userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
shares NoteShare[] // All share records for this note
order Int @default(0) order Int @default(0)
notebookId String?
// NEW: Notebook relation (optional - null = "Notes générales" / Inbox)
notebookId String? // NULL = note is in general notes
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: SetNull)
// NEW: Many-to-many relation with labels
labelRelations Label[] // Uses implicit _NoteToLabel junction table
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
autoGenerated Boolean?
// Phase 1 AI Extensions aiProvider String?
autoGenerated Boolean? // True if title/content was AI-generated aiConfidence Int?
aiProvider String? // 'openai' | 'ollama' language String?
aiConfidence Int? // 0-100 (collected Phase 1, UI Phase 3) languageConfidence Float?
language String? // ISO 639-1: 'fr', 'en', 'es', 'de', 'fa' lastAiAnalysis DateTime?
languageConfidence Float? // 0.0-1.0
lastAiAnalysis DateTime? // Timestamp of last AI analysis
// Relations for Phase 1 AI
aiFeedback AiFeedback[] aiFeedback AiFeedback[]
memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2") memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2")
memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
notebook Notebook? @relation(fields: [notebookId], references: [id])
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
shares NoteShare[]
labelRelations Label[] @relation("LabelToNote")
@@index([isPinned]) @@index([isPinned])
@@index([isArchived]) @@index([isArchived])
@@index([order]) @@index([order])
@@index([reminder]) @@index([reminder])
@@index([userId]) @@index([userId])
@@index([userId, notebookId]) // NEW: For filtering notes by notebook @@index([userId, notebookId])
} }
model NoteShare { model NoteShare {
id String @id @default(cuid()) id String @id @default(cuid())
noteId String noteId String
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
userId String userId String
user User @relation("ReceivedShares", fields: [userId], references: [id], onDelete: Cascade) sharedBy String
sharedBy String // User ID who shared the note status String @default("pending")
sharer User @relation("SentShares", fields: [sharedBy], references: [id], onDelete: Cascade) permission String @default("view")
status String @default("pending") // "pending", "accepted", "declined", "removed"
permission String @default("view") // "view", "comment", "edit"
notifiedAt DateTime? notifiedAt DateTime?
respondedAt DateTime? respondedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sharer User @relation("SentShares", fields: [sharedBy], references: [id], onDelete: Cascade)
user User @relation("ReceivedShares", fields: [userId], references: [id], onDelete: Cascade)
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
@@unique([noteId, userId]) @@unique([noteId, userId])
@@index([userId]) @@index([userId])
@ -196,21 +177,18 @@ model SystemConfig {
value String value String
} }
// Phase 1 MVP AI Models
model AiFeedback { model AiFeedback {
id String @id @default(cuid()) id String @id @default(cuid())
noteId String noteId String
userId String? userId String?
feedbackType String // 'thumbs_up' | 'thumbs_down' | 'correction' feedbackType String
feature String // 'title_suggestion' | 'memory_echo' | 'semantic_search' | 'paragraph_refactor' feature String
originalContent String // JSON string of AI-generated content originalContent String
correctedContent String? // User's modified version correctedContent String?
metadata String? // JSON string for additional data (provider, model, timestamp) metadata String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
@@index([noteId]) @@index([noteId])
@@index([userId]) @@index([userId])
@ -223,41 +201,33 @@ model MemoryEchoInsight {
note1Id String note1Id String
note2Id String note2Id String
similarityScore Float similarityScore Float
insight String // AI-generated explanation of the connection insight String
insightDate DateTime @default(now()) insightDate DateTime @default(now())
viewed Boolean @default(false) viewed Boolean @default(false)
feedback String? // 'thumbs_up' | 'thumbs_down' feedback String?
dismissed Boolean @default(false) // User dismissed this connection dismissed Boolean @default(false)
note1 Note @relation("EchoNote1", fields: [note1Id], references: [id], onDelete: Cascade)
note2 Note @relation("EchoNote2", fields: [note2Id], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
note2 Note @relation("EchoNote2", fields: [note2Id], references: [id], onDelete: Cascade)
note1 Note @relation("EchoNote1", fields: [note1Id], references: [id], onDelete: Cascade)
@@unique([userId, insightDate]) @@unique([userId, insightDate])
@@index([userId, insightDate]) @@index([userId, insightDate])
@@index([userId, dismissed]) // For filtering dismissed connections @@index([userId, dismissed])
} }
model UserAISettings { model UserAISettings {
userId String @id userId String @id
// Feature Flags (granular ON/OFF)
titleSuggestions Boolean @default(true) titleSuggestions Boolean @default(true)
semanticSearch Boolean @default(true) semanticSearch Boolean @default(true)
paragraphRefactor Boolean @default(true) paragraphRefactor Boolean @default(true)
memoryEcho Boolean @default(true) memoryEcho Boolean @default(true)
memoryEchoFrequency String @default("daily")
// Configuration aiProvider String @default("auto")
memoryEchoFrequency String @default("daily") // 'daily' | 'weekly' | 'custom' preferredLanguage String @default("auto")
aiProvider String @default("auto") // 'auto' | 'openai' | 'ollama' fontSize String @default("medium")
preferredLanguage String @default("auto") // 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl' demoMode Boolean @default(false)
fontSize String @default("medium") // 'small' | 'medium' | 'large' | 'extra-large'
demoMode Boolean @default(false) // Demo mode for testing Memory Echo
// Relation
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Indexes for analytics
@@index([memoryEcho]) @@index([memoryEcho])
@@index([aiProvider]) @@index([aiProvider])
@@index([memoryEchoFrequency]) @@index([memoryEchoFrequency])

View File

@ -0,0 +1,41 @@
import prisma from '../lib/prisma'
async function debugConfig() {
console.log('=== System Configuration Debug ===\n')
const configs = await prisma.systemConfig.findMany()
console.log(`Total configs in DB: ${configs.length}\n`)
// Group by category
const aiConfigs = configs.filter(c => c.key.startsWith('AI_'))
const ollamaConfigs = configs.filter(c => c.key.includes('OLLAMA'))
const openaiConfigs = configs.filter(c => c.key.includes('OPENAI'))
console.log('=== AI Provider Configs ===')
aiConfigs.forEach(c => {
console.log(`${c.key}: "${c.value}"`)
})
console.log('\n=== Ollama Configs ===')
ollamaConfigs.forEach(c => {
console.log(`${c.key}: "${c.value}"`)
})
console.log('\n=== OpenAI Configs ===')
openaiConfigs.forEach(c => {
console.log(`${c.key}: "${c.value}"`)
})
console.log('\n=== All Configs ===')
configs.forEach(c => {
console.log(`${c.key}: "${c.value}"`)
})
}
debugConfig()
.then(() => process.exit(0))
.catch((err) => {
console.error(err)
process.exit(1)
})