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"
@ -13,31 +10,28 @@ datasource db {
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
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[] createdAt DateTime @default(now())
sessions Session[] updatedAt DateTime @updatedAt
notes Note[] accounts Account[]
labels Label[]
notebooks Notebook[] // NEW: Relation to notebooks
receivedShares NoteShare[] @relation("ReceivedShares")
sentShares NoteShare[] @relation("SentShares")
// Phase 1 AI Relations
aiFeedback AiFeedback[] aiFeedback AiFeedback[]
aiSettings UserAISettings? labels Label[]
memoryEchoInsights MemoryEchoInsight[] memoryEchoInsights MemoryEchoInsight[]
notes Note[]
createdAt DateTime @default(now()) sentShares NoteShare[] @relation("SentShares")
updatedAt DateTime @updatedAt receivedShares NoteShare[] @relation("ReceivedShares")
notebooks Notebook[]
sessions Session[]
aiSettings UserAISettings?
} }
model Account { model Account {
@ -52,11 +46,9 @@ 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
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
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,89 +91,80 @@ 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 {
id String @id @default(cuid()) id String @id @default(cuid())
title String? title String?
content String content String
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) order Int @default(0)
shares NoteShare[] // All share records for this note notebookId String?
order Int @default(0) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// NEW: Notebook relation (optional - null = "Notes générales" / Inbox) autoGenerated Boolean?
notebookId String? // NULL = note is in general notes aiProvider String?
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: SetNull) aiConfidence Int?
language String?
// NEW: Many-to-many relation with labels languageConfidence Float?
labelRelations Label[] // Uses implicit _NoteToLabel junction table lastAiAnalysis DateTime?
aiFeedback AiFeedback[]
createdAt DateTime @default(now()) memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2")
updatedAt DateTime @updatedAt memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
notebook Notebook? @relation(fields: [notebookId], references: [id])
// Phase 1 AI Extensions user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
autoGenerated Boolean? // True if title/content was AI-generated shares NoteShare[]
aiProvider String? // 'openai' | 'ollama' labelRelations Label[] @relation("LabelToNote")
aiConfidence Int? // 0-100 (collected Phase 1, UI Phase 3)
language String? // ISO 639-1: 'fr', 'en', 'es', 'de', 'fa'
languageConfidence Float? // 0.0-1.0
lastAiAnalysis DateTime? // Timestamp of last AI analysis
// Relations for Phase 1 AI
aiFeedback AiFeedback[]
memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2")
@@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())
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade) note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], 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)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
note1 Note @relation("EchoNote1", fields: [note1Id], references: [id], onDelete: Cascade) note2 Note @relation("EchoNote2", fields: [note2Id], references: [id], onDelete: Cascade)
note2 Note @relation("EchoNote2", fields: [note2Id], references: [id], onDelete: Cascade) note1 Note @relation("EchoNote1", fields: [note1Id], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], 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
titleSuggestions Boolean @default(true)
semanticSearch Boolean @default(true)
paragraphRefactor Boolean @default(true)
memoryEcho Boolean @default(true)
memoryEchoFrequency String @default("daily")
aiProvider String @default("auto")
preferredLanguage String @default("auto")
fontSize String @default("medium")
demoMode Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Feature Flags (granular ON/OFF)
titleSuggestions Boolean @default(true)
semanticSearch Boolean @default(true)
paragraphRefactor Boolean @default(true)
memoryEcho Boolean @default(true)
// Configuration
memoryEchoFrequency String @default("daily") // 'daily' | 'weekly' | 'custom'
aiProvider String @default("auto") // 'auto' | 'openai' | 'ollama'
preferredLanguage String @default("auto") // 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
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)
// Indexes for analytics
@@index([memoryEcho]) @@index([memoryEcho])
@@index([aiProvider]) @@index([aiProvider])
@@index([memoryEchoFrequency]) @@index([memoryEchoFrequency])

View File

@ -1,9 +1,6 @@
// 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"
binaryTargets = ["debian-openssl-1.1.x", "native"] binaryTargets = ["debian-openssl-1.1.x", "native"]
} }
@ -13,31 +10,28 @@ datasource db {
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
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[] createdAt DateTime @default(now())
sessions Session[] updatedAt DateTime @updatedAt
notes Note[] accounts Account[]
labels Label[] aiFeedback AiFeedback[]
notebooks Notebook[] // NEW: Relation to notebooks labels Label[]
receivedShares NoteShare[] @relation("ReceivedShares")
sentShares NoteShare[] @relation("SentShares")
// Phase 1 AI Relations
aiFeedback AiFeedback[]
aiSettings UserAISettings?
memoryEchoInsights MemoryEchoInsight[] memoryEchoInsights MemoryEchoInsight[]
notes Note[]
createdAt DateTime @default(now()) sentShares NoteShare[] @relation("SentShares")
updatedAt DateTime @updatedAt receivedShares NoteShare[] @relation("ReceivedShares")
notebooks Notebook[]
sessions Session[]
aiSettings UserAISettings?
} }
model Account { model Account {
@ -52,11 +46,9 @@ 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
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model VerificationToken { model VerificationToken {
@ -79,111 +70,101 @@ 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])
} }
model Label { 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 createdAt DateTime @default(now())
userId String? // DEPRECATED: Kept for migration, will be removed after migration updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: Cascade)
updatedAt DateTime @updatedAt 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 {
id String @id @default(cuid()) id String @id @default(cuid())
title String? title String?
content String content String
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) order Int @default(0)
shares NoteShare[] // All share records for this note notebookId String?
order Int @default(0) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// NEW: Notebook relation (optional - null = "Notes générales" / Inbox) autoGenerated Boolean?
notebookId String? // NULL = note is in general notes aiProvider String?
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: SetNull) aiConfidence Int?
language String?
// NEW: Many-to-many relation with labels languageConfidence Float?
labelRelations Label[] // Uses implicit _NoteToLabel junction table lastAiAnalysis DateTime?
aiFeedback AiFeedback[]
createdAt DateTime @default(now()) memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2")
updatedAt DateTime @updatedAt memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
notebook Notebook? @relation(fields: [notebookId], references: [id])
// Phase 1 AI Extensions user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
autoGenerated Boolean? // True if title/content was AI-generated shares NoteShare[]
aiProvider String? // 'openai' | 'ollama' labelRelations Label[] @relation("LabelToNote")
aiConfidence Int? // 0-100 (collected Phase 1, UI Phase 3)
language String? // ISO 639-1: 'fr', 'en', 'es', 'de', 'fa'
languageConfidence Float? // 0.0-1.0
lastAiAnalysis DateTime? // Timestamp of last AI analysis
// Relations for Phase 1 AI
aiFeedback AiFeedback[]
memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2")
@@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())
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade) note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([noteId]) @@index([noteId])
@@index([userId]) @@index([userId])
@ -218,46 +196,38 @@ model AiFeedback {
} }
model MemoryEchoInsight { model MemoryEchoInsight {
id String @id @default(cuid()) id String @id @default(cuid())
userId String? userId String?
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)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
note1 Note @relation("EchoNote1", fields: [note1Id], references: [id], onDelete: Cascade) note2 Note @relation("EchoNote2", fields: [note2Id], references: [id], onDelete: Cascade)
note2 Note @relation("EchoNote2", fields: [note2Id], references: [id], onDelete: Cascade) note1 Note @relation("EchoNote1", fields: [note1Id], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], 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
titleSuggestions Boolean @default(true)
semanticSearch Boolean @default(true)
paragraphRefactor Boolean @default(true)
memoryEcho Boolean @default(true)
memoryEchoFrequency String @default("daily")
aiProvider String @default("auto")
preferredLanguage String @default("auto")
fontSize String @default("medium")
demoMode Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Feature Flags (granular ON/OFF)
titleSuggestions Boolean @default(true)
semanticSearch Boolean @default(true)
paragraphRefactor Boolean @default(true)
memoryEcho Boolean @default(true)
// Configuration
memoryEchoFrequency String @default("daily") // 'daily' | 'weekly' | 'custom'
aiProvider String @default("auto") // 'auto' | 'openai' | 'ollama'
preferredLanguage String @default("auto") // 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
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)
// 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)
})