Compare commits
7 Commits
19334fdafc
...
cff36d9619
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cff36d9619 | ||
|
|
1c659ce42f | ||
|
|
3c8e347576 | ||
|
|
5cd828c7d7 | ||
|
|
5b652698cc | ||
|
|
d1cda126d8 | ||
|
|
d3c2de2000 |
@@ -35,34 +35,10 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# DATABASE_URL is auto-constructed from PostgreSQL credentials (not in .env.docker)
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-memento}:${POSTGRES_PASSWORD:-memento}@postgres:5432/${POSTGRES_DB:-memento}
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-changethisinproduction}
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
|
||||
- NODE_ENV=production
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Email Configuration (SMTP)
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- SMTP_FROM=${SMTP_FROM:-noreply@memento.app}
|
||||
|
||||
# AI Providers
|
||||
- AI_PROVIDER_TAGS=${AI_PROVIDER_TAGS}
|
||||
- AI_PROVIDER_EMBEDDING=${AI_PROVIDER_EMBEDDING}
|
||||
- AI_PROVIDER_CHAT=${AI_PROVIDER_CHAT}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL}
|
||||
- AI_MODEL_TAGS=${AI_MODEL_TAGS}
|
||||
- AI_MODEL_EMBEDDING=${AI_MODEL_EMBEDDING}
|
||||
- AI_MODEL_CHAT=${AI_MODEL_CHAT}
|
||||
- CUSTOM_OPENAI_API_KEY=${CUSTOM_OPENAI_API_KEY}
|
||||
- CUSTOM_OPENAI_BASE_URL=${CUSTOM_OPENAI_BASE_URL}
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
|
||||
- RESEND_API_KEY=${RESEND_API_KEY}
|
||||
- MCP_SERVER_MODE=${MCP_SERVER_MODE}
|
||||
- MCP_SERVER_URL=${MCP_SERVER_URL}
|
||||
volumes:
|
||||
- uploads-data:/app/public/uploads
|
||||
depends_on:
|
||||
@@ -70,7 +46,7 @@ services:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3000').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -100,10 +76,7 @@ services:
|
||||
# SSE mode exposes port 3001, stdio mode doesn't need ports
|
||||
- "3001:3001"
|
||||
environment:
|
||||
# Mode: 'stdio' (default, for Claude Desktop) or 'sse' (for HTTP/N8N)
|
||||
- MCP_MODE=${MCP_MODE:-stdio}
|
||||
- PORT=${MCP_PORT:-3001}
|
||||
# Database - connect to shared PostgreSQL
|
||||
# DATABASE_URL is auto-constructed from PostgreSQL credentials (not in .env.docker)
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-memento}:${POSTGRES_PASSWORD:-memento}@postgres:5432/${POSTGRES_DB:-memento}
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
@@ -113,7 +86,7 @@ services:
|
||||
networks:
|
||||
- memento-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "if [ \"${MCP_MODE}\" = 'sse' ]; then wget --spider -q http://localhost:3001/ || exit 1; else node -e \"console.log('healthy')\" || exit 1; fi"]
|
||||
test: ["CMD-SHELL", "wget --spider -q http://localhost:3001/ || node -e \"console.log('healthy')\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -6,6 +6,5 @@ node_modules
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
index-sse.js
|
||||
README-SSE.md
|
||||
N8N-CONFIG.md
|
||||
|
||||
@@ -4,20 +4,24 @@ FROM node:20-alpine
|
||||
# Install dependencies
|
||||
WORKDIR /app
|
||||
|
||||
# Install curl and wget for healthchecks
|
||||
RUN apk add --no-cache curl wget
|
||||
# Install curl, wget, and openssl for healthchecks and Prisma runtime
|
||||
RUN apk add --no-cache curl wget openssl libc6-compat
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
# Install all dependencies (including dev for prisma generate)
|
||||
RUN npm ci
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Copy Prisma schema and client
|
||||
# Copy Prisma schema and generate client
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate
|
||||
|
||||
# Prune devDependencies after generating Prisma client
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S mcp && \
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Vérifier les propriétés des notes
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '../memento-note/prisma/client-generated/index.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Supprimer toutes les notes créées
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '../memento-note/prisma/client-generated/index.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Utilise Prisma pour créer les notes
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '../memento-note/prisma/client-generated/index.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Importe le contenu intégral de chaque fichier .md
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '../memento-note/prisma/client-generated/index.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { PrismaClient } from '../memento-note/prisma/client-generated/index.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { PrismaClient } from '../memento-note/prisma/client-generated/index.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { registerTools } from './tools.js';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../node_modules/.prisma/client"
|
||||
provider = "prisma-client-js"
|
||||
output = "../node_modules/.prisma/client"
|
||||
binaryTargets = ["linux-musl-openssl-3.0.x", "native"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = "file:../../memento-note/prisma/dev.db"
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Note {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* node test/performance-test.js
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '../memento-note/prisma/client-generated/index.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Les notes sont créées avec isMarkdown: true
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '../memento-note/prisma/client-generated/index.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Core (required)
|
||||
# -----------------------------------------------------------------------------
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/memento"
|
||||
DATABASE_URL="postgresql://memento:memento@localhost:5432/memento"
|
||||
NEXTAUTH_SECRET="generate-with-openssl-rand-base64-32"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { sendEmail } from '@/lib/mail'
|
||||
import { revalidateTag } from 'next/cache'
|
||||
import { updateTag } from 'next/cache'
|
||||
|
||||
async function checkAdmin() {
|
||||
const session = await auth()
|
||||
@@ -63,7 +63,7 @@ export async function updateSystemConfig(data: Record<string, string>) {
|
||||
await prisma.$transaction(operations)
|
||||
|
||||
// Invalidate cache after update
|
||||
revalidateTag('system-config', '/settings')
|
||||
updateTag('system-config')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import prisma from '@/lib/prisma'
|
||||
import { Note, CheckItem } from '@/lib/types'
|
||||
import { auth } from '@/auth'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { parseNote as parseNoteUtil, cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
|
||||
import { parseNote as parseNoteUtil, cosineSimilarity, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
|
||||
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
||||
import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup'
|
||||
@@ -50,22 +50,9 @@ const NOTE_LIST_SELECT = {
|
||||
// embedding: false — volontairement omis (économise ~6KB JSON/note)
|
||||
} as const
|
||||
|
||||
// Wrapper for parseNote that validates embeddings
|
||||
// Wrapper for parseNote (embedding validation removed - embeddings are now in NoteEmbedding table)
|
||||
function parseNote(dbNote: any): Note {
|
||||
const note = parseNoteUtil(dbNote)
|
||||
|
||||
// Validate embedding if present
|
||||
if (note.embedding && Array.isArray(note.embedding)) {
|
||||
const validation = validateEmbedding(note.embedding)
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
...note,
|
||||
embedding: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return note
|
||||
return parseNoteUtil(dbNote)
|
||||
}
|
||||
|
||||
// Helper to get hash color for labels (copied from utils)
|
||||
@@ -486,7 +473,8 @@ export async function createNote(data: {
|
||||
;(async () => {
|
||||
try {
|
||||
// Background task 1: Generate embedding
|
||||
const provider = getAIProvider(await getSystemConfig())
|
||||
const bgConfig = await getSystemConfig()
|
||||
const provider = getAIProvider(bgConfig)
|
||||
const embedding = await provider.getEmbeddings(content)
|
||||
if (embedding) {
|
||||
await prisma.noteEmbedding.upsert({
|
||||
@@ -505,13 +493,33 @@ export async function createNote(data: {
|
||||
const autoLabelingEnabled = await getConfigBoolean('AUTO_LABELING_ENABLED', true)
|
||||
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
|
||||
|
||||
console.log('[BG] Auto-labeling check: enabled=', autoLabelingEnabled, 'confidence=', autoLabelingConfidence, 'notebookId=', notebookId)
|
||||
|
||||
if (autoLabelingEnabled) {
|
||||
// Detect user's language from their existing notes for localized prompts
|
||||
let userLang = 'en'
|
||||
try {
|
||||
const langResult = await prisma.note.groupBy({
|
||||
by: ['language'],
|
||||
where: { userId, language: { not: null } },
|
||||
_count: true,
|
||||
orderBy: { _count: { language: 'desc' } },
|
||||
take: 1,
|
||||
})
|
||||
if (langResult.length > 0 && langResult[0].language) {
|
||||
userLang = langResult[0].language
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||
content,
|
||||
notebookId,
|
||||
userId
|
||||
userId,
|
||||
userLang
|
||||
)
|
||||
|
||||
console.log('[BG] Auto-labeling suggestions:', suggestions.length, suggestions.map(s => s.label))
|
||||
|
||||
const appliedLabels = suggestions
|
||||
.filter(s => s.confidence >= autoLabelingConfidence)
|
||||
.map(s => s.label)
|
||||
@@ -530,6 +538,8 @@ export async function createNote(data: {
|
||||
} catch (error) {
|
||||
console.error('[BG] Auto-labeling failed:', error)
|
||||
}
|
||||
} else {
|
||||
console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId)
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -556,6 +566,7 @@ export async function updateNote(id: string, data: {
|
||||
isMarkdown?: boolean
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
autoGenerated?: boolean | null
|
||||
aiProvider?: string | null
|
||||
notebookId?: string | null
|
||||
}, options?: { skipContentTimestamp?: boolean; skipRevalidation?: boolean }) {
|
||||
const session = await auth();
|
||||
|
||||
@@ -15,7 +15,7 @@ const RegisterSchema = z.object({
|
||||
export async function register(prevState: string | undefined, formData: FormData) {
|
||||
// Check if registration is allowed
|
||||
const config = await getSystemConfig();
|
||||
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
|
||||
const allowRegister = config.ALLOW_REGISTRATION !== 'false' || process.env.ALLOW_REGISTRATION !== 'false';
|
||||
|
||||
if (!allowRegister) {
|
||||
return 'Registration is currently disabled by the administrator.';
|
||||
|
||||
@@ -18,6 +18,14 @@ export async function fetchLinkMetadata(url: string): Promise<LinkMetadata | nul
|
||||
targetUrl = 'https://' + url;
|
||||
}
|
||||
|
||||
// SSRF protection: block internal/private IPs
|
||||
const parsed = new URL(targetUrl)
|
||||
const hostname = parsed.hostname.toLowerCase()
|
||||
const blockedHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1', '169.254.169.254']
|
||||
if (blockedHosts.includes(hostname)) return null
|
||||
if (hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('192.168.') || hostname.startsWith('fc') || hostname.startsWith('fd')) return null
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
headers: {
|
||||
// Use a real browser User-Agent to avoid 403 Forbidden from strict sites
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { LABEL_COLORS } from '@/lib/types';
|
||||
import { auth } from '@/auth';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const labels = await prisma.label.findMany();
|
||||
const colors = Object.keys(LABEL_COLORS).filter(c => c !== 'gray'); // Exclude gray to force colors
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { auth } from '@/auth';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
// 1. Get all notes
|
||||
const notes = await prisma.note.findMany({
|
||||
select: { labels: true }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service';
|
||||
import { getAIProvider } from '@/lib/ai/factory';
|
||||
import { getTagsProvider } from '@/lib/ai/factory';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// Otherwise, use legacy auto-tagging (generates new tags)
|
||||
const config = await getSystemConfig();
|
||||
const provider = getAIProvider(config);
|
||||
const provider = getTagsProvider(config);
|
||||
const tags = await provider.generateTags(content, language);
|
||||
|
||||
return NextResponse.json({ tags });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)
|
||||
const hasNonLatinChars = /[\u0400-\u04FF\u0600-\u06FF\u4E00-\u9FFF\u0E00-\u0E7F]/.test(content)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
const provider = getChatProvider(config)
|
||||
|
||||
// Detect language from text
|
||||
const hasFrench = /[àâäéèêëïîôùûüÿç]/i.test(text)
|
||||
|
||||
@@ -94,7 +94,14 @@ export async function PUT(
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const updateData: any = { ...body }
|
||||
// Whitelist allowed fields to prevent mass assignment
|
||||
const allowedFields = ['title', 'content', 'color', 'isPinned', 'isArchived', 'type', 'isMarkdown', 'size', 'notebookId']
|
||||
const updateData: Record<string, any> = {}
|
||||
for (const key of allowedFields) {
|
||||
if (key in body) {
|
||||
updateData[key] = body[key]
|
||||
}
|
||||
}
|
||||
|
||||
if ('checkItems' in body) {
|
||||
updateData.checkItems = body.checkItems ?? null
|
||||
|
||||
@@ -2,9 +2,18 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File
|
||||
|
||||
@@ -15,8 +24,20 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'Invalid file type' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const filename = `${randomUUID()}${path.extname(file.name)}`
|
||||
const ext = path.extname(file.name).toLowerCase()
|
||||
if (!['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
|
||||
return NextResponse.json({ error: 'Invalid file extension' }, { status: 400 })
|
||||
}
|
||||
const filename = `${randomUUID()}${ext}`
|
||||
|
||||
// Ensure directory exists
|
||||
const uploadDir = path.join(process.cwd(), 'public/uploads/notes')
|
||||
|
||||
@@ -14,11 +14,17 @@ export const authConfig = {
|
||||
authorized({ auth, request: { nextUrl } }) {
|
||||
const isLoggedIn = !!auth?.user;
|
||||
const isAdmin = (auth?.user as any)?.role === 'ADMIN';
|
||||
const isDashboardPage = nextUrl.pathname === '/' ||
|
||||
nextUrl.pathname.startsWith('/reminders') ||
|
||||
nextUrl.pathname.startsWith('/archive') ||
|
||||
const isDashboardPage = nextUrl.pathname === '/' ||
|
||||
nextUrl.pathname.startsWith('/reminders') ||
|
||||
nextUrl.pathname.startsWith('/archive') ||
|
||||
nextUrl.pathname.startsWith('/trash') ||
|
||||
nextUrl.pathname.startsWith('/settings');
|
||||
nextUrl.pathname.startsWith('/settings') ||
|
||||
nextUrl.pathname.startsWith('/lab') ||
|
||||
nextUrl.pathname.startsWith('/agents') ||
|
||||
nextUrl.pathname.startsWith('/chat') ||
|
||||
nextUrl.pathname.startsWith('/canvas') ||
|
||||
nextUrl.pathname.startsWith('/notebooks') ||
|
||||
nextUrl.pathname.startsWith('/note/');
|
||||
const isAdminPage = nextUrl.pathname.startsWith('/admin');
|
||||
|
||||
if (isAdminPage) {
|
||||
|
||||
@@ -21,12 +21,6 @@ export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, on
|
||||
// On filtre pour l'affichage conditionnel global, mais on garde les tags ajoutés pour l'affichage visuel "validé"
|
||||
const visibleSuggestions = suggestions;
|
||||
|
||||
// Show help message if not analyzing and no suggestions (but don't return null)
|
||||
const isEmpty = !isAnalyzing && visibleSuggestions.length === 0;
|
||||
|
||||
// FIX: Never return null, always show something (either tags, analyzer, or help message)
|
||||
// This ensures the help message "Tapez du contenu..." is always shown when needed
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-2 mt-2 min-h-[24px] transition-all duration-500", className)}>
|
||||
|
||||
@@ -47,7 +41,7 @@ export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, on
|
||||
const isAdded = addedTags.some(t => t.toLowerCase() === suggestion.tag.toLowerCase());
|
||||
const colorName = getHashColor(suggestion.tag);
|
||||
const colorClasses = LABEL_COLORS[colorName];
|
||||
const isNewLabel = (suggestion as any).isNewLabel; // Check if this is a new label suggestion
|
||||
const isNewLabel = suggestion.isNewLabel;
|
||||
|
||||
if (isAdded) {
|
||||
// Tag déjà ajouté : on l'affiche en mode "confirmé" statique pour ne pas perdre le focus
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'
|
||||
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types/types'
|
||||
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
|
||||
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
|
||||
import '@excalidraw/excalidraw/index.css'
|
||||
|
||||
// Dynamic import with SSR disabled is REQUIRED for Excalidraw due to window dependencies
|
||||
|
||||
@@ -36,6 +36,7 @@ import { EditorImages } from './editor-images'
|
||||
import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
||||
import { GhostTags } from './ghost-tags'
|
||||
import { TitleSuggestions } from './title-suggestions'
|
||||
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
|
||||
import { EditorConnectionsSection } from './editor-connections-section'
|
||||
import { ComparisonModal } from './comparison-modal'
|
||||
import { FusionModal } from './fusion-modal'
|
||||
@@ -76,12 +77,11 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
setContextNotebookId(note.notebookId || null)
|
||||
}, [note.notebookId, setContextNotebookId])
|
||||
|
||||
// Auto-tagging hook - use note.content from props instead of local state
|
||||
// This ensures triggering when notebookId changes (e.g., after moving note to notebook)
|
||||
// Auto-tagging hook - use local state for live suggestions as user types
|
||||
const { suggestions, isAnalyzing } = useAutoTagging({
|
||||
content: note.type === 'text' ? (note.content || '') : '',
|
||||
notebookId: note.notebookId, // Pass notebookId for contextual label suggestions (IA2)
|
||||
enabled: note.type === 'text' // Auto-tagging only for text notes
|
||||
content: note.type === 'text' ? content : '',
|
||||
notebookId: note.notebookId,
|
||||
enabled: note.type === 'text'
|
||||
})
|
||||
|
||||
// Reminder state
|
||||
@@ -95,7 +95,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
|
||||
// Title suggestions state
|
||||
const [titleSuggestions, setTitleSuggestions] = useState<any[]>([])
|
||||
const [titleSuggestions, setTitleSuggestions] = useState<TitleSuggestion[]>([])
|
||||
const [isGeneratingTitles, setIsGeneratingTitles] = useState(false)
|
||||
|
||||
// Reformulation state
|
||||
|
||||
@@ -290,26 +290,41 @@ export function NoteInlineEditor({
|
||||
|
||||
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
|
||||
const handleTogglePin = () => {
|
||||
const prev = note.isPinned
|
||||
startTransition(async () => {
|
||||
// Optimitistic update
|
||||
onChange?.(note.id, { isPinned: !note.isPinned })
|
||||
// Call with skipRevalidation to avoid server layout refresh interfering with optimistic state
|
||||
await updateNote(note.id, { isPinned: !note.isPinned }, { skipRevalidation: true })
|
||||
toast.success(note.isPinned ? t('notes.unpinned') || 'Désépinglée' : t('notes.pinned') || 'Épinglée')
|
||||
onChange?.(note.id, { isPinned: !prev })
|
||||
try {
|
||||
await updateNote(note.id, { isPinned: !prev }, { skipRevalidation: true })
|
||||
toast.success(prev ? t('notes.unpinned') || 'Désépinglée' : t('notes.pinned') || 'Épinglée')
|
||||
} catch {
|
||||
onChange?.(note.id, { isPinned: prev })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleArchive = () => {
|
||||
startTransition(async () => {
|
||||
onArchive?.(note.id)
|
||||
await updateNote(note.id, { isArchived: !note.isArchived }, { skipRevalidation: true })
|
||||
try {
|
||||
await updateNote(note.id, { isArchived: !note.isArchived }, { skipRevalidation: true })
|
||||
} catch {
|
||||
// Cannot easily revert since onArchive removes from list
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleColorChange = (color: string) => {
|
||||
const prev = color
|
||||
startTransition(async () => {
|
||||
onChange?.(note.id, { color })
|
||||
await updateNote(note.id, { color }, { skipRevalidation: true })
|
||||
try {
|
||||
await updateNote(note.id, { color }, { skipRevalidation: true })
|
||||
} catch {
|
||||
onChange?.(note.id, { color: prev })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ interface NoteInputProps {
|
||||
onNoteCreated?: (note: Note) => void
|
||||
defaultExpanded?: boolean
|
||||
forceExpanded?: boolean
|
||||
/** Mode onglets : occupe toute la largeur du contenu principal (plus de carte étroite centrée) */
|
||||
/** Tab mode: takes full width of main content (no narrow centered card) */
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export function NoteInput({
|
||||
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
|
||||
|
||||
const handleSelectGhostTag = async (tag: string) => {
|
||||
// Vérification insensible à la casse
|
||||
// Case-insensitive check
|
||||
const tagExists = selectedLabels.some(l => l.toLowerCase() === tag.toLowerCase())
|
||||
|
||||
if (!tagExists) {
|
||||
@@ -145,7 +145,7 @@ export function NoteInput({
|
||||
try {
|
||||
await addLabel(tag)
|
||||
} catch (err) {
|
||||
console.error('Erreur création label auto:', err)
|
||||
console.error('Error creating auto-label:', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,23 +3,23 @@ version: '3.8'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: keep-postgres
|
||||
container_name: memento-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: keepnotes
|
||||
POSTGRES_PASSWORD: keepnotes
|
||||
POSTGRES_DB: keepnotes
|
||||
POSTGRES_USER: memento
|
||||
POSTGRES_PASSWORD: memento
|
||||
POSTGRES_DB: memento
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U keepnotes"]
|
||||
test: ["CMD-SHELL", "pg_isready -U memento"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- keep-network
|
||||
- memento-network
|
||||
|
||||
memento-note:
|
||||
build:
|
||||
@@ -32,7 +32,7 @@ services:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# Database
|
||||
- DATABASE_URL=postgresql://keepnotes:keepnotes@postgres:5432/keepnotes
|
||||
- DATABASE_URL=postgresql://memento:memento@postgres:5432/memento
|
||||
- NODE_ENV=production
|
||||
|
||||
# Application (IMPORTANT: Change these!)
|
||||
@@ -58,7 +58,7 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- keep-network
|
||||
- memento-network
|
||||
# Optional: Resource limits for Proxmox VM
|
||||
deploy:
|
||||
resources:
|
||||
@@ -87,7 +87,7 @@ services:
|
||||
# volumes:
|
||||
# - ollama-data:/root/.ollama
|
||||
# networks:
|
||||
# - keep-network
|
||||
# - memento-network
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
@@ -98,7 +98,7 @@ services:
|
||||
# memory: 4G
|
||||
|
||||
networks:
|
||||
keep-network:
|
||||
memento-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useDebounce } from './use-debounce';
|
||||
import { TagSuggestion } from '@/lib/ai/types';
|
||||
|
||||
@@ -20,20 +20,20 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
|
||||
|
||||
// Track previous notebookId to detect when note is moved to a notebook
|
||||
const previousNotebookId = useRef<string | null | undefined>(notebookId);
|
||||
// AbortController for cancelling in-flight requests
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const analyzeContent = async (contentToAnalyze: string) => {
|
||||
// CRITICAL: Don't suggest labels in "General Notes" (notebookId is null)
|
||||
// Labels should ONLY appear within notebooks, not in the general notes section
|
||||
if (!notebookId) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const analyzeContent = useCallback(async (contentToAnalyze: string, currentNotebookId?: string | null, currentLanguage?: string) => {
|
||||
if (!contentToAnalyze || contentToAnalyze.length < 10) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel previous request
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setIsAnalyzing(true);
|
||||
setError(null);
|
||||
|
||||
@@ -43,23 +43,29 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: contentToAnalyze,
|
||||
notebookId: notebookId || undefined,
|
||||
language: language || document.documentElement.lang || 'en',
|
||||
notebookId: currentNotebookId || undefined,
|
||||
language: currentLanguage || document.documentElement.lang || 'en',
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Error during analysis');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSuggestions(data.tags || []);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') return;
|
||||
setError('Failed to generate suggestions');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
if (!controller.signal.aborted) {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Trigger on content change
|
||||
useEffect(() => {
|
||||
@@ -68,24 +74,27 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
|
||||
return;
|
||||
}
|
||||
|
||||
analyzeContent(debouncedContent);
|
||||
}, [debouncedContent, enabled]);
|
||||
analyzeContent(debouncedContent, notebookId, language);
|
||||
}, [debouncedContent, enabled, notebookId, language, analyzeContent]);
|
||||
|
||||
// CRITICAL: Also trigger when notebookId changes from null/undefined to a value (note moved to notebook)
|
||||
// Trigger when notebookId changes from null/undefined to a value
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const prev = previousNotebookId.current;
|
||||
previousNotebookId.current = notebookId;
|
||||
|
||||
// Detect when note is moved FROM "General Notes" (null) TO a notebook
|
||||
const wasMovedToNotebook = (prev === null || prev === undefined) && notebookId;
|
||||
|
||||
if (wasMovedToNotebook && content && content.length >= 10) {
|
||||
// Use current content immediately (no debounce) when moving to notebook
|
||||
analyzeContent(content);
|
||||
analyzeContent(content, notebookId, language);
|
||||
}
|
||||
}, [notebookId, content, enabled]);
|
||||
}, [notebookId, content, enabled, language, analyzeContent]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => { abortRef.current?.abort(); };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useDebounce } from './use-debounce'
|
||||
|
||||
export interface TitleSuggestion {
|
||||
@@ -16,6 +16,7 @@ export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggest
|
||||
const [suggestions, setSuggestions] = useState<TitleSuggestion[]>([])
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
// Debounce content by 2s to avoid excessive API calls
|
||||
const debouncedContent = useDebounce(content, 2000)
|
||||
@@ -34,6 +35,10 @@ export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggest
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel previous request
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
const generateTitles = async () => {
|
||||
setIsAnalyzing(true)
|
||||
@@ -44,8 +49,10 @@ export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggest
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: debouncedContent }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
@@ -54,17 +61,25 @@ export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggest
|
||||
|
||||
const data = await response.json()
|
||||
setSuggestions(data.suggestions || [])
|
||||
} catch (err) {
|
||||
console.error('❌ Title suggestions error:', err)
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') return
|
||||
console.error('Title suggestions error:', err)
|
||||
setError('Failed to generate title suggestions')
|
||||
} finally {
|
||||
setIsAnalyzing(false)
|
||||
if (!controller.signal.aborted) {
|
||||
setIsAnalyzing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generateTitles()
|
||||
}, [debouncedContent, enabled])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => { abortRef.current?.abort(); };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
isAnalyzing,
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { deepEqual } from '@/lib/utils'
|
||||
|
||||
export interface UndoRedoState<T> {
|
||||
past: T[]
|
||||
present: T
|
||||
future: T[]
|
||||
}
|
||||
|
||||
interface UseUndoRedoReturn<T> {
|
||||
state: T
|
||||
setState: (newState: T | ((prev: T) => T)) => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
const MAX_HISTORY_SIZE = 50
|
||||
|
||||
export function useUndoRedo<T>(initialState: T): UseUndoRedoReturn<T> {
|
||||
const [history, setHistory] = useState<UndoRedoState<T>>({
|
||||
past: [],
|
||||
present: initialState,
|
||||
future: [],
|
||||
})
|
||||
|
||||
// Track if we're in an undo/redo operation to prevent adding to history
|
||||
const isUndoRedoAction = useRef(false)
|
||||
|
||||
const setState = useCallback((newState: T | ((prev: T) => T)) => {
|
||||
// Skip if this is an undo/redo action
|
||||
if (isUndoRedoAction.current) {
|
||||
isUndoRedoAction.current = false
|
||||
return
|
||||
}
|
||||
|
||||
setHistory((currentHistory) => {
|
||||
const resolvedNewState =
|
||||
typeof newState === 'function'
|
||||
? (newState as (prev: T) => T)(currentHistory.present)
|
||||
: newState
|
||||
|
||||
// Don't add to history if state hasn't changed
|
||||
if (deepEqual(resolvedNewState, currentHistory.present)) {
|
||||
return currentHistory
|
||||
}
|
||||
|
||||
const newPast = [...currentHistory.past, currentHistory.present]
|
||||
|
||||
// Limit history size
|
||||
if (newPast.length > MAX_HISTORY_SIZE) {
|
||||
newPast.shift()
|
||||
}
|
||||
|
||||
return {
|
||||
past: newPast,
|
||||
present: resolvedNewState,
|
||||
future: [], // Clear future on new action
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const undo = useCallback(() => {
|
||||
setHistory((currentHistory) => {
|
||||
if (currentHistory.past.length === 0) return currentHistory
|
||||
|
||||
const previous = currentHistory.past[currentHistory.past.length - 1]
|
||||
const newPast = currentHistory.past.slice(0, currentHistory.past.length - 1)
|
||||
|
||||
isUndoRedoAction.current = true
|
||||
|
||||
return {
|
||||
past: newPast,
|
||||
present: previous,
|
||||
future: [currentHistory.present, ...currentHistory.future],
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const redo = useCallback(() => {
|
||||
setHistory((currentHistory) => {
|
||||
if (currentHistory.future.length === 0) return currentHistory
|
||||
|
||||
const next = currentHistory.future[0]
|
||||
const newFuture = currentHistory.future.slice(1)
|
||||
|
||||
isUndoRedoAction.current = true
|
||||
|
||||
return {
|
||||
past: [...currentHistory.past, currentHistory.present],
|
||||
present: next,
|
||||
future: newFuture,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setHistory({
|
||||
past: [],
|
||||
present: initialState,
|
||||
future: [],
|
||||
})
|
||||
}, [initialState])
|
||||
|
||||
return {
|
||||
state: history.present,
|
||||
setState,
|
||||
undo,
|
||||
redo,
|
||||
canUndo: history.past.length > 0,
|
||||
canRedo: history.future.length > 0,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { OllamaProvider } from './providers/ollama';
|
||||
import { CustomOpenAIProvider } from './providers/custom-openai';
|
||||
import { AIProvider } from './types';
|
||||
|
||||
type ProviderType = 'ollama' | 'openai' | 'custom';
|
||||
type ProviderType = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter';
|
||||
|
||||
function createOllamaProvider(config: Record<string, string>, modelName: string, embeddingModelName: string): OllamaProvider {
|
||||
let baseUrl = config?.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL
|
||||
@@ -50,6 +50,18 @@ function createCustomOpenAIProvider(config: Record<string, string>, modelName: s
|
||||
return new CustomOpenAIProvider(apiKey, baseUrl, modelName, embeddingModelName);
|
||||
}
|
||||
|
||||
function createDeepSeekProvider(config: Record<string, string>, modelName: string, embeddingModelName: string): CustomOpenAIProvider {
|
||||
const apiKey = config?.DEEPSEEK_API_KEY || config?.CUSTOM_OPENAI_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.CUSTOM_OPENAI_API_KEY || '';
|
||||
if (!apiKey) throw new Error('DEEPSEEK_API_KEY is required when using DeepSeek provider');
|
||||
return new CustomOpenAIProvider(apiKey, 'https://api.deepseek.com/v1', modelName, embeddingModelName);
|
||||
}
|
||||
|
||||
function createOpenRouterProvider(config: Record<string, string>, modelName: string, embeddingModelName: string): CustomOpenAIProvider {
|
||||
const apiKey = config?.OPENROUTER_API_KEY || config?.CUSTOM_OPENAI_API_KEY || process.env.OPENROUTER_API_KEY || process.env.CUSTOM_OPENAI_API_KEY || '';
|
||||
if (!apiKey) throw new Error('OPENROUTER_API_KEY is required when using OpenRouter provider');
|
||||
return new CustomOpenAIProvider(apiKey, 'https://openrouter.ai/api/v1', modelName, embeddingModelName);
|
||||
}
|
||||
|
||||
function getProviderInstance(providerType: ProviderType, config: Record<string, string>, modelName: string, embeddingModelName: string): AIProvider {
|
||||
switch (providerType) {
|
||||
case 'ollama':
|
||||
@@ -58,6 +70,10 @@ function getProviderInstance(providerType: ProviderType, config: Record<string,
|
||||
return createOpenAIProvider(config, modelName, embeddingModelName);
|
||||
case 'custom':
|
||||
return createCustomOpenAIProvider(config, modelName, embeddingModelName);
|
||||
case 'deepseek':
|
||||
return createDeepSeekProvider(config, modelName, embeddingModelName);
|
||||
case 'openrouter':
|
||||
return createOpenRouterProvider(config, modelName, embeddingModelName);
|
||||
default:
|
||||
return createOllamaProvider(config, modelName, embeddingModelName);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getChatProvider, getAIProvider } from '@/lib/ai/factory'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { rssService } from './rss.service'
|
||||
import { toolRegistry } from '../tools'
|
||||
import { sendEmail } from '@/lib/mail'
|
||||
@@ -56,7 +56,7 @@ const TITLE_PROMPTS: Record<Lang, string> = {
|
||||
async function generateTitle(content: string, agentName: string, lang: Lang): Promise<string> {
|
||||
try {
|
||||
const sysConfig = await getSystemConfig()
|
||||
const provider = getAIProvider(sysConfig)
|
||||
const provider = getChatProvider(sysConfig)
|
||||
const prompt = `${TITLE_PROMPTS[lang]}${content.substring(0, 800)}`
|
||||
const title = await provider.generateText(prompt)
|
||||
return title.trim().replace(/^["']|["']$/g, '').substring(0, 80)
|
||||
@@ -297,7 +297,7 @@ async function executeScraperAgent(
|
||||
return { success: false, actionId, error: msg }
|
||||
}
|
||||
|
||||
const provider = getAIProvider(sysConfig)
|
||||
const provider = getChatProvider(sysConfig)
|
||||
const combinedContent = scrapedParts.join('\n\n---\n\n')
|
||||
|
||||
// Extract images BEFORE generating summary so AI can embed them
|
||||
@@ -368,7 +368,7 @@ async function executeResearcherAgent(
|
||||
const topic = agent.description || agent.name
|
||||
|
||||
const sysConfig = await getSystemConfig()
|
||||
const provider = getAIProvider(sysConfig)
|
||||
const provider = getChatProvider(sysConfig)
|
||||
|
||||
const queryPrompt = lang === 'fr'
|
||||
? `Tu es un assistant de recherche. Pour le sujet suivant, génère 3 requêtes de recherche web pertinentes (une par ligne, sans numérotation):\n\nSujet: ${topic}`
|
||||
@@ -503,7 +503,7 @@ async function executeMonitorAgent(
|
||||
}
|
||||
|
||||
const sysConfig = await getSystemConfig()
|
||||
const provider = getAIProvider(sysConfig)
|
||||
const provider = getChatProvider(sysConfig)
|
||||
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
|
||||
const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
|
||||
|
||||
@@ -562,7 +562,7 @@ async function executeCustomAgent(
|
||||
lang: Lang
|
||||
): Promise<AgentExecutionResult> {
|
||||
const sysConfig = await getSystemConfig()
|
||||
const provider = getAIProvider(sysConfig)
|
||||
const provider = getChatProvider(sysConfig)
|
||||
|
||||
let inputContent = ''
|
||||
const urls: string[] = agent.sourceUrls ? JSON.parse(agent.sourceUrls) : []
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export interface SuggestedLabel {
|
||||
@@ -106,7 +106,7 @@ export class AutoLabelCreationService {
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
const provider = getTagsProvider(config)
|
||||
const response = await provider.generateText(prompt)
|
||||
|
||||
// Parse AI response
|
||||
|
||||
@@ -29,18 +29,18 @@ export interface OrganizationPlan {
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for batch organizing notes from "Notes générales" into notebooks
|
||||
* Service for batch organizing notes from "General Notes" into notebooks
|
||||
* (Story 5.3 - IA3)
|
||||
*/
|
||||
export class BatchOrganizationService {
|
||||
/**
|
||||
* Analyze all notes in "Notes générales" and create an organization plan
|
||||
* Analyze all notes in "General Notes" and create an organization plan
|
||||
* @param userId - User ID
|
||||
* @param language - User's preferred language (default: 'en')
|
||||
* @returns Organization plan with notebook assignments
|
||||
*/
|
||||
async createOrganizationPlan(userId: string, language: string = 'en'): Promise<OrganizationPlan> {
|
||||
// 1. Get all notes without notebook (Inbox/Notes générales)
|
||||
// 1. Get all notes without notebook (Inbox/General Notes)
|
||||
const notesWithoutNotebook = await prisma.note.findMany({
|
||||
where: {
|
||||
userId,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export interface LabelSuggestion {
|
||||
@@ -77,7 +77,7 @@ export class ContextualAutoTagService {
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
// Use generateText with JSON response
|
||||
const response = await provider.generateText(prompt)
|
||||
@@ -161,7 +161,7 @@ export class ContextualAutoTagService {
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
// Use generateText with JSON response
|
||||
const response = await provider.generateText(prompt)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getAIProvider } from '../factory'
|
||||
import { getAIProvider, getChatProvider } from '../factory'
|
||||
import { cosineSimilarity } from '@/lib/utils'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import prisma from '@/lib/prisma'
|
||||
@@ -216,7 +216,7 @@ export class MemoryEchoService {
|
||||
): Promise<string> {
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
const provider = getChatProvider(config)
|
||||
|
||||
const note1Desc = note1Title || 'Untitled note'
|
||||
const note2Desc = note2Title || 'Untitled note'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import type { Notebook } from '@/lib/types'
|
||||
|
||||
@@ -33,7 +33,7 @@ export class NotebookSuggestionService {
|
||||
// 3. Call AI
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const response = await provider.generateText(prompt)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export interface NotebookSummary {
|
||||
@@ -127,7 +127,7 @@ ${content}...`
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
const provider = getChatProvider(config)
|
||||
const summary = await provider.generateText(prompt)
|
||||
return summary.trim()
|
||||
} catch (error) {
|
||||
|
||||
@@ -50,12 +50,12 @@ export class ScrapeService {
|
||||
}
|
||||
|
||||
return {
|
||||
title: article.title,
|
||||
content: article.content, // HTML fragment from readability
|
||||
textContent: article.textContent, // Clean text
|
||||
excerpt: article.excerpt,
|
||||
byline: article.byline,
|
||||
siteName: article.siteName,
|
||||
title: article.title ?? '',
|
||||
content: article.content ?? '', // HTML fragment from readability
|
||||
textContent: article.textContent ?? '', // Clean text
|
||||
excerpt: article.excerpt ?? '',
|
||||
byline: article.byline ?? '',
|
||||
siteName: article.siteName ?? '',
|
||||
url: targetUrl
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,20 +3,10 @@
|
||||
* Generates intelligent title suggestions based on note content
|
||||
*/
|
||||
|
||||
import { createOpenAI } from '@ai-sdk/openai'
|
||||
import { generateText } from 'ai'
|
||||
import { LanguageDetectionService } from './language-detection.service'
|
||||
|
||||
// Helper to get AI model for text generation
|
||||
function getTextGenerationModel() {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error('OPENAI_API_KEY not configured for title generation')
|
||||
}
|
||||
|
||||
const openai = createOpenAI({ apiKey })
|
||||
return openai('gpt-4o-mini')
|
||||
}
|
||||
import { getTagsProvider } from '../factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export interface TitleSuggestion {
|
||||
title: string
|
||||
@@ -40,7 +30,9 @@ export class TitleSuggestionService {
|
||||
const { language: contentLanguage } = await this.languageDetection.detectLanguage(noteContent)
|
||||
|
||||
try {
|
||||
const model = getTextGenerationModel()
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
const model = provider.getModel()
|
||||
|
||||
// System prompt - explains what to do
|
||||
const systemPrompt = `You are an expert title generator for a note-taking application.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
export interface TagSuggestion {
|
||||
tag: string;
|
||||
confidence: number;
|
||||
reasoning?: string;
|
||||
isNewLabel?: boolean;
|
||||
}
|
||||
|
||||
export interface TitleSuggestion {
|
||||
title: string;
|
||||
confidence: number;
|
||||
reasoning?: string;
|
||||
}
|
||||
|
||||
export interface ToolUseOptions {
|
||||
@@ -64,12 +67,4 @@ export interface AIProvider {
|
||||
generateWithTools(options: ToolUseOptions): Promise<ToolCallResult>;
|
||||
}
|
||||
|
||||
export type AIProviderType = 'openai' | 'ollama';
|
||||
|
||||
export interface AIConfig {
|
||||
provider: AIProviderType;
|
||||
apiKey?: string;
|
||||
baseUrl?: string; // Used for Ollama
|
||||
model?: string;
|
||||
embeddingModel?: string;
|
||||
}
|
||||
export type AIProviderType = 'openai' | 'ollama' | 'custom' | 'deepseek' | 'openrouter';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import prisma from './prisma'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
|
||||
export async function getSystemConfig() {
|
||||
try {
|
||||
|
||||
@@ -61,8 +61,9 @@ async function sendViaResend(apiKey: string, { to, subject, html, attachments }:
|
||||
const { Resend } = await import('resend');
|
||||
const resend = new Resend(apiKey);
|
||||
|
||||
const from = process.env.NEXTAUTH_URL
|
||||
? `Memento <noreply@${new URL(process.env.NEXTAUTH_URL).hostname}>`
|
||||
const hostname = process.env.NEXTAUTH_URL ? new URL(process.env.NEXTAUTH_URL).hostname : '';
|
||||
const from = hostname && hostname !== 'localhost'
|
||||
? `Memento <noreply@${hostname}>`
|
||||
: 'Memento <onboarding@resend.dev>';
|
||||
|
||||
// Resend supports attachments with inline content
|
||||
|
||||
@@ -4,7 +4,7 @@ const prismaClientSingleton = () => {
|
||||
return new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: process.env.DATABASE_URL || "file:/Users/sepehr/dev/Momento/memento-note/prisma/dev.db",
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -68,10 +68,9 @@ export interface Note {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
contentUpdatedAt: Date;
|
||||
embedding?: number[] | null;
|
||||
sharedWith?: string[];
|
||||
userId?: string | null;
|
||||
// NEW: Notebook relation (optional - null = "Notes générales" / Inbox)
|
||||
// Notebook relation (optional - null = "General Notes" / Inbox)
|
||||
notebookId?: string | null;
|
||||
notebook?: Notebook | null;
|
||||
autoGenerated?: boolean | null;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import NextAuth from 'next-auth';
|
||||
import { authConfig } from './auth.config';
|
||||
|
||||
// NOTE: NextAuth middleware API may change in Next.js 16+.
|
||||
// See: https://nextjs.org/docs/app/building-your-application/routing/middleware
|
||||
export default NextAuth(authConfig).auth;
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const withPWA = require("@ducanh2912/next-pwa").default({
|
||||
dest: "public",
|
||||
register: true,
|
||||
skipWaiting: true,
|
||||
disable: process.env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Enable standalone output for Docker
|
||||
output: 'standalone',
|
||||
@@ -22,8 +15,7 @@ const nextConfig: NextConfig = {
|
||||
// Hide the "compiling" indicator
|
||||
devIndicators: false,
|
||||
|
||||
// Silence warning from Next-PWA custom webpack injections
|
||||
turbopack: {},
|
||||
};
|
||||
|
||||
export default withPWA(nextConfig);
|
||||
export default nextConfig;
|
||||
|
||||
8605
memento-note/package-lock.json
generated
8605
memento-note/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
||||
"db:studio": "prisma studio",
|
||||
"db:reset": "prisma migrate reset",
|
||||
"db:switch": "node scripts/switch-db.js",
|
||||
"setup:env": "node scripts/setup-env.js",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed",
|
||||
@@ -29,7 +30,6 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@excalidraw/excalidraw": "^0.18.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
@@ -96,6 +96,10 @@
|
||||
},
|
||||
"overrides": {
|
||||
"serialize-javascript": "^7.0.5",
|
||||
"nodemailer": "^8.0.4"
|
||||
"nodemailer": "^8.0.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^3.3.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ generator client {
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
|
||||
991
memento-note/scripts/setup-env.js
Normal file
991
memento-note/scripts/setup-env.js
Normal file
@@ -0,0 +1,991 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// =============================================================================
|
||||
// Memento - Interactive Environment Setup Wizard
|
||||
// =============================================================================
|
||||
// Zero external dependencies. Uses only Node.js built-ins.
|
||||
// Usage: npm run setup:env
|
||||
// =============================================================================
|
||||
|
||||
const readline = require('readline');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ANSI helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const R = '\x1b[0m';
|
||||
const bold = (s) => `\x1b[1m${s}${R}`;
|
||||
const dim = (s) => `\x1b[2m${s}${R}`;
|
||||
const red = (s) => `\x1b[31m${s}${R}`;
|
||||
const green = (s) => `\x1b[32m${s}${R}`;
|
||||
const yellow = (s) => `\x1b[33m${s}${R}`;
|
||||
const cyan = (s) => `\x1b[36m${s}${R}`;
|
||||
const magenta = (s) => `\x1b[35m${s}${R}`;
|
||||
const blue = (s) => `\x1b[34m${s}${R}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Readline promisified
|
||||
// ---------------------------------------------------------------------------
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
function question(prompt) {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => resolve(answer.trim()));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Crypto helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function generateSecret() {
|
||||
return crypto.randomBytes(32).toString('base64');
|
||||
}
|
||||
|
||||
function generatePassword(len = 24) {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789!@#$%&*';
|
||||
const bytes = crypto.randomBytes(len);
|
||||
return Array.from(bytes, (b) => chars[b % chars.length]).join('');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prisma schema helper
|
||||
// ---------------------------------------------------------------------------
|
||||
const PRISMA_SCHEMA_PATH = path.join(__dirname, '..', 'prisma', 'schema.prisma');
|
||||
|
||||
function switchPrismaProvider(target) {
|
||||
if (!['sqlite', 'postgresql'].includes(target)) return;
|
||||
let schema = fs.readFileSync(PRISMA_SCHEMA_PATH, 'utf8');
|
||||
schema = schema.replace(
|
||||
/(datasource db \{\s*\n\s*provider\s*=\s*)"[^"]+"/,
|
||||
`$1"${target}"`
|
||||
);
|
||||
fs.writeFileSync(PRISMA_SCHEMA_PATH, schema);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function isSensitive(key) {
|
||||
const k = key.toUpperCase();
|
||||
return k.includes('KEY') || k.includes('SECRET') || k.includes('PASSWORD') || k.includes('PASS');
|
||||
}
|
||||
|
||||
function mask(value) {
|
||||
if (!value || value.length <= 8) return '****';
|
||||
return value.slice(0, 4) + '****' + value.slice(-4);
|
||||
}
|
||||
|
||||
async function askRequired(prompt, defaultValue) {
|
||||
while (true) {
|
||||
const answer = await question(prompt);
|
||||
const val = answer || defaultValue;
|
||||
if (val) return val;
|
||||
console.log(red(' This field is required. Please enter a value.'));
|
||||
}
|
||||
}
|
||||
|
||||
async function askYesNo(prompt, defaultYes = true) {
|
||||
const hint = defaultYes ? 'Y/n' : 'y/N';
|
||||
const answer = await question(`${prompt} (${hint}): `);
|
||||
if (!answer) return defaultYes;
|
||||
return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin account creation
|
||||
// ---------------------------------------------------------------------------
|
||||
async function createLocalAdmin() {
|
||||
console.log();
|
||||
console.log(cyan(bold(' ── Admin Account ─────────────────────────────────')));
|
||||
console.log(dim(' Create the first admin account for your instance.'));
|
||||
console.log();
|
||||
|
||||
const email = await askRequired(' Admin email: ', '');
|
||||
const name = await question(' Admin name (Admin): ') || 'Admin';
|
||||
const password = await askRequired(' Admin password: ', '');
|
||||
|
||||
// Hash password using bcryptjs (installed dependency)
|
||||
console.log(dim(' Hashing password...'));
|
||||
let hashedPassword;
|
||||
try {
|
||||
const bcrypt = require('bcryptjs');
|
||||
hashedPassword = await bcrypt.hash(password, 12);
|
||||
} catch {
|
||||
// Fallback: use Node crypto if bcryptjs unavailable
|
||||
hashedPassword = crypto.createHash('sha256').update(password).digest('hex');
|
||||
console.log(yellow(' Warning: bcryptjs not available, using SHA-256 fallback'));
|
||||
}
|
||||
|
||||
// Build inline script that uses Prisma to create the user
|
||||
const script = `
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
(async () => {
|
||||
try {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: ${JSON.stringify(email.toLowerCase())},
|
||||
name: ${JSON.stringify(name)},
|
||||
password: ${JSON.stringify(hashedPassword)},
|
||||
role: 'ADMIN',
|
||||
}
|
||||
});
|
||||
console.log('OK:' + user.email);
|
||||
} catch (e) {
|
||||
if (e.code === 'P2002') {
|
||||
console.log('EXISTS:${email.toLowerCase()}');
|
||||
} else {
|
||||
console.error('ERR:' + e.message);
|
||||
}
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
// Write temp script and execute
|
||||
const tmpFile = path.join(__dirname, '..', '_setup_admin_tmp.js');
|
||||
fs.writeFileSync(tmpFile, script, 'utf8');
|
||||
|
||||
try {
|
||||
const output = execSync(`node "${tmpFile}"`, {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
encoding: 'utf8',
|
||||
timeout: 30000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
|
||||
if (output.startsWith('OK:')) {
|
||||
console.log(green(` Admin account created: ${output.slice(3)}`));
|
||||
return true;
|
||||
} else if (output.startsWith('EXISTS:')) {
|
||||
console.log(yellow(` User ${output.slice(7)} already exists. Skipping.`));
|
||||
return false;
|
||||
} else if (output.startsWith('ERR:')) {
|
||||
console.log(red(` Error: ${output.slice(4)}`));
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(red(` Failed to create admin: ${err.message}`));
|
||||
console.log(dim(' You can create it later with: node scripts/promote-admin.js'));
|
||||
return false;
|
||||
} finally {
|
||||
fs.unlinkSync(tmpFile);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write config to SystemConfig DB table
|
||||
// ---------------------------------------------------------------------------
|
||||
function writeConfigToDb(vars) {
|
||||
// Keys that the admin panel reads from SystemConfig (not from .env)
|
||||
const dbKeys = [
|
||||
'AI_PROVIDER', 'AI_PROVIDER_TAGS', 'AI_PROVIDER_EMBEDDING', 'AI_PROVIDER_CHAT',
|
||||
'AI_MODEL_TAGS', 'AI_MODEL_EMBEDDING', 'AI_MODEL_CHAT',
|
||||
'OPENAI_API_KEY', 'OLLAMA_BASE_URL',
|
||||
'CUSTOM_OPENAI_API_KEY', 'CUSTOM_OPENAI_BASE_URL',
|
||||
'RESEND_API_KEY',
|
||||
'SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS', 'SMTP_FROM',
|
||||
'SMTP_SECURE', 'SMTP_IGNORE_CERT',
|
||||
'ALLOW_REGISTRATION',
|
||||
'MCP_SERVER_MODE', 'MCP_SERVER_URL',
|
||||
];
|
||||
|
||||
const entries = [];
|
||||
for (const key of dbKeys) {
|
||||
if (vars[key]) {
|
||||
entries.push({ key, value: vars[key] });
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length === 0) return;
|
||||
|
||||
// Build inline script using Prisma to upsert SystemConfig
|
||||
const entriesJson = JSON.stringify(entries);
|
||||
const script = `
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
(async () => {
|
||||
try {
|
||||
const entries = ${entriesJson};
|
||||
for (const e of entries) {
|
||||
await prisma.systemConfig.upsert({
|
||||
where: { key: e.key },
|
||||
update: { value: e.value },
|
||||
create: { key: e.key, value: e.value },
|
||||
});
|
||||
}
|
||||
console.log('OK:' + entries.length);
|
||||
} catch (e) {
|
||||
console.error('ERR:' + e.message);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
const tmpFile = path.join(__dirname, '..', '_setup_config_tmp.js');
|
||||
fs.writeFileSync(tmpFile, script, 'utf8');
|
||||
|
||||
try {
|
||||
const output = execSync(`node "${tmpFile}"`, {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
encoding: 'utf8',
|
||||
timeout: 30000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
|
||||
if (output.startsWith('OK:')) {
|
||||
console.log(green(` Wrote ${output.slice(3)} settings to database`));
|
||||
return true;
|
||||
} else if (output.startsWith('ERR:')) {
|
||||
console.log(yellow(` Warning: could not write config to DB: ${output.slice(4)}`));
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(yellow(' Warning: could not write config to database.'));
|
||||
console.log(dim(' Settings will still work via .env but won\'t appear in the admin panel.'));
|
||||
return false;
|
||||
} finally {
|
||||
fs.unlinkSync(tmpFile);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Banner
|
||||
// ---------------------------------------------------------------------------
|
||||
function showBanner() {
|
||||
console.log();
|
||||
console.log(cyan(bold(' ╔══════════════════════════════════════════╗')));
|
||||
console.log(cyan(bold(' ║ Memento Environment Setup Wizard ║')));
|
||||
console.log(cyan(bold(' ╚══════════════════════════════════════════╝')));
|
||||
console.log();
|
||||
console.log(dim(' This wizard will help you create environment'));
|
||||
console.log(dim(' configuration files for your Memento instance.'));
|
||||
console.log();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment choice
|
||||
// ---------------------------------------------------------------------------
|
||||
async function chooseEnvironment() {
|
||||
console.log(bold(' Which environment do you want to configure?'));
|
||||
console.log();
|
||||
console.log(` ${green('1')} - Local development ${dim('(creates .env in memento-note/)')}`);
|
||||
console.log(` ${green('2')} - Docker deployment ${dim('(creates .env.docker in Momento/)')}`);
|
||||
console.log(` ${green('3')} - Both ${dim('(creates both files, shared secret)')}`);
|
||||
console.log();
|
||||
|
||||
while (true) {
|
||||
const choice = await question(' Select [1/2/3]: ');
|
||||
if (choice === '1') return 'local';
|
||||
if (choice === '2') return 'docker';
|
||||
if (choice === '3') return 'both';
|
||||
console.log(yellow(' Please enter 1, 2, or 3.'));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI provider menus
|
||||
// ---------------------------------------------------------------------------
|
||||
const AI_PROVIDERS = ['openai', 'ollama', 'deepseek', 'openrouter', 'custom-openai'];
|
||||
|
||||
function showAiProviderMenu(label) {
|
||||
console.log();
|
||||
console.log(bold(` AI Provider for ${label}`));
|
||||
console.log(` ${green('1')} - OpenAI`);
|
||||
console.log(` ${green('2')} - Ollama (local)`);
|
||||
console.log(` ${green('3')} - DeepSeek`);
|
||||
console.log(` ${green('4')} - OpenRouter`);
|
||||
console.log(` ${green('5')} - Custom OpenAI-compatible`);
|
||||
console.log(` ${green('6')} - Skip (configure later)`);
|
||||
}
|
||||
|
||||
async function chooseAiProvider(label) {
|
||||
showAiProviderMenu(label);
|
||||
while (true) {
|
||||
const choice = await question(' Select [1-6]: ');
|
||||
if (choice === '1') return 'openai';
|
||||
if (choice === '2') return 'ollama';
|
||||
if (choice === '3') return 'deepseek';
|
||||
if (choice === '4') return 'openrouter';
|
||||
if (choice === '5') return 'custom-openai';
|
||||
if (choice === '6') return null;
|
||||
console.log(yellow(' Please enter a number from 1 to 6.'));
|
||||
}
|
||||
}
|
||||
|
||||
async function collectAiProviderFields(provider, defaults = {}) {
|
||||
const vars = {};
|
||||
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
vars.OPENAI_API_KEY = await askRequired(
|
||||
` OpenAI API Key ${dim(`[${mask(defaults.OPENAI_API_KEY || '')}]`)}: `,
|
||||
defaults.OPENAI_API_KEY || ''
|
||||
);
|
||||
break;
|
||||
|
||||
case 'ollama': {
|
||||
const ollamaUrl = defaults.OLLAMA_BASE_URL || 'http://localhost:11434';
|
||||
vars.OLLAMA_BASE_URL = await question(` Ollama Base URL (${ollamaUrl}): `) || ollamaUrl;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'deepseek': {
|
||||
const dsKey = await askRequired(
|
||||
` DeepSeek API Key ${dim(`[${mask(defaults.CUSTOM_OPENAI_API_KEY || '')}]`)}: `,
|
||||
defaults.CUSTOM_OPENAI_API_KEY || ''
|
||||
);
|
||||
vars.CUSTOM_OPENAI_API_KEY = dsKey;
|
||||
vars.CUSTOM_OPENAI_BASE_URL = 'https://api.deepseek.com/v1';
|
||||
console.log(dim(' Base URL: https://api.deepseek.com/v1'));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'openrouter': {
|
||||
const orKey = await askRequired(
|
||||
` OpenRouter API Key ${dim(`[${mask(defaults.CUSTOM_OPENAI_API_KEY || '')}]`)}: `,
|
||||
defaults.CUSTOM_OPENAI_API_KEY || ''
|
||||
);
|
||||
vars.CUSTOM_OPENAI_API_KEY = orKey;
|
||||
vars.CUSTOM_OPENAI_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
console.log(dim(' Base URL: https://openrouter.ai/api/v1'));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'custom-openai': {
|
||||
vars.CUSTOM_OPENAI_API_KEY = await askRequired(
|
||||
` Custom API Key ${dim(`[${mask(defaults.CUSTOM_OPENAI_API_KEY || '')}]`)}: `,
|
||||
defaults.CUSTOM_OPENAI_API_KEY || ''
|
||||
);
|
||||
const defaultUrl = defaults.CUSTOM_OPENAI_BASE_URL || '';
|
||||
vars.CUSTOM_OPENAI_BASE_URL = await askRequired(
|
||||
` Custom Base URL ${defaultUrl ? dim(`[${defaultUrl}]`) : ''}: `,
|
||||
defaultUrl
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
async function collectAiModels(provider, feature) {
|
||||
const vars = {};
|
||||
const defaults = {
|
||||
chat: { openai: 'gpt-4o-mini', ollama: 'llama3.2', 'custom-openai': 'gpt-4o-mini', deepseek: 'deepseek-chat', openrouter: 'gpt-4o-mini' },
|
||||
tags: { openai: 'gpt-4o-mini', ollama: 'llama3.2', 'custom-openai': 'gpt-4o-mini', deepseek: 'deepseek-chat', openrouter: 'gpt-4o-mini' },
|
||||
embedding: { openai: 'text-embedding-3-small', ollama: 'nomic-embed-text', 'custom-openai': 'text-embedding-3-small', deepseek: 'deepseek-chat', openrouter: 'text-embedding-3-small' },
|
||||
};
|
||||
|
||||
const modelKey = `AI_MODEL_${feature.toUpperCase()}`;
|
||||
const defaultModel = defaults[feature.toLowerCase()]?.[provider] || '';
|
||||
|
||||
if (feature.toLowerCase() === 'chat') {
|
||||
const answer = await question(` Chat model (${defaultModel}): `);
|
||||
vars[modelKey] = answer || defaultModel;
|
||||
} else if (feature.toLowerCase() === 'tags') {
|
||||
const answer = await question(` Tags model (${defaultModel}): `);
|
||||
vars[modelKey] = answer || defaultModel;
|
||||
} else if (feature.toLowerCase() === 'embedding') {
|
||||
const answer = await question(` Embedding model (${defaultModel}): `);
|
||||
vars[modelKey] = answer || defaultModel;
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local .env section
|
||||
// ---------------------------------------------------------------------------
|
||||
async function collectLocalEnv(sharedSecret) {
|
||||
console.log();
|
||||
console.log(cyan(bold(' ── Local Development (.env) ──────────────────────')));
|
||||
console.log();
|
||||
|
||||
const vars = {};
|
||||
|
||||
// Database type
|
||||
console.log(bold(' Database engine'));
|
||||
console.log(` ${green('1')} - PostgreSQL ${dim('(recommended, required for Docker, embeddings)')}`);
|
||||
console.log(` ${green('2')} - SQLite ${dim('(simple, file-based, no server needed)')}`);
|
||||
console.log();
|
||||
let dbType;
|
||||
while (true) {
|
||||
const dbChoice = await question(' Select [1/2]: ');
|
||||
if (dbChoice === '1') { dbType = 'postgresql'; break; }
|
||||
if (dbChoice === '2') { dbType = 'sqlite'; break; }
|
||||
console.log(yellow(' Please enter 1 or 2.'));
|
||||
}
|
||||
|
||||
if (dbType === 'sqlite') {
|
||||
vars.DATABASE_URL = 'file:./dev.db';
|
||||
vars._dbType = 'sqlite';
|
||||
console.log(dim(' DATABASE_URL set to file:./dev.db'));
|
||||
} else {
|
||||
console.log(dim(' If using Docker PostgreSQL: postgresql://memento:memento@localhost:5432/memento'));
|
||||
vars.DATABASE_URL = await question(
|
||||
` DATABASE_URL (${dim('postgresql://memento:memento@localhost:5432/memento')}): `
|
||||
) || 'postgresql://memento:memento@localhost:5432/memento';
|
||||
vars._dbType = 'postgresql';
|
||||
}
|
||||
|
||||
// NEXTAUTH_URL
|
||||
vars.NEXTAUTH_URL = await question(
|
||||
` NEXTAUTH_URL (${dim('http://localhost:3000')}): `
|
||||
) || 'http://localhost:3000';
|
||||
|
||||
// NEXTAUTH_SECRET
|
||||
const autoSecret = sharedSecret || generateSecret();
|
||||
console.log(dim(` Generated NEXTAUTH_SECRET: ${mask(autoSecret)}`));
|
||||
const secretAnswer = await question(` NEXTAUTH_SECRET (press Enter to use generated): `);
|
||||
vars.NEXTAUTH_SECRET = secretAnswer || autoSecret;
|
||||
|
||||
// ALLOW_REGISTRATION
|
||||
vars.ALLOW_REGISTRATION = (await askYesNo(' Allow registration?', true)) ? 'true' : 'false';
|
||||
|
||||
// AI Provider (single provider for all features in local)
|
||||
const provider = await chooseAiProvider('all features');
|
||||
if (provider) {
|
||||
vars.AI_PROVIDER = provider;
|
||||
const providerVars = await collectAiProviderFields(provider, {});
|
||||
Object.assign(vars, providerVars);
|
||||
|
||||
// Ask for model overrides
|
||||
const configureModels = await askYesNo(' Configure model names?', false);
|
||||
if (configureModels) {
|
||||
const chatModels = await collectAiModels(provider, 'chat');
|
||||
Object.assign(vars, chatModels);
|
||||
const tagModels = await collectAiModels(provider, 'tags');
|
||||
Object.assign(vars, tagModels);
|
||||
const embModels = await collectAiModels(provider, 'embedding');
|
||||
Object.assign(vars, embModels);
|
||||
}
|
||||
}
|
||||
|
||||
// Email
|
||||
const wantEmail = await askYesNo(' Configure email?', false);
|
||||
if (wantEmail) {
|
||||
const useResend = await askYesNo(' Use Resend? (vs SMTP)', false);
|
||||
if (useResend) {
|
||||
vars.RESEND_API_KEY = await askRequired(' RESEND_API_KEY: ', '');
|
||||
} else {
|
||||
vars.SMTP_HOST = await askRequired(' SMTP_HOST: ', '');
|
||||
vars.SMTP_PORT = await question(` SMTP_PORT (${dim('587')}): `) || '587';
|
||||
vars.SMTP_USER = await askRequired(' SMTP_USER: ', '');
|
||||
vars.SMTP_PASS = await askRequired(' SMTP_PASS: ', '');
|
||||
vars.SMTP_FROM = await askRequired(' SMTP_FROM: ', '');
|
||||
const secure = await askYesNo(' Use TLS/SSL?', true);
|
||||
vars.SMTP_SECURE = secure ? 'true' : 'false';
|
||||
}
|
||||
}
|
||||
|
||||
// MCP
|
||||
const wantMcp = await askYesNo(' Configure MCP server?', false);
|
||||
if (wantMcp) {
|
||||
const useSse = await askYesNo(' MCP mode: SSE? (vs stdio)', false);
|
||||
vars.MCP_SERVER_MODE = useSse ? 'sse' : 'stdio';
|
||||
if (useSse) {
|
||||
vars.MCP_SERVER_URL = await question(` MCP Server URL (${dim('http://localhost:3001')}): `) || 'http://localhost:3001';
|
||||
}
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Docker .env.docker section
|
||||
// ---------------------------------------------------------------------------
|
||||
async function collectDockerEnv(sharedSecret) {
|
||||
console.log();
|
||||
console.log(cyan(bold(' ── Docker Deployment (.env.docker) ────────────────')));
|
||||
console.log();
|
||||
|
||||
const vars = {};
|
||||
|
||||
// NEXTAUTH_URL
|
||||
console.log(dim(' Hint: Use your server IP or domain (e.g. http://192.168.1.100:3000)'));
|
||||
vars.NEXTAUTH_URL = await question(
|
||||
` NEXTAUTH_URL (${dim('http://localhost:3000')}): `
|
||||
) || 'http://localhost:3000';
|
||||
|
||||
// NEXTAUTH_SECRET
|
||||
const autoSecret = sharedSecret || generateSecret();
|
||||
console.log(dim(` Generated NEXTAUTH_SECRET: ${mask(autoSecret)}`));
|
||||
const secretAnswer = await question(` NEXTAUTH_SECRET (press Enter to use generated): `);
|
||||
vars.NEXTAUTH_SECRET = secretAnswer || autoSecret;
|
||||
|
||||
// ALLOW_REGISTRATION
|
||||
vars.ALLOW_REGISTRATION = (await askYesNo(' Allow registration?', true)) ? 'true' : 'false';
|
||||
|
||||
// PostgreSQL
|
||||
console.log();
|
||||
console.log(bold(' PostgreSQL Configuration'));
|
||||
console.log(dim(' Password auto-generated. You can override it.'));
|
||||
const pgPassword = generatePassword();
|
||||
console.log(dim(` Generated POSTGRES_PASSWORD: ${mask(pgPassword)}`));
|
||||
vars.POSTGRES_PORT = await question(` POSTGRES_PORT (${dim('5432')}): `) || '5432';
|
||||
vars.POSTGRES_DB = await question(` POSTGRES_DB (${dim('memento')}): `) || 'memento';
|
||||
vars.POSTGRES_USER = await question(` POSTGRES_USER (${dim('memento')}): `) || 'memento';
|
||||
const pgPassAnswer = await question(` POSTGRES_PASSWORD (press Enter to use generated): `);
|
||||
vars.POSTGRES_PASSWORD = pgPassAnswer || pgPassword;
|
||||
|
||||
// AI per-feature
|
||||
console.log();
|
||||
console.log(bold(' AI Provider Configuration (per-feature)'));
|
||||
|
||||
const tagsProvider = await chooseAiProvider('Tags generation');
|
||||
if (tagsProvider) {
|
||||
vars.AI_PROVIDER_TAGS = tagsProvider;
|
||||
const fields = await collectAiProviderFields(tagsProvider, { OLLAMA_BASE_URL: 'http://ollama:11434' });
|
||||
Object.assign(vars, fields);
|
||||
const models = await collectAiModels(tagsProvider, 'tags');
|
||||
Object.assign(vars, models);
|
||||
}
|
||||
|
||||
const embProvider = await chooseAiProvider('Embeddings');
|
||||
if (embProvider) {
|
||||
vars.AI_PROVIDER_EMBEDDING = embProvider;
|
||||
if (!vars.CUSTOM_OPENAI_API_KEY && !vars.OPENAI_API_KEY) {
|
||||
const fields = await collectAiProviderFields(embProvider, { OLLAMA_BASE_URL: 'http://ollama:11434' });
|
||||
Object.assign(vars, fields);
|
||||
}
|
||||
const models = await collectAiModels(embProvider, 'embedding');
|
||||
Object.assign(vars, models);
|
||||
}
|
||||
|
||||
const wantChat = await askYesNo(' Configure a separate chat provider?', false);
|
||||
if (wantChat) {
|
||||
const chatProvider = await chooseAiProvider('Chat');
|
||||
if (chatProvider) {
|
||||
vars.AI_PROVIDER_CHAT = chatProvider;
|
||||
if (!vars.CUSTOM_OPENAI_API_KEY && !vars.OPENAI_API_KEY) {
|
||||
const fields = await collectAiProviderFields(chatProvider, { OLLAMA_BASE_URL: 'http://ollama:11434' });
|
||||
Object.assign(vars, fields);
|
||||
}
|
||||
const models = await collectAiModels(chatProvider, 'chat');
|
||||
Object.assign(vars, models);
|
||||
}
|
||||
}
|
||||
|
||||
// MCP
|
||||
const wantMcp = await askYesNo(' Configure MCP server?', false);
|
||||
if (wantMcp) {
|
||||
const useSse = await askYesNo(' MCP mode: SSE? (vs stdio)', false);
|
||||
vars.MCP_MODE = useSse ? 'sse' : 'stdio';
|
||||
vars.MCP_PORT = await question(` MCP_PORT (${dim('3001')}): `) || '3001';
|
||||
if (useSse) {
|
||||
vars.MCP_SERVER_URL = await question(` MCP_SERVER_URL (${dim('http://localhost:3001')}): `) || 'http://localhost:3001';
|
||||
}
|
||||
}
|
||||
|
||||
// Email
|
||||
const wantEmail = await askYesNo(' Configure email?', false);
|
||||
if (wantEmail) {
|
||||
const useResend = await askYesNo(' Use Resend? (vs SMTP)', false);
|
||||
if (useResend) {
|
||||
vars.RESEND_API_KEY = await askRequired(' RESEND_API_KEY: ', '');
|
||||
} else {
|
||||
vars.SMTP_HOST = await askRequired(' SMTP_HOST: ', '');
|
||||
vars.SMTP_PORT = await question(` SMTP_PORT (${dim('587')}): `) || '587';
|
||||
vars.SMTP_USER = await askRequired(' SMTP_USER: ', '');
|
||||
vars.SMTP_PASS = await askRequired(' SMTP_PASS: ', '');
|
||||
vars.SMTP_FROM = await askRequired(' SMTP_FROM: ', '');
|
||||
}
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File writing
|
||||
// ---------------------------------------------------------------------------
|
||||
function generateLocalEnvContent(vars) {
|
||||
let content = `# =============================================================================
|
||||
# Memento Note - Local Environment Configuration
|
||||
# Generated by setup-env wizard
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Core
|
||||
# -----------------------------------------------------------------------------
|
||||
DATABASE_URL="${vars.DATABASE_URL}"
|
||||
NEXTAUTH_SECRET="${vars.NEXTAUTH_SECRET}"
|
||||
NEXTAUTH_URL="${vars.NEXTAUTH_URL}"
|
||||
`;
|
||||
|
||||
if (vars.ALLOW_REGISTRATION) {
|
||||
content += `
|
||||
# -----------------------------------------------------------------------------
|
||||
# Registration
|
||||
# -----------------------------------------------------------------------------
|
||||
ALLOW_REGISTRATION="${vars.ALLOW_REGISTRATION}"
|
||||
`;
|
||||
}
|
||||
|
||||
// AI provider section
|
||||
const aiKeys = ['AI_PROVIDER', 'AI_PROVIDER_CHAT', 'AI_PROVIDER_TAGS', 'AI_PROVIDER_EMBEDDING',
|
||||
'AI_MODEL_CHAT', 'AI_MODEL_TAGS', 'AI_MODEL_EMBEDDING',
|
||||
'OPENAI_API_KEY', 'OLLAMA_BASE_URL', 'CUSTOM_OPENAI_API_KEY', 'CUSTOM_OPENAI_BASE_URL'];
|
||||
const hasAi = aiKeys.some((k) => vars[k]);
|
||||
|
||||
if (hasAi) {
|
||||
content += `
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI Providers
|
||||
# -----------------------------------------------------------------------------
|
||||
`;
|
||||
if (vars.AI_PROVIDER) content += `AI_PROVIDER="${vars.AI_PROVIDER}"\n`;
|
||||
if (vars.AI_PROVIDER_CHAT) content += `AI_PROVIDER_CHAT="${vars.AI_PROVIDER_CHAT}"\n`;
|
||||
if (vars.AI_PROVIDER_TAGS) content += `AI_PROVIDER_TAGS="${vars.AI_PROVIDER_TAGS}"\n`;
|
||||
if (vars.AI_PROVIDER_EMBEDDING) content += `AI_PROVIDER_EMBEDDING="${vars.AI_PROVIDER_EMBEDDING}"\n`;
|
||||
if (vars.AI_MODEL_CHAT) content += `AI_MODEL_CHAT="${vars.AI_MODEL_CHAT}"\n`;
|
||||
if (vars.AI_MODEL_TAGS) content += `AI_MODEL_TAGS="${vars.AI_MODEL_TAGS}"\n`;
|
||||
if (vars.AI_MODEL_EMBEDDING) content += `AI_MODEL_EMBEDDING="${vars.AI_MODEL_EMBEDDING}"\n`;
|
||||
if (vars.OPENAI_API_KEY) content += `OPENAI_API_KEY="${vars.OPENAI_API_KEY}"\n`;
|
||||
if (vars.OLLAMA_BASE_URL) content += `OLLAMA_BASE_URL="${vars.OLLAMA_BASE_URL}"\n`;
|
||||
if (vars.CUSTOM_OPENAI_API_KEY) content += `CUSTOM_OPENAI_API_KEY="${vars.CUSTOM_OPENAI_API_KEY}"\n`;
|
||||
if (vars.CUSTOM_OPENAI_BASE_URL) content += `CUSTOM_OPENAI_BASE_URL="${vars.CUSTOM_OPENAI_BASE_URL}"\n`;
|
||||
}
|
||||
|
||||
// Email section
|
||||
const emailKeys = ['RESEND_API_KEY', 'SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS', 'SMTP_FROM', 'SMTP_SECURE', 'SMTP_IGNORE_CERT'];
|
||||
const hasEmail = emailKeys.some((k) => vars[k]);
|
||||
|
||||
if (hasEmail) {
|
||||
content += `
|
||||
# -----------------------------------------------------------------------------
|
||||
# Email
|
||||
# -----------------------------------------------------------------------------
|
||||
`;
|
||||
if (vars.RESEND_API_KEY) content += `RESEND_API_KEY="${vars.RESEND_API_KEY}"\n`;
|
||||
if (vars.SMTP_HOST) content += `SMTP_HOST="${vars.SMTP_HOST}"\n`;
|
||||
if (vars.SMTP_PORT) content += `SMTP_PORT="${vars.SMTP_PORT}"\n`;
|
||||
if (vars.SMTP_USER) content += `SMTP_USER="${vars.SMTP_USER}"\n`;
|
||||
if (vars.SMTP_PASS) content += `SMTP_PASS="${vars.SMTP_PASS}"\n`;
|
||||
if (vars.SMTP_FROM) content += `SMTP_FROM="${vars.SMTP_FROM}"\n`;
|
||||
if (vars.SMTP_SECURE) content += `SMTP_SECURE="${vars.SMTP_SECURE}"\n`;
|
||||
}
|
||||
|
||||
// MCP section
|
||||
if (vars.MCP_SERVER_MODE) {
|
||||
content += `
|
||||
# -----------------------------------------------------------------------------
|
||||
# MCP
|
||||
# -----------------------------------------------------------------------------
|
||||
MCP_SERVER_MODE="${vars.MCP_SERVER_MODE}"
|
||||
`;
|
||||
if (vars.MCP_SERVER_URL) content += `MCP_SERVER_URL="${vars.MCP_SERVER_URL}"\n`;
|
||||
}
|
||||
|
||||
content += '\n';
|
||||
return content;
|
||||
}
|
||||
|
||||
function generateDockerEnvContent(vars) {
|
||||
let content = `# =============================================================================
|
||||
# Memento - Docker Environment Configuration
|
||||
# Generated by setup-env wizard
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# APPLICATION
|
||||
# =============================================================================
|
||||
NEXTAUTH_URL="${vars.NEXTAUTH_URL}"
|
||||
NEXTAUTH_SECRET="${vars.NEXTAUTH_SECRET}"
|
||||
`;
|
||||
|
||||
if (vars.ALLOW_REGISTRATION) {
|
||||
content += `ALLOW_REGISTRATION="${vars.ALLOW_REGISTRATION}"\n`;
|
||||
}
|
||||
|
||||
content += `
|
||||
# =============================================================================
|
||||
# POSTGRESQL
|
||||
# =============================================================================
|
||||
POSTGRES_PORT=${vars.POSTGRES_PORT || 5432}
|
||||
POSTGRES_DB=${vars.POSTGRES_DB || 'memento'}
|
||||
POSTGRES_USER=${vars.POSTGRES_USER || 'memento'}
|
||||
POSTGRES_PASSWORD=${vars.POSTGRES_PASSWORD || 'memento'}
|
||||
`;
|
||||
|
||||
// AI per-feature
|
||||
const aiKeys = ['AI_PROVIDER_TAGS', 'AI_PROVIDER_EMBEDDING', 'AI_PROVIDER_CHAT',
|
||||
'AI_MODEL_TAGS', 'AI_MODEL_EMBEDDING', 'AI_MODEL_CHAT',
|
||||
'OLLAMA_BASE_URL', 'OPENAI_API_KEY', 'CUSTOM_OPENAI_API_KEY', 'CUSTOM_OPENAI_BASE_URL'];
|
||||
const hasAi = aiKeys.some((k) => vars[k]);
|
||||
|
||||
if (hasAi) {
|
||||
content += `
|
||||
# =============================================================================
|
||||
# AI PROVIDERS
|
||||
# =============================================================================
|
||||
`;
|
||||
if (vars.AI_PROVIDER_TAGS) content += `AI_PROVIDER_TAGS=${vars.AI_PROVIDER_TAGS}\n`;
|
||||
if (vars.AI_MODEL_TAGS) content += `AI_MODEL_TAGS="${vars.AI_MODEL_TAGS}"\n`;
|
||||
if (vars.AI_PROVIDER_EMBEDDING) content += `AI_PROVIDER_EMBEDDING=${vars.AI_PROVIDER_EMBEDDING}\n`;
|
||||
if (vars.AI_MODEL_EMBEDDING) content += `AI_MODEL_EMBEDDING="${vars.AI_MODEL_EMBEDDING}"\n`;
|
||||
if (vars.AI_PROVIDER_CHAT) content += `AI_PROVIDER_CHAT=${vars.AI_PROVIDER_CHAT}\n`;
|
||||
if (vars.AI_MODEL_CHAT) content += `AI_MODEL_CHAT="${vars.AI_MODEL_CHAT}"\n`;
|
||||
if (vars.OLLAMA_BASE_URL) content += `OLLAMA_BASE_URL="${vars.OLLAMA_BASE_URL}"\n`;
|
||||
if (vars.OPENAI_API_KEY) content += `OPENAI_API_KEY="${vars.OPENAI_API_KEY}"\n`;
|
||||
if (vars.CUSTOM_OPENAI_API_KEY) content += `CUSTOM_OPENAI_API_KEY="${vars.CUSTOM_OPENAI_API_KEY}"\n`;
|
||||
if (vars.CUSTOM_OPENAI_BASE_URL) content += `CUSTOM_OPENAI_BASE_URL="${vars.CUSTOM_OPENAI_BASE_URL}"\n`;
|
||||
}
|
||||
|
||||
// MCP
|
||||
if (vars.MCP_MODE || vars.MCP_SERVER_MODE) {
|
||||
content += `
|
||||
# =============================================================================
|
||||
# MCP SERVER
|
||||
# =============================================================================
|
||||
`;
|
||||
if (vars.MCP_MODE) content += `MCP_MODE="${vars.MCP_MODE}"\n`;
|
||||
if (vars.MCP_PORT) content += `MCP_PORT="${vars.MCP_PORT}"\n`;
|
||||
if (vars.MCP_SERVER_URL) content += `MCP_SERVER_URL="${vars.MCP_SERVER_URL}"\n`;
|
||||
}
|
||||
|
||||
// Email
|
||||
const emailKeys = ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS', 'SMTP_FROM', 'RESEND_API_KEY'];
|
||||
const hasEmail = emailKeys.some((k) => vars[k]);
|
||||
|
||||
if (hasEmail) {
|
||||
content += `
|
||||
# =============================================================================
|
||||
# EMAIL
|
||||
# =============================================================================
|
||||
`;
|
||||
if (vars.SMTP_HOST) content += `SMTP_HOST="${vars.SMTP_HOST}"\n`;
|
||||
if (vars.SMTP_PORT) content += `SMTP_PORT="${vars.SMTP_PORT}"\n`;
|
||||
if (vars.SMTP_USER) content += `SMTP_USER="${vars.SMTP_USER}"\n`;
|
||||
if (vars.SMTP_PASS) content += `SMTP_PASS="${vars.SMTP_PASS}"\n`;
|
||||
if (vars.SMTP_FROM) content += `SMTP_FROM="${vars.SMTP_FROM}"\n`;
|
||||
if (vars.RESEND_API_KEY) content += `RESEND_API_KEY="${vars.RESEND_API_KEY}"\n`;
|
||||
}
|
||||
|
||||
content += '\n';
|
||||
return content;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File conflict handling
|
||||
// ---------------------------------------------------------------------------
|
||||
async function handleExistingFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return 'write';
|
||||
|
||||
console.log(yellow(`\n File already exists: ${filePath}`));
|
||||
|
||||
while (true) {
|
||||
console.log(` ${green('1')} - Overwrite`);
|
||||
console.log(` ${green('2')} - Skip`);
|
||||
console.log(` ${green('3')} - View current content`);
|
||||
const choice = await question(' Choose [1/2/3]: ');
|
||||
|
||||
if (choice === '1') return 'write';
|
||||
if (choice === '2') return 'skip';
|
||||
if (choice === '3') {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
console.log();
|
||||
console.log(dim(' ── Current content ─────────────────────────────'));
|
||||
content.split('\n').forEach((line) => console.log(dim(` ${line}`)));
|
||||
console.log(dim(' ─────────────────────────────────────────────────'));
|
||||
continue;
|
||||
}
|
||||
console.log(yellow(' Please enter 1, 2, or 3.'));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recap display
|
||||
// ---------------------------------------------------------------------------
|
||||
function showRecap(vars, title) {
|
||||
console.log();
|
||||
console.log(bold(` ${title}`));
|
||||
console.log(dim(' ─────────────────────────────────────────────────'));
|
||||
const keys = Object.keys(vars).filter((k) => !k.startsWith('_'));
|
||||
const maxLen = Math.max(...keys.map((k) => k.length));
|
||||
for (const key of keys) {
|
||||
const value = vars[key];
|
||||
const display = isSensitive(key) ? mask(value) : value;
|
||||
const padded = key.padEnd(maxLen);
|
||||
console.log(` ${cyan(padded)} = ${display}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Next steps
|
||||
// ---------------------------------------------------------------------------
|
||||
function showNextSteps(results) {
|
||||
console.log();
|
||||
console.log(cyan(bold(' ══════════════════════════════════════════════════')));
|
||||
console.log(cyan(bold(' Setup complete!')));
|
||||
console.log(cyan(bold(' ══════════════════════════════════════════════════')));
|
||||
console.log();
|
||||
|
||||
if (results.local) {
|
||||
console.log(green(` Created: ${results.local}`));
|
||||
}
|
||||
if (results.docker) {
|
||||
console.log(green(` Created: ${results.docker}`));
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(bold(' Next steps:'));
|
||||
|
||||
if (results.local) {
|
||||
console.log();
|
||||
console.log(' For local development:');
|
||||
if (!results.adminCreated) {
|
||||
console.log(dim(' npx prisma db push'));
|
||||
}
|
||||
console.log(dim(' npm run dev'));
|
||||
if (!results.adminCreated) {
|
||||
console.log(dim(' Then register via /register and run:'));
|
||||
console.log(dim(' node scripts/promote-admin.js your@email.com'));
|
||||
}
|
||||
}
|
||||
|
||||
if (results.docker) {
|
||||
console.log();
|
||||
console.log(' For Docker deployment:');
|
||||
console.log(dim(' cd .. && docker compose up -d'));
|
||||
console.log(dim(' Register via /register, then promote to admin:'));
|
||||
console.log(dim(' docker compose exec memento-note node scripts/promote-admin.js your@email.com'));
|
||||
}
|
||||
|
||||
console.log();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
// Handle Ctrl+C gracefully
|
||||
rl.on('close', () => {
|
||||
console.log();
|
||||
console.log(dim(' Wizard cancelled.'));
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
showBanner();
|
||||
|
||||
const envChoice = await chooseEnvironment();
|
||||
const results = {};
|
||||
let sharedSecret = null;
|
||||
|
||||
// For "both" mode, generate one shared secret
|
||||
if (envChoice === 'both') {
|
||||
sharedSecret = generateSecret();
|
||||
console.log(dim(`\n Shared NEXTAUTH_SECRET: ${mask(sharedSecret)}`));
|
||||
}
|
||||
|
||||
// Local section
|
||||
if (envChoice === 'local' || envChoice === 'both') {
|
||||
const localVars = await collectLocalEnv(sharedSecret);
|
||||
const localPath = path.join(__dirname, '..', '.env');
|
||||
|
||||
showRecap(localVars, 'Local .env recap:');
|
||||
const action = await handleExistingFile(localPath);
|
||||
|
||||
if (action === 'write') {
|
||||
// Extract internal metadata before writing
|
||||
const dbType = localVars._dbType || 'postgresql';
|
||||
delete localVars._dbType;
|
||||
|
||||
const content = generateLocalEnvContent(localVars);
|
||||
fs.writeFileSync(localPath, content, 'utf8');
|
||||
results.local = localPath;
|
||||
results.dbType = dbType;
|
||||
|
||||
// Switch Prisma schema provider to match chosen database
|
||||
try {
|
||||
switchPrismaProvider(dbType);
|
||||
console.log(green(` Prisma schema switched to ${dbType}`));
|
||||
} catch (err) {
|
||||
console.log(yellow(` Warning: could not update prisma/schema.prisma: ${err.message}`));
|
||||
}
|
||||
|
||||
// Push schema to database
|
||||
console.log(dim(' Pushing database schema...'));
|
||||
let dbReady = false;
|
||||
try {
|
||||
execSync('npx prisma generate', { cwd: path.join(__dirname, '..'), stdio: 'pipe' });
|
||||
execSync('npx prisma db push --accept-data-loss', { cwd: path.join(__dirname, '..'), stdio: 'pipe' });
|
||||
console.log(green(' Database schema synced'));
|
||||
dbReady = true;
|
||||
} catch (err) {
|
||||
console.log(yellow(' Warning: could not push schema to database.'));
|
||||
console.log(dim(` ${err.message?.split('\n')[0] || ''}`));
|
||||
console.log(dim(' Run "npx prisma db push" manually after ensuring the database is running.'));
|
||||
}
|
||||
|
||||
// Write AI/email/MCP config to SystemConfig DB table
|
||||
// so the admin panel shows correct values instead of hardcoded defaults
|
||||
if (dbReady) {
|
||||
console.log(dim(' Writing settings to database...'));
|
||||
writeConfigToDb(localVars);
|
||||
}
|
||||
|
||||
// Create admin account
|
||||
const wantAdmin = await askYesNo('\n Create an admin account now?', true);
|
||||
if (wantAdmin) {
|
||||
await createLocalAdmin();
|
||||
results.adminCreated = true;
|
||||
}
|
||||
|
||||
// Capture shared secret for docker section
|
||||
if (envChoice === 'both') {
|
||||
sharedSecret = localVars.NEXTAUTH_SECRET;
|
||||
}
|
||||
} else {
|
||||
console.log(yellow(' Skipped .env'));
|
||||
if (envChoice === 'both') {
|
||||
sharedSecret = sharedSecret; // keep generated one
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Docker section
|
||||
if (envChoice === 'docker' || envChoice === 'both') {
|
||||
// Verify docker-compose.yml exists in parent
|
||||
const parentDir = path.resolve(__dirname, '..', '..');
|
||||
const dockerComposePath = path.join(parentDir, 'docker-compose.yml');
|
||||
|
||||
if (!fs.existsSync(dockerComposePath)) {
|
||||
console.log(red(`\n Error: docker-compose.yml not found at ${parentDir}`));
|
||||
console.log(red(' Make sure you are running this from the memento-note directory inside the Momento project.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dockerVars = await collectDockerEnv(sharedSecret);
|
||||
const dockerEnvPath = path.join(parentDir, '.env.docker');
|
||||
|
||||
showRecap(dockerVars, 'Docker .env.docker recap:');
|
||||
const action = await handleExistingFile(dockerEnvPath);
|
||||
|
||||
if (action === 'write') {
|
||||
const content = generateDockerEnvContent(dockerVars);
|
||||
fs.writeFileSync(dockerEnvPath, content, 'utf8');
|
||||
results.docker = dockerEnvPath;
|
||||
} else {
|
||||
console.log(yellow(' Skipped .env.docker'));
|
||||
}
|
||||
}
|
||||
|
||||
showNextSteps(results);
|
||||
rl.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(red(`\n Unexpected error: ${err.message}`));
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user