diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d780c99..0b7c78b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,22 @@ "Bash(npm ls *)", "mcp__zread__get_repo_structure", "Bash(npm install *)", - "Bash(docker exec *)" + "Bash(docker exec *)", + "Bash(chmod:*)", + "Bash(ls:*)", + "Bash(echo:*)", + "Bash(npx tsc:*)", + "Bash(./node_modules/.bin/tsc:*)", + "Bash(git status:*)", + "Bash(docker compose exec:*)", + "Bash(node:*)", + "Bash(npm uninstall:*)", + "Bash(npx prisma migrate dev:*)", + "Bash(for f in /Users/sepehr/dev/Momento/memento-note/locales/*.json)", + "Bash(do python3 -c \"import json; json.load\\(open\\(''$f''\\)\\)\")", + "Bash(done)", + "Bash(npx prisma generate)", + "Bash(git add:*)" ] } } diff --git a/.env.docker.example b/.env.docker.example index a2c9b5c..6f2187a 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -22,11 +22,14 @@ NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="changethisinproduction" # ============================================================================= -# REGISTRATION +# REGISTRATION & ADMIN # ============================================================================= # Set to "false" to disable public registration (default: true) # ALLOW_REGISTRATION=true +# Admin email - The first user registering with this email gets ADMIN role (REQUIRED) +# ADMIN_EMAIL="admin@yourdomain.com" + # ============================================================================= # POSTGRESQL CONFIGURATION # ============================================================================= diff --git a/DEPLOY.md b/DEPLOY.md index 4eae762..ab9c4c4 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -63,13 +63,14 @@ git config credential.helper store ```bash cd /opt/memento -bash scripts/deploy.sh --full +bash scripts/deploy-docker.sh --full ``` Le script demande interactivement : +- **ADMIN_EMAIL** (obligatoire) - le premier utilisateur inscrit avec cet email obtient le role ADMIN - URL (mettre `http://192.168.1.190` ou `http://notes.parsanet.org`) - Provider AI + cle API -- Email (optionnel) +- MCP server, email, recherche web (optionnels) Il genere automatiquement NEXTAUTH_SECRET et POSTGRES_PASSWORD. @@ -77,16 +78,17 @@ Il genere automatiquement NEXTAUTH_SECRET et POSTGRES_PASSWORD. ```bash cd /opt/memento -bash scripts/deploy.sh --build +bash scripts/deploy-docker.sh --build docker compose ps # Les 3 conteneurs doivent etre "healthy" ``` ### 5. Creer le premier admin +Inscrivez-vous sur `http://192.168.1.190/register` avec l'email defini dans `ADMIN_EMAIL` : le role ADMIN est attribue automatiquement. + +Alternative manuelle : ```bash -# S'inscrire sur http://192.168.1.190/register -# Puis promouvoir en admin : docker compose exec -T postgres psql -U memento -d memento \ -c "UPDATE \"User\" SET role='ADMIN' WHERE email='VOTRE_EMAIL';" ``` @@ -289,9 +291,9 @@ docker compose restart memento-note - [ ] Creer `memento-deploy` sur 192.168.1.190 - [ ] Cloner le repo dans `/opt/memento` -- [ ] `bash scripts/deploy.sh --full` +- [ ] `bash scripts/deploy-docker.sh --full` - [ ] 3 conteneurs healthy -- [ ] S'inscrire, puis promouvoir en ADMIN via SQL +- [ ] S'inscrire avec l'email ADMIN_EMAIL defini dans .env.docker - [ ] Configurer Nginx ### CI/CD (une seule fois) diff --git a/GUIDE.en.md b/GUIDE.en.md index 00f32c7..bdc3fed 100644 --- a/GUIDE.en.md +++ b/GUIDE.en.md @@ -144,7 +144,26 @@ Browser -> Next.js App Router - PostgreSQL 16 - npm -### Steps +### Quick Setup (interactive script) + +```bash +git clone https://github.com/votre-user/Momento.git +cd Momento + +# macOS / Linux +./scripts/deploy-local.sh + +# Windows PowerShell +.\scripts\deploy-local.ps1 +``` + +The script will: +1. Check that Node.js, npm, and PostgreSQL are installed +2. Ask for configuration (database, admin email, AI provider, etc.) +3. Generate `.env` with auto-generated secrets +4. Install dependencies and run database migrations + +### Manual Steps ```bash # 1. Clone the repository @@ -156,7 +175,7 @@ npm install --legacy-peer-deps # 3. Configure the environment cp .env.example .env -# Edit .env with your values (DATABASE_URL, NEXTAUTH_SECRET, etc.) +# Edit .env with your values (DATABASE_URL, NEXTAUTH_SECRET, ADMIN_EMAIL, etc.) # 4. Create the database and apply migrations npx prisma migrate dev @@ -170,15 +189,45 @@ npm run dev ### First Launch -1. Create an account via the registration page -2. The first registered user automatically becomes admin +1. Register an account using the email you set in `ADMIN_EMAIL` +2. That account automatically gets the **ADMIN** role 3. Access the admin panel: `http://localhost:3000/admin/settings` --- ## Docker Deployment -### Quick Start +### Quick Setup (interactive script) + +```bash +git clone https://github.com/votre-user/Momento.git +cd Momento + +# macOS / Linux +./scripts/deploy-docker.sh + +# Windows PowerShell +.\scripts\deploy-docker.ps1 +``` + +The script will: +1. Check Docker and Docker Compose are installed +2. Ask for configuration (URL, admin email, PostgreSQL, AI provider, MCP, email, Ollama, web search) +3. Auto-generate `NEXTAUTH_SECRET` and `POSTGRES_PASSWORD` +4. Build and start all containers +5. Run database migrations + +**Deploy script options:** + +| Command | Description | +|---------|-------------| +| `./scripts/deploy-docker.sh` | Full setup (env + build + deploy) | +| `./scripts/deploy-docker.sh --env-only` | Generate `.env.docker` only | +| `./scripts/deploy-docker.sh --build` | Build + deploy (requires existing `.env.docker`) | +| `./scripts/deploy-docker.sh --stop` | Stop all containers | +| `./scripts/deploy-docker.sh --logs` | Show container logs | + +### Manual Setup ```bash # 1. Clone the repository @@ -188,7 +237,7 @@ cd Momento # 2. Configure the environment cp .env.docker.example .env.docker nano .env.docker -# Modify NEXTAUTH_URL and NEXTAUTH_SECRET (required) +# Modify NEXTAUTH_URL, NEXTAUTH_SECRET, and ADMIN_EMAIL (required) # 3. Start the containers docker compose up -d @@ -215,6 +264,7 @@ docker exec memento-ollama ollama pull embeddinggemma |----------|-------------| | `NEXTAUTH_URL` | Public URL of the app (e.g., `http://192.168.1.190:3000`) | | `NEXTAUTH_SECRET` | JWT secret - generate with `openssl rand -base64 32` | +| `ADMIN_EMAIL` | Email that automatically gets ADMIN role on registration | ### Ports Used @@ -628,7 +678,9 @@ The `SMTP_SECURE` and `SMTP_IGNORE_CERT` settings can be configured from the adm ### Admin Panel -Accessible only to the first registered user (or users with the admin role). +Accessible only to users with the admin role. + +The **first user who registers with the email set in `ADMIN_EMAIL`** automatically gets the ADMIN role. Make sure to set this variable in your `.env` or `.env.docker` before deploying. **URL**: `/admin/settings` @@ -678,6 +730,14 @@ Accessible only to the first registered user (or users with the admin role). | `DATABASE_URL` | - | PostgreSQL connection string | | `NEXTAUTH_SECRET` | - | JWT secret (`openssl rand -base64 32`) | | `NEXTAUTH_URL` | `http://localhost:3000` | Public URL of the app | +| `ADMIN_EMAIL` | - | Email that automatically gets ADMIN role on registration | + +#### Registration & Admin + +| Variable | Default | Description | +|----------|---------|-------------| +| `ALLOW_REGISTRATION` | `true` | Allow public registration | +| `ADMIN_EMAIL` | - | Email that gets ADMIN role on registration | #### PostgreSQL (Docker) @@ -765,6 +825,37 @@ Accessible only to the first registered user (or users with the admin role). ## Useful Commands +### Deploy Scripts + +```bash +# Docker deployment (macOS / Linux) +./scripts/deploy-docker.sh # Full setup (env + build + deploy) +./scripts/deploy-docker.sh --env-only # Generate .env.docker interactively +./scripts/deploy-docker.sh --build # Build + deploy (needs existing .env.docker) +./scripts/deploy-docker.sh --stop # Stop all containers +./scripts/deploy-docker.sh --logs # Show container logs + +# Local deployment (macOS / Linux) +./scripts/deploy-local.sh # Full setup (env + install + migrate) +./scripts/deploy-local.sh --env-only # Generate .env interactively +./scripts/deploy-local.sh --start # Start app (dev or prod mode) +./scripts/deploy-local.sh --migrate # Run database migrations +./scripts/deploy-local.sh --install # Install npm dependencies + +# Windows PowerShell +.\scripts\deploy-docker.ps1 # Full Docker setup +.\scripts\deploy-docker.ps1 -EnvOnly # Generate .env.docker only +.\scripts\deploy-docker.ps1 -Build # Build + deploy +.\scripts\deploy-docker.ps1 -Stop # Stop containers +.\scripts\deploy-docker.ps1 -Logs # Show logs + +.\scripts\deploy-local.ps1 # Full local setup +.\scripts\deploy-local.ps1 -EnvOnly # Generate .env only +.\scripts\deploy-local.ps1 -Start # Start app +.\scripts\deploy-local.ps1 -Migrate # Run migrations +.\scripts\deploy-local.ps1 -Install # Install dependencies +``` + ### Docker ```bash @@ -828,7 +919,9 @@ npm start npm run lint ``` -### deploy.sh (Deployment Script) +### deploy.sh (Deployment Script - legacy) + +The older `memento-note/deploy.sh` script is still available for the standalone Docker Compose setup: ```bash cd memento-note diff --git a/GUIDE.md b/GUIDE.md index 31d01a7..8055b08 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -145,7 +145,26 @@ Navigateur -> Next.js App Router - PostgreSQL 16 - npm -### Etapes +### Installation rapide (script interactif) + +```bash +git clone https://github.com/votre-user/Momento.git +cd Momento + +# macOS / Linux +./scripts/deploy-local.sh + +# Windows PowerShell +.\scripts\deploy-local.ps1 +``` + +Le script va : +1. Verifier que Node.js, npm et PostgreSQL sont installes +2. Demander la configuration (base de donnees, email admin, provider IA, etc.) +3. Generer le `.env` avec des secrets auto-generes +4. Installer les dependances et lancer les migrations + +### Etapes manuelles ```bash # 1. Cloner le depot @@ -157,7 +176,7 @@ npm install --legacy-peer-deps # 3. Configurer l'environnement cp .env.example .env -# Editer .env avec vos valeurs (DATABASE_URL, NEXTAUTH_SECRET, etc.) +# Editer .env avec vos valeurs (DATABASE_URL, NEXTAUTH_SECRET, ADMIN_EMAIL, etc.) # 4. Creer la base de donnees et appliquer les migrations npx prisma migrate dev @@ -171,15 +190,45 @@ npm run dev ### Premier lancement -1. Creez un compte via la page d'inscription -2. Le premier utilisateur inscrit devient automatiquement admin +1. Inscrivez-vous avec l'email defini dans `ADMIN_EMAIL` +2. Ce compte obtient automatiquement le role **ADMIN** 3. Accedez au panneau admin : `http://localhost:3000/admin/settings` --- ## Deploiement Docker -### Quick Start +### Installation rapide (script interactif) + +```bash +git clone https://github.com/votre-user/Momento.git +cd Momento + +# macOS / Linux +./scripts/deploy-docker.sh + +# Windows PowerShell +.\scripts\deploy-docker.ps1 +``` + +Le script va : +1. Verifier que Docker et Docker Compose sont installes +2. Demander la configuration (URL, email admin, PostgreSQL, provider IA, MCP, email, Ollama, recherche web) +3. Auto-generer `NEXTAUTH_SECRET` et `POSTGRES_PASSWORD` +4. Construire et demarrer tous les conteneurs +5. Lancer les migrations de base de donnees + +**Options du script de deploiement :** + +| Commande | Description | +|----------|-------------| +| `./scripts/deploy-docker.sh` | Setup complet (env + build + deploy) | +| `./scripts/deploy-docker.sh --env-only` | Generer `.env.docker` seulement | +| `./scripts/deploy-docker.sh --build` | Build + deploy (requiert `.env.docker` existant) | +| `./scripts/deploy-docker.sh --stop` | Arreter tous les conteneurs | +| `./scripts/deploy-docker.sh --logs` | Afficher les logs des conteneurs | + +### Setup manuel ```bash # 1. Cloner le depot @@ -189,7 +238,7 @@ cd Momento # 2. Configurer l'environnement cp .env.docker.example .env.docker nano .env.docker -# Modifier NEXTAUTH_URL et NEXTAUTH_SECRET (obligatoire) +# Modifier NEXTAUTH_URL, NEXTAUTH_SECRET et ADMIN_EMAIL (obligatoire) # 3. Lancer les conteneurs docker compose up -d @@ -216,6 +265,7 @@ docker exec memento-ollama ollama pull embeddinggemma |----------|-------------| | `NEXTAUTH_URL` | URL publique de l'app (ex: `http://192.168.1.190:3000`) | | `NEXTAUTH_SECRET` | Secret JWT - generer avec `openssl rand -base64 32` | +| `ADMIN_EMAIL` | Email qui obtient automatiquement le role ADMIN a l'inscription | ### Ports utilises @@ -629,7 +679,9 @@ Les parametres `SMTP_SECURE` et `SMTP_IGNORE_CERT` sont configurables depuis le ### Panneau admin -Accessibles uniquement par le premier utilisateur inscrit (ou les utilisateurs avec le role admin). +Accessibles uniquement aux utilisateurs avec le role admin. + +Le **premier utilisateur inscrit avec l'email defini dans `ADMIN_EMAIL`** obtient automatiquement le role ADMIN. Assurez-vous de definir cette variable dans votre `.env` ou `.env.docker` avant le deploiement. **URL** : `/admin/settings` @@ -679,6 +731,14 @@ Accessibles uniquement par le premier utilisateur inscrit (ou les utilisateurs a | `DATABASE_URL` | - | Connection string PostgreSQL | | `NEXTAUTH_SECRET` | - | Secret JWT (`openssl rand -base64 32`) | | `NEXTAUTH_URL` | `http://localhost:3000` | URL publique de l'app | +| `ADMIN_EMAIL` | - | Email qui obtient automatiquement le role ADMIN a l'inscription | + +#### Inscription et admin + +| Variable | Defaut | Description | +|----------|--------|-------------| +| `ALLOW_REGISTRATION` | `true` | Autoriser l'inscription publique | +| `ADMIN_EMAIL` | - | Email qui obtient le role ADMIN a l'inscription | #### PostgreSQL (Docker) @@ -766,6 +826,37 @@ Accessibles uniquement par le premier utilisateur inscrit (ou les utilisateurs a ## Commandes utiles +### Scripts de deploiement + +```bash +# Deploiement Docker (macOS / Linux) +./scripts/deploy-docker.sh # Setup complet (env + build + deploy) +./scripts/deploy-docker.sh --env-only # Generer .env.docker interactivement +./scripts/deploy-docker.sh --build # Build + deploy (requiert .env.docker existant) +./scripts/deploy-docker.sh --stop # Arreter tous les conteneurs +./scripts/deploy-docker.sh --logs # Afficher les logs des conteneurs + +# Deploiement local (macOS / Linux) +./scripts/deploy-local.sh # Setup complet (env + install + migrate) +./scripts/deploy-local.sh --env-only # Generer .env interactivement +./scripts/deploy-local.sh --start # Demarrer l'app (mode dev ou prod) +./scripts/deploy-local.sh --migrate # Lancer les migrations +./scripts/deploy-local.sh --install # Installer les dependances npm + +# Windows PowerShell +.\scripts\deploy-docker.ps1 # Setup Docker complet +.\scripts\deploy-docker.ps1 -EnvOnly # Generer .env.docker seulement +.\scripts\deploy-docker.ps1 -Build # Build + deploy +.\scripts\deploy-docker.ps1 -Stop # Arreter les conteneurs +.\scripts\deploy-docker.ps1 -Logs # Afficher les logs + +.\scripts\deploy-local.ps1 # Setup local complet +.\scripts\deploy-local.ps1 -EnvOnly # Generer .env seulement +.\scripts\deploy-local.ps1 -Start # Demarrer l'app +.\scripts\deploy-local.ps1 -Migrate # Lancer les migrations +.\scripts\deploy-local.ps1 -Install # Installer les dependances +``` + ### Docker ```bash @@ -829,7 +920,9 @@ npm start npm run lint ``` -### Deploy.sh (script de deploiement) +### deploy.sh (script de deploiement - legacy) + +L'ancien script `memento-note/deploy.sh` reste disponible pour le Docker Compose standalone : ```bash cd memento-note diff --git a/README.fr.md b/README.fr.md index 4513e61..aa08c13 100644 --- a/README.fr.md +++ b/README.fr.md @@ -9,21 +9,24 @@ Une application de prise de notes intelligente et powered by IA. Comme Google Ke ## Fonctionnalites **Notes et organisation** -- Notes texte, checklists et Markdown (avec LaTeX/KaTeX) +- Notes texte riche (style Notion), checklists, Markdown et texte brut - Notebooks avec labels contextuels -- Grille masonry responsive avec drag-and-drop +- Grille masonry responsive et vue onglets (style OneNote) - Upload d'images, partage de notes, archive et corbeille - 10 themes pastel + mode sombre +- Historique des notes avec restauration de versions +- Reorganisation des cartes par drag-and-drop **IA et automatisation** - Recherche semantique par embeddings - Generation automatique de tags et suggestions de titre -- Agents IA configurables avec instructions personnalisees +- Agents IA configurables avec instructions personnalisees et planification - Conversations chat IA persistees - Memory Echo - decouverte de connexions entre notes - Organisation automatique par batch et labels intelligents - Resumes de notebooks generes par IA - Workflows visuels +- Notifications in-app pour les resultats des agents **Integrations** - Serveur MCP avec **22 outils** - connecter Claude Desktop, N8N, ou tout client MCP @@ -44,30 +47,48 @@ Une application de prise de notes intelligente et powered by IA. Comme Google Ke ### Docker (recommande) +Le script de deploiement interactif s'occupe de tout - configuration, build et demarrage : + ```bash git clone https://github.com/yourusername/Momento.git cd Momento -cp .env.docker.example .env.docker +# macOS / Linux +./scripts/deploy-docker.sh -# Modifier ces deux valeurs obligatoires : -# NEXTAUTH_URL="http://VOTRE_IP_SERVEUR:3000" -# NEXTAUTH_SECRET="generer avec : openssl rand -base64 32" - -docker compose up -d +# Windows PowerShell +.\scripts\deploy-docker.ps1 ``` -Ouvrir `http://localhost:3000` - le premier utilisateur inscrit devient admin. +Le script demande votre `ADMIN_EMAIL` (le premier utilisateur inscrit avec cet email obtient le role ADMIN), le provider IA et d'autres parametres. Les secrets sont generes automatiquement. + +Ou manuellement : + +```bash +cp .env.docker.example .env.docker +# Editer .env.docker : definir NEXTAUTH_URL, NEXTAUTH_SECRET et ADMIN_EMAIL +docker compose up -d +``` ### Developpement local ```bash git clone https://github.com/yourusername/Momento.git +cd Momento + +# macOS / Linux +./scripts/deploy-local.sh + +# Windows PowerShell +.\scripts\deploy-local.ps1 +``` + +Ou manuellement : + +```bash cd Momento/memento-note - cp .env.example .env -# Editer .env avec DATABASE_URL, NEXTAUTH_SECRET, etc. - +# Editer .env avec DATABASE_URL, NEXTAUTH_SECRET, ADMIN_EMAIL, etc. npm install --legacy-peer-deps npx prisma migrate dev npm run dev @@ -77,13 +98,18 @@ npm run dev ## Providers IA -Memento supporte trois providers IA, configurables independamment pour les tags, embeddings et chat : +Memento supporte 8 providers IA, configurables independamment pour les tags, embeddings et chat : | Provider | Type | Configuration | |----------|------|---------------| | **Ollama** | Local, gratuit | `docker compose --profile ollama up -d` | | **OpenAI** | Cloud, payant | Definir `OPENAI_API_KEY` | -| **Custom** | OpenRouter, Groq, Together, Mistral... | Definir `CUSTOM_OPENAI_API_KEY` + `CUSTOM_OPENAI_BASE_URL` | +| **DeepSeek** | Cloud, payant | Definir `DEEPSEEK_API_KEY` | +| **OpenRouter** | Cloud, payant | Definir `OPENROUTER_API_KEY` | +| **Mistral AI** | Cloud, payant | Definir `MISTRAL_API_KEY` | +| **Z.AI** | Cloud, payant | Definir `ZAI_API_KEY` | +| **LM Studio** | Local, gratuit | Definir `LMSTUDIO_BASE_URL` | +| **Custom** | Toute API compatible OpenAI | Definir base URL + cle API | Exemple avec Ollama : ```bash @@ -129,7 +155,7 @@ Pour N8N ou clients HTTP, utiliser le mode HTTP Streamable : `http://localhost:3 | Style | Tailwind CSS 4, shadcn/ui | | Base de donnees | PostgreSQL 16, Prisma ORM 5 | | Auth | NextAuth.js v5 | -| IA | Vercel AI SDK (OpenAI, Ollama, Custom) | +| IA | Vercel AI SDK (OpenAI, Ollama, DeepSeek, OpenRouter, Mistral, Z.AI, LM Studio) | | MCP | @modelcontextprotocol/sdk | | Email | Nodemailer (SMTP) / Resend | @@ -147,6 +173,11 @@ Pour le guide complet d'installation, deploiement et configuration, voir **[GUID Momento/ ├── docker-compose.yml # Orchestration multi-conteneurs ├── .env.docker.example # Template environnement Docker +├── scripts/ # Scripts de deploiement +│ ├── deploy-docker.sh # Deploiement Docker (macOS/Linux) +│ ├── deploy-local.sh # Deploiement local (macOS/Linux) +│ ├── deploy-docker.ps1 # Deploiement Docker (Windows) +│ └── deploy-local.ps1 # Deploiement local (Windows) ├── memento-note/ # Application Next.js │ ├── app/ # App Router (pages, actions, API) │ ├── components/ # Composants React @@ -173,6 +204,7 @@ Voir [.env.docker.example](.env.docker.example) pour la liste complete. Variable |----------|--------|-------------| | `NEXTAUTH_URL` | Oui | URL publique de l'app | | `NEXTAUTH_SECRET` | Oui | Secret JWT (`openssl rand -base64 32`) | +| `ADMIN_EMAIL` | Oui | Email qui obtient automatiquement le role ADMIN a l'inscription | | `POSTGRES_PASSWORD` | Rec. | Mot de passe PostgreSQL (defaut : `memento`) | | `AI_PROVIDER_TAGS` | Non | Provider IA pour tags : `ollama`, `openai`, `custom` | | `OPENAI_API_KEY` | Si OpenAI | Cle API OpenAI | diff --git a/README.md b/README.md index a9243c7..2473b78 100644 --- a/README.md +++ b/README.md @@ -9,21 +9,24 @@ A smart, AI-powered note-taking app. Like Google Keep, but with notebooks, seman ## Features **Notes & Organization** -- Text, checklist, and Markdown notes (with LaTeX/KaTeX) +- Rich text (Notion-like), checklist, Markdown, and plain text notes - Notebooks with contextual labels -- Responsive masonry grid with drag-and-drop +- Responsive masonry grid and tabs (OneNote-style) view - Image upload, note sharing, archive, and trash - 10 pastel color themes + dark mode +- Note history with version restore +- Drag-and-drop card reordering **AI & Automation** - Semantic search powered by embeddings - Auto-generated tags and title suggestions -- Configurable AI agents with custom instructions +- Configurable AI agents with custom instructions and scheduling - Persistent AI chat conversations - Memory Echo - discover hidden connections between notes - Batch auto-organization and smart labels - AI-generated notebook summaries - Visual workflow builder +- In-app notifications for agent results **Integrations** - MCP Server with **22 tools** - connect Claude Desktop, N8N, or any MCP client @@ -44,30 +47,48 @@ A smart, AI-powered note-taking app. Like Google Keep, but with notebooks, seman ### Docker (recommended) +The interactive deploy script handles everything - environment config, container build, and startup: + ```bash git clone https://github.com/yourusername/Momento.git cd Momento -cp .env.docker.example .env.docker +# macOS / Linux +./scripts/deploy-docker.sh -# Edit these two required values: -# NEXTAUTH_URL="http://YOUR_SERVER_IP:3000" -# NEXTAUTH_SECRET="generate-with: openssl rand -base64 32" - -docker compose up -d +# Windows PowerShell +.\scripts\deploy-docker.ps1 ``` -Open `http://localhost:3000` - the first registered user becomes admin. +The script will ask for your `ADMIN_EMAIL` (the first user registered with this email gets ADMIN role), AI provider, and other settings. It auto-generates secrets. + +Or manually: + +```bash +cp .env.docker.example .env.docker +# Edit .env.docker: set NEXTAUTH_URL, NEXTAUTH_SECRET, and ADMIN_EMAIL +docker compose up -d +``` ### Local Development ```bash git clone https://github.com/yourusername/Momento.git +cd Momento + +# macOS / Linux +./scripts/deploy-local.sh + +# Windows PowerShell +.\scripts\deploy-local.ps1 +``` + +Or manually: + +```bash cd Momento/memento-note - cp .env.example .env -# Edit .env with your DATABASE_URL, NEXTAUTH_SECRET, etc. - +# Edit .env with your DATABASE_URL, NEXTAUTH_SECRET, ADMIN_EMAIL, etc. npm install --legacy-peer-deps npx prisma migrate dev npm run dev @@ -77,13 +98,18 @@ npm run dev ## AI Providers -Memento supports three AI providers, configurable independently for tags, embeddings, and chat: +Memento supports 8 AI providers, configurable independently for tags, embeddings, and chat: | Provider | Type | Setup | |----------|------|-------| | **Ollama** | Local, free | `docker compose --profile ollama up -d` | | **OpenAI** | Cloud, paid | Set `OPENAI_API_KEY` | -| **Custom** | OpenRouter, Groq, Together, Mistral... | Set `CUSTOM_OPENAI_API_KEY` + `CUSTOM_OPENAI_BASE_URL` | +| **DeepSeek** | Cloud, paid | Set `DEEPSEEK_API_KEY` | +| **OpenRouter** | Cloud, paid | Set `OPENROUTER_API_KEY` | +| **Mistral AI** | Cloud, paid | Set `MISTRAL_API_KEY` | +| **Z.AI** | Cloud, paid | Set `ZAI_API_KEY` | +| **LM Studio** | Local, free | Set `LMSTUDIO_BASE_URL` | +| **Custom** | Any OpenAI-compatible API | Set base URL + API key | Example for Ollama: ```bash @@ -129,7 +155,7 @@ For N8N or HTTP clients, use Streamable HTTP mode: `http://localhost:3001/mcp` w | Styling | Tailwind CSS 4, shadcn/ui | | Database | PostgreSQL 16, Prisma ORM 5 | | Auth | NextAuth.js v5 | -| AI | Vercel AI SDK (OpenAI, Ollama, Custom) | +| AI | Vercel AI SDK (OpenAI, Ollama, DeepSeek, OpenRouter, Mistral, Z.AI, LM Studio) | | MCP | @modelcontextprotocol/sdk | | Email | Nodemailer (SMTP) / Resend | @@ -147,6 +173,11 @@ For the complete installation, deployment, and configuration guide, see **[GUIDE Momento/ ├── docker-compose.yml # Multi-container orchestration ├── .env.docker.example # Docker environment template +├── scripts/ # Deployment scripts +│ ├── deploy-docker.sh # Docker deploy (macOS/Linux) +│ ├── deploy-local.sh # Local deploy (macOS/Linux) +│ ├── deploy-docker.ps1 # Docker deploy (Windows) +│ └── deploy-local.ps1 # Local deploy (Windows) ├── memento-note/ # Next.js application │ ├── app/ # App Router (pages, actions, API) │ ├── components/ # React UI components @@ -173,6 +204,7 @@ See [.env.docker.example](.env.docker.example) for the complete list. Key variab |----------|----------|-------------| | `NEXTAUTH_URL` | Yes | Public URL of the app | | `NEXTAUTH_SECRET` | Yes | JWT secret (`openssl rand -base64 32`) | +| `ADMIN_EMAIL` | Yes | Email that automatically gets ADMIN role on registration | | `POSTGRES_PASSWORD` | Rec. | PostgreSQL password (default: `memento`) | | `AI_PROVIDER_TAGS` | No | AI provider for tags: `ollama`, `openai`, `custom` | | `OPENAI_API_KEY` | If OpenAI | Your OpenAI API key | diff --git a/docker-compose.yml b/docker-compose.yml index 85f5361..424e444 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,7 @@ services: - DATABASE_URL=postgresql://${POSTGRES_USER:-memento}:${POSTGRES_PASSWORD:-memento}@postgres:5432/${POSTGRES_DB:-memento} - NODE_ENV=production - NEXT_TELEMETRY_DISABLED=1 + # ADMIN_EMAIL comes from .env.docker via env_file directive above volumes: - uploads-data:/app/data/uploads - backup-data:/app/data/backups diff --git a/memento-note/.env.example b/memento-note/.env.example index fdc95ae..f249374 100644 --- a/memento-note/.env.example +++ b/memento-note/.env.example @@ -11,11 +11,14 @@ NEXTAUTH_SECRET="generate-with-openssl-rand-base64-32" NEXTAUTH_URL="http://localhost:3000" # ----------------------------------------------------------------------------- -# Registration +# Registration & Admin # ----------------------------------------------------------------------------- # Set to "false" to disable public registration (default: true) # ALLOW_REGISTRATION="true" +# Admin email - The first user registering with this email gets ADMIN role (REQUIRED) +# ADMIN_EMAIL="admin@yourdomain.com" + # ----------------------------------------------------------------------------- # AI Providers # ----------------------------------------------------------------------------- diff --git a/memento-note/Dockerfile b/memento-note/Dockerfile index 437dae5..db4b13d 100644 --- a/memento-note/Dockerfile +++ b/memento-note/Dockerfile @@ -51,7 +51,7 @@ RUN useradd --system --uid 1001 --gid nodejs nextjs COPY --from=builder --chown=nextjs:nodejs /app/public ./public # Upload directory (outside public/ — served via API route) -RUN mkdir -p ./data/uploads/notes && chown -R nextjs:nodejs ./data +RUN mkdir -p ./data/uploads/notes ./data/backups && chown -R nextjs:nodejs ./data # Next.js standalone output COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ diff --git a/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx b/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx index e7b120a..9825faf 100644 --- a/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx +++ b/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx @@ -12,20 +12,72 @@ import { useState, useEffect, useCallback } from 'react' import { TestTube, ExternalLink, RefreshCw } from 'lucide-react' import { useLanguage } from '@/lib/i18n' -type AIProvider = 'ollama' | 'openai' | 'custom' +type AIProvider = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter' | 'mistral' | 'zai' | 'lmstudio' -interface AvailableModels { - tags: string[] - embeddings: string[] +// Provider config metadata +const PROVIDER_META: Record = { + ollama: { apiKeyLabel: '', baseUrlLabel: 'admin.ai.baseUrl', hasApiKey: false, hasBaseUrl: true, isLocal: true }, + openai: { apiKeyLabel: 'OPENAI_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false }, + deepseek: { apiKeyLabel: 'DEEPSEEK_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false }, + openrouter:{ apiKeyLabel: 'OPENROUTER_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false }, + mistral: { apiKeyLabel: 'MISTRAL_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false }, + zai: { apiKeyLabel: 'ZAI_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false }, + lmstudio: { apiKeyLabel: '', baseUrlLabel: 'admin.ai.baseUrl', hasApiKey: false, hasBaseUrl: true, isLocal: true }, + custom: { apiKeyLabel: 'CUSTOM_OPENAI_API_KEY', baseUrlLabel: 'admin.ai.baseUrl', hasApiKey: true, hasBaseUrl: true, isLocal: false }, } -const MODELS_2026 = { - openai: { - tags: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'], - embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002'] - }, +// Config key names for each provider's API key (used to read/save from config) +const API_KEY_CONFIG: Record = { + ollama: '', + openai: 'OPENAI_API_KEY', + deepseek: 'DEEPSEEK_API_KEY', + openrouter: 'OPENROUTER_API_KEY', + mistral: 'MISTRAL_API_KEY', + zai: 'ZAI_API_KEY', + lmstudio: 'LMSTUDIO_API_KEY', + custom: 'CUSTOM_OPENAI_API_KEY', } +const BASE_URL_CONFIG: Record = { + ollama: 'OLLAMA_BASE_URL', + openai: '', + deepseek: '', + openrouter: '', + mistral: '', + zai: '', + lmstudio: 'LMSTUDIO_BASE_URL', + custom: 'CUSTOM_OPENAI_BASE_URL', +} + +const DEFAULT_BASE_URLS: Record = { + ollama: 'http://localhost:11434', + openai: '', + deepseek: 'https://api.deepseek.com/v1', + openrouter: 'https://openrouter.ai/api/v1', + mistral: 'https://api.mistral.ai/v1', + zai: 'https://api.zukijourney.com/v1', + lmstudio: 'http://localhost:1234/v1', + custom: '', +} + +// Suggested models per provider (shown as hints in Combobox - user can always type a custom name) +const SUGGESTED_MODELS: Record = { + openai: ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'gpt-4o', 'gpt-4o-mini', 'o3-mini', 'o4-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'], + openrouter: ['openai/gpt-4o-mini', 'openai/gpt-4.1-mini', 'anthropic/claude-sonnet-4', 'google/gemini-2.5-flash-preview', 'google/gemma-4-26b-a4b-it', 'meta-llama/llama-4-maverick', 'deepseek/deepseek-chat-v3-0324'], + deepseek: ['deepseek-chat', 'deepseek-reasoner'], + mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest', 'codestral-latest', 'mistral-embed'], + zai: ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4o', 'gpt-4o-mini', 'claude-sonnet-4', 'gemini-2.5-flash'], +} + +const SUGGESTED_EMBEDDINGS: Record = { + openai: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002'], + openrouter: ['openai/text-embedding-3-small'], + mistral: ['mistral-embed'], + zai: ['text-embedding-3-small', 'text-embedding-3-large'], +} + +type ModelPurpose = 'tags' | 'embeddings' | 'chat' + export function AdminSettingsForm({ config }: { config: Record }) { const { t } = useLanguage() const [isSaving, setIsSaving] = useState(false) @@ -50,123 +102,117 @@ export function AdminSettingsForm({ config }: { config: Record } const [embeddingsProvider, setEmbeddingsProvider] = useState((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama') const [chatProvider, setChatProvider] = useState((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama') - // Selected Models State (Controlled Inputs) + // Selected Models State const [selectedTagsModel, setSelectedTagsModel] = useState(config.AI_MODEL_TAGS || '') const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState(config.AI_MODEL_EMBEDDING || '') const [selectedChatModel, setSelectedChatModel] = useState(config.AI_MODEL_CHAT || '') + // Dynamic Models State (for local providers with /api/tags or /v1/models endpoints) + const [dynamicModels, setDynamicModels] = useState>({ + tags: [], + embeddings: [], + chat: [], + }) + const [isLoadingModels, setIsLoadingModels] = useState>({ + tags: false, + embeddings: false, + chat: false, + }) - // Dynamic Models State - const [ollamaTagsModels, setOllamaTagsModels] = useState([]) - const [ollamaEmbeddingsModels, setOllamaEmbeddingsModels] = useState([]) - const [ollamaChatModels, setOllamaChatModels] = useState([]) - const [isLoadingTagsModels, setIsLoadingTagsModels] = useState(false) - const [isLoadingEmbeddingsModels, setIsLoadingEmbeddingsModels] = useState(false) - const [isLoadingChatModels, setIsLoadingChatModels] = useState(false) - - // Custom provider dynamic models - const [customTagsModels, setCustomTagsModels] = useState([]) - const [customEmbeddingsModels, setCustomEmbeddingsModels] = useState([]) - const [customChatModels, setCustomChatModels] = useState([]) - const [isLoadingCustomTagsModels, setIsLoadingCustomTagsModels] = useState(false) - const [isLoadingCustomEmbeddingsModels, setIsLoadingCustomEmbeddingsModels] = useState(false) - const [isLoadingCustomChatModels, setIsLoadingCustomChatModels] = useState(false) - - // Fetch Ollama models via Route API (not Server Action) to avoid App Router - // action queue dispatch during render, which causes React Error #310. - const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings' | 'chat', url: string) => { - if (!url) return - - if (type === 'tags') setIsLoadingTagsModels(true) - else if (type === 'embeddings') setIsLoadingEmbeddingsModels(true) - else setIsLoadingChatModels(true) - + // Fetch models from local providers (Ollama, LM Studio) or cloud providers with /v1/models + const fetchModels = useCallback(async (purpose: ModelPurpose, provider: AIProvider, url: string, apiKey?: string) => { + setIsLoadingModels(prev => ({ ...prev, [purpose]: true })) try { - const params = new URLSearchParams({ type: 'ollama', url }) - const res = await fetch(`/api/admin/models?${params}`) - const result = await res.json() - - if (result.success) { - if (type === 'tags') setOllamaTagsModels(result.models) - else if (type === 'embeddings') setOllamaEmbeddingsModels(result.models) - else setOllamaChatModels(result.models) - } else { - toast.error(`${t('admin.ai.fetchModelsFailed')}: ${result.error}`) - } - } catch (error) { - console.error(error) - toast.error(t('admin.ai.fetchModelsFailed')) - } finally { - if (type === 'tags') setIsLoadingTagsModels(false) - else if (type === 'embeddings') setIsLoadingEmbeddingsModels(false) - else setIsLoadingChatModels(false) - } - }, []) - - // Fetch Custom provider models via Route API (not Server Action). - const fetchCustomModels = useCallback(async (type: 'tags' | 'embeddings' | 'chat', url: string, apiKey?: string) => { - if (!url) return - - if (type === 'tags') setIsLoadingCustomTagsModels(true) - else if (type === 'embeddings') setIsLoadingCustomEmbeddingsModels(true) - else setIsLoadingCustomChatModels(true) - - try { - const params = new URLSearchParams({ type: 'custom', url, kind: type }) + const params = new URLSearchParams({ type: provider === 'ollama' ? 'ollama' : 'custom', url, kind: purpose }) if (apiKey) params.set('key', apiKey) const res = await fetch(`/api/admin/models?${params}`) const result = await res.json() if (result.success && result.models.length > 0) { - if (type === 'tags') setCustomTagsModels(result.models) - else if (type === 'embeddings') setCustomEmbeddingsModels(result.models) - else setCustomChatModels(result.models) - } else { + setDynamicModels(prev => ({ ...prev, [purpose]: result.models })) + } else if (!result.success) { toast.error(`${t('admin.ai.fetchModelsFailed')}: ${result.error}`) } } catch (error) { console.error(error) toast.error(t('admin.ai.fetchModelsFailed')) } finally { - if (type === 'tags') setIsLoadingCustomTagsModels(false) - else if (type === 'embeddings') setIsLoadingCustomEmbeddingsModels(false) - else setIsLoadingCustomChatModels(false) + setIsLoadingModels(prev => ({ ...prev, [purpose]: false })) } - }, []) + }, [t]) - // Single consolidated effect for initial model fetching. - // Batching all provider checks into ONE effect prevents multiple Server Actions - // from being dispatched simultaneously, which would cause React Error #310 by - // flooding the App Router's action queue during an ongoing navigation transition. + // Get the API URL for fetching models for a given provider + const getModelFetchUrl = useCallback((provider: AIProvider): string => { + if (provider === 'ollama') { + const input = document.getElementById(`BASE_URL_${provider}`) as HTMLInputElement + return input?.value || config.OLLAMA_BASE_URL || 'http://localhost:11434' + } + if (provider === 'lmstudio') { + const input = document.getElementById('BASE_URL_lmstudio') as HTMLInputElement + return input?.value || config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1' + } + // Cloud providers have known URLs + return DEFAULT_BASE_URLS[provider] || '' + }, [config]) + + const getApiKey = useCallback((provider: AIProvider, purpose: ModelPurpose): string => { + const configKey = API_KEY_CONFIG[provider] + if (!configKey) return '' + const input = document.getElementById(`API_KEY_${provider}_${purpose}`) as HTMLInputElement + return input?.value || config[configKey] || '' + }, [config]) + + // Initial model fetch useEffect(() => { - const fetchInitialModels = async () => { - const ollamaBase = config.OLLAMA_BASE_URL || 'http://localhost:11434' - const customUrl = config.CUSTOM_OPENAI_BASE_URL || '' - const customKey = config.CUSTOM_OPENAI_API_KEY || '' - + const fetchInitial = async () => { if (tagsProvider === 'ollama') { - await fetchOllamaModels('tags', config.OLLAMA_BASE_URL_TAGS || ollamaBase) - } else if (tagsProvider === 'custom' && customUrl) { - await fetchCustomModels('tags', customUrl, customKey) + await fetchModels('tags', 'ollama', config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434') + } else if (tagsProvider === 'lmstudio') { + await fetchModels('tags', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1') + } else if (PROVIDER_META[tagsProvider]?.hasApiKey) { + const url = DEFAULT_BASE_URLS[tagsProvider] + const key = config[API_KEY_CONFIG[tagsProvider]] || '' + if (url && key) await fetchModels('tags', tagsProvider, url, key) } if (embeddingsProvider === 'ollama') { - await fetchOllamaModels('embeddings', config.OLLAMA_BASE_URL_EMBEDDING || ollamaBase) - } else if (embeddingsProvider === 'custom' && customUrl) { - await fetchCustomModels('embeddings', customUrl, customKey) + await fetchModels('embeddings', 'ollama', config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434') + } else if (embeddingsProvider === 'lmstudio') { + await fetchModels('embeddings', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1') + } else if (PROVIDER_META[embeddingsProvider]?.hasApiKey) { + const url = DEFAULT_BASE_URLS[embeddingsProvider] + const key = config[API_KEY_CONFIG[embeddingsProvider]] || '' + if (url && key) await fetchModels('embeddings', embeddingsProvider, url, key) } if (chatProvider === 'ollama') { - await fetchOllamaModels('chat', config.OLLAMA_BASE_URL_CHAT || ollamaBase) - } else if (chatProvider === 'custom' && customUrl) { - await fetchCustomModels('chat', customUrl, customKey) + await fetchModels('chat', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434') + } else if (chatProvider === 'lmstudio') { + await fetchModels('chat', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1') + } else if (PROVIDER_META[chatProvider]?.hasApiKey) { + const url = DEFAULT_BASE_URLS[chatProvider] + const key = config[API_KEY_CONFIG[chatProvider]] || '' + if (url && key) await fetchModels('chat', chatProvider, url, key) } } - - fetchInitialModels() + fetchInitial() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // Build model options for Combobox: dynamic models + current saved + suggested + const buildModelOptions = useCallback((purpose: ModelPurpose, provider: AIProvider, currentValue: string) => { + const dynamic = dynamicModels[purpose] || [] + const suggested = purpose === 'embeddings' ? (SUGGESTED_EMBEDDINGS[provider] || []) : (SUGGESTED_MODELS[provider] || []) + + const allModels = new Set() + dynamic.forEach(m => allModels.add(m)) + suggested.forEach(m => allModels.add(m)) + if (currentValue) allModels.add(currentValue) + + const options = Array.from(allModels).sort().map(m => ({ value: m, label: m })) + return options + }, [dynamicModels]) + const handleSaveSecurity = async (formData: FormData) => { setIsSaving(true) const data = { @@ -188,65 +234,88 @@ export function AdminSettingsForm({ config }: { config: Record } const data: Record = {} try { + // Tags provider const tagsProv = formData.get('AI_PROVIDER_TAGS') as AIProvider if (!tagsProv) throw new Error(t('admin.ai.providerTagsRequired')) data.AI_PROVIDER_TAGS = tagsProv - const tagsModel = formData.get(`AI_MODEL_TAGS_${tagsProv.toUpperCase()}`) as string + const tagsModel = formData.get('AI_MODEL_TAGS') as string if (tagsModel) data.AI_MODEL_TAGS = tagsModel + // Save provider-specific config for tags if (tagsProv === 'ollama') { - const ollamaUrl = formData.get('OLLAMA_BASE_URL_TAGS') as string + const ollamaUrl = formData.get('BASE_URL_ollama_tags') as string if (ollamaUrl) data.OLLAMA_BASE_URL_TAGS = ollamaUrl - } else if (tagsProv === 'openai') { - const openaiKey = formData.get('OPENAI_API_KEY') as string - if (openaiKey) data.OPENAI_API_KEY = openaiKey - } else if (tagsProv === 'custom') { - const customKey = formData.get('CUSTOM_OPENAI_API_KEY_TAGS') as string - const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_TAGS') as string - if (customKey) data.CUSTOM_OPENAI_API_KEY = customKey - if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl + } else if (tagsProv === 'lmstudio') { + const url = formData.get('BASE_URL_lmstudio_tags') as string + if (url) data.LMSTUDIO_BASE_URL = url + } else { + const apiKeyConfigKey = API_KEY_CONFIG[tagsProv] + if (apiKeyConfigKey) { + const apiKey = formData.get(`API_KEY_${tagsProv}_tags`) as string + if (apiKey) data[apiKeyConfigKey] = apiKey + } + const baseUrlConfigKey = BASE_URL_CONFIG[tagsProv] + if (baseUrlConfigKey) { + const baseUrl = formData.get(`BASE_URL_${tagsProv}_tags`) as string + if (baseUrl) data[baseUrlConfigKey] = baseUrl + } } + // Embeddings provider const embedProv = formData.get('AI_PROVIDER_EMBEDDING') as AIProvider if (!embedProv) throw new Error(t('admin.ai.providerEmbeddingRequired')) data.AI_PROVIDER_EMBEDDING = embedProv - const embedModel = formData.get(`AI_MODEL_EMBEDDING_${embedProv.toUpperCase()}`) as string + const embedModel = formData.get('AI_MODEL_EMBEDDING') as string if (embedModel) data.AI_MODEL_EMBEDDING = embedModel + // Save provider-specific config for embeddings if (embedProv === 'ollama') { - const ollamaUrl = formData.get('OLLAMA_BASE_URL_EMBEDDING') as string + const ollamaUrl = formData.get('BASE_URL_ollama_embeddings') as string if (ollamaUrl) data.OLLAMA_BASE_URL_EMBEDDING = ollamaUrl - } else if (embedProv === 'openai') { - const openaiKey = formData.get('OPENAI_API_KEY') as string - if (openaiKey) data.OPENAI_API_KEY = openaiKey - } else if (embedProv === 'custom') { - const customKey = formData.get('CUSTOM_OPENAI_API_KEY_EMBEDDING') as string - const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_EMBEDDING') as string - if (customKey) data.CUSTOM_OPENAI_API_KEY = customKey - if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl + } else if (embedProv === 'lmstudio') { + const url = formData.get('BASE_URL_lmstudio_embeddings') as string + if (url) data.LMSTUDIO_BASE_URL = url + } else { + const apiKeyConfigKey = API_KEY_CONFIG[embedProv] + if (apiKeyConfigKey) { + const apiKey = formData.get(`API_KEY_${embedProv}_embeddings`) as string + if (apiKey) data[apiKeyConfigKey] = apiKey + } + const baseUrlConfigKey = BASE_URL_CONFIG[embedProv] + if (baseUrlConfigKey) { + const baseUrl = formData.get(`BASE_URL_${embedProv}_embeddings`) as string + if (baseUrl) data[baseUrlConfigKey] = baseUrl + } } - // Chat provider config + // Chat provider const chatProv = formData.get('AI_PROVIDER_CHAT') as AIProvider if (chatProv) { data.AI_PROVIDER_CHAT = chatProv - const chatModel = formData.get(`AI_MODEL_CHAT_${chatProv.toUpperCase()}`) as string + const chatModel = formData.get('AI_MODEL_CHAT') as string if (chatModel) data.AI_MODEL_CHAT = chatModel + // Save provider-specific config for chat if (chatProv === 'ollama') { - const ollamaUrl = formData.get('OLLAMA_BASE_URL_CHAT') as string + const ollamaUrl = formData.get('BASE_URL_ollama_chat') as string if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl - } else if (chatProv === 'openai') { - const openaiKey = formData.get('OPENAI_API_KEY') as string - if (openaiKey) data.OPENAI_API_KEY = openaiKey - } else if (chatProv === 'custom') { - const customKey = formData.get('CUSTOM_OPENAI_API_KEY_CHAT') as string - const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_CHAT') as string - if (customKey) data.CUSTOM_OPENAI_API_KEY = customKey - if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl + } else if (chatProv === 'lmstudio') { + const url = formData.get('BASE_URL_lmstudio_chat') as string + if (url) data.LMSTUDIO_BASE_URL = url + } else { + const apiKeyConfigKey = API_KEY_CONFIG[chatProv] + if (apiKeyConfigKey) { + const apiKey = formData.get(`API_KEY_${chatProv}_chat`) as string + if (apiKey) data[apiKeyConfigKey] = apiKey + } + const baseUrlConfigKey = BASE_URL_CONFIG[chatProv] + if (baseUrlConfigKey) { + const baseUrl = formData.get(`BASE_URL_${chatProv}_chat`) as string + if (baseUrl) data[baseUrlConfigKey] = baseUrl + } } } @@ -351,6 +420,131 @@ export function AdminSettingsForm({ config }: { config: Record } } } + // Renders the provider config fields (API key, base URL) for a given provider and purpose + const renderProviderConfig = (provider: AIProvider, purpose: ModelPurpose, currentValue: string, onModelChange: (v: string) => void) => { + const meta = PROVIDER_META[provider] + const loading = isLoadingModels[purpose] + + return ( +
+ {/* API Key field */} + {meta.hasApiKey && ( +
+ + +
+ )} + + {/* Base URL field */} + {meta.hasBaseUrl && ( +
+ +
+ + +
+
+ )} + + {/* API Key field for cloud providers without base URL - refresh button inline */} + {meta.hasApiKey && !meta.hasBaseUrl && ( +
+
+ {/* API key already rendered above */} +
+ +
+ )} + + {/* Model selection - always editable Combobox */} +
+ + +

+ {loading + ? t('admin.ai.fetchingModels') + : dynamicModels[purpose].length > 0 + ? t('admin.ai.modelsAvailable', { count: dynamicModels[purpose].length }) + : provider === 'ollama' || provider === 'lmstudio' + ? t('admin.ai.selectOllamaModel') + : t('admin.ai.enterUrlToLoad')} +

+
+
+ ) + } + + // Provider dropdown options + const providerOptions = [ + { value: 'ollama', label: t('admin.ai.providerOllamaOption') }, + { value: 'openai', label: t('admin.ai.providerOpenAIOption') }, + { value: 'deepseek', label: t('admin.ai.providerDeepSeekOption') }, + { value: 'openrouter', label: t('admin.ai.providerOpenRouterOption') }, + { value: 'mistral', label: t('admin.ai.providerMistralOption') }, + { value: 'zai', label: t('admin.ai.providerZAIOption') }, + { value: 'lmstudio', label: t('admin.ai.providerLMStudioOption') }, + { value: 'custom', label: t('admin.ai.providerCustomOption') }, + ] + return (
@@ -389,7 +583,9 @@ export function AdminSettingsForm({ config }: { config: Record } {t('admin.ai.description')}
{ e.preventDefault(); handleSaveAI(new FormData(e.currentTarget)) }}> -
+ + {/* Tags Generation Provider */} +

🏷️ {t('admin.ai.tagsGenerationProvider')}

@@ -402,156 +598,23 @@ export function AdminSettingsForm({ config }: { config: Record } name="AI_PROVIDER_TAGS" value={tagsProvider} onChange={(e) => { - const newProvider = e.target.value as AIProvider - setTagsProvider(newProvider) - const defaultModels: Record = { - ollama: '', - openai: MODELS_2026.openai.tags[0], - custom: '', - } - setSelectedTagsModel(defaultModels[newProvider] || '') + setTagsProvider(e.target.value as AIProvider) + setSelectedTagsModel('') + setDynamicModels(prev => ({ ...prev, tags: [] })) }} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" > - - - + {providerOptions.map(opt => ( + + ))}
- {tagsProvider === 'ollama' && ( -
-
- -
- - -
-
-
- - - 0 - ? ollamaTagsModels.map((m) => ({ value: m, label: m })) - : selectedTagsModel - ? [{ value: selectedTagsModel, label: `${selectedTagsModel} (${t('admin.ai.saved')})` }] - : [] - } - value={selectedTagsModel} - onChange={setSelectedTagsModel} - placeholder={selectedTagsModel || t('admin.ai.clickToLoadModels')} - searchPlaceholder={t('admin.ai.searchModel')} - emptyMessage={t('admin.ai.noModels')} - /> -

- {isLoadingTagsModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectOllamaModel')} -

-
-
- )} - - {tagsProvider === 'openai' && ( -
-
- - -

{t('admin.ai.openAIKeyDescription')}

-
-
- - -

gpt-4o-mini = {t('admin.ai.bestValue')} • gpt-4o = {t('admin.ai.bestQuality')}

-
-
- )} - - {tagsProvider === 'custom' && ( -
-
- -
- - -
-
-
- - -
-
- - - 0 - ? customTagsModels.map((m) => ({ value: m, label: m })) - : selectedTagsModel - ? [{ value: selectedTagsModel, label: selectedTagsModel }] - : [] - } - value={selectedTagsModel} - onChange={setSelectedTagsModel} - placeholder={selectedTagsModel || t('admin.ai.clickToLoadModels')} - searchPlaceholder={t('admin.ai.searchModel')} - emptyMessage={t('admin.ai.noModels')} - /> -

- {isLoadingCustomTagsModels - ? t('admin.ai.fetchingModels') - : customTagsModels.length > 0 - ? t('admin.ai.modelsAvailable', { count: customTagsModels.length }) - : t('admin.ai.enterUrlToLoad')} -

-
-
- )} + + {renderProviderConfig(tagsProvider, 'tags', selectedTagsModel, setSelectedTagsModel)}
+ {/* Embeddings Provider */}

🔍 {t('admin.ai.embeddingsProvider')} @@ -570,157 +633,23 @@ export function AdminSettingsForm({ config }: { config: Record } name="AI_PROVIDER_EMBEDDING" value={embeddingsProvider} onChange={(e) => { - const newProvider = e.target.value as AIProvider - setEmbeddingsProvider(newProvider) - const defaultModels: Record = { - ollama: '', - openai: MODELS_2026.openai.embeddings[0], - custom: '', - } - setSelectedEmbeddingModel(defaultModels[newProvider] || '') + setEmbeddingsProvider(e.target.value as AIProvider) + setSelectedEmbeddingModel('') + setDynamicModels(prev => ({ ...prev, embeddings: [] })) }} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" > - - - + {providerOptions.map(opt => ( + + ))}

- {embeddingsProvider === 'ollama' && ( -
-
- -
- - -
-
-
- - - 0 - ? ollamaEmbeddingsModels.map((m) => ({ value: m, label: m })) - : selectedEmbeddingModel - ? [{ value: selectedEmbeddingModel, label: `${selectedEmbeddingModel} (${t('admin.ai.saved')})` }] - : [] - } - value={selectedEmbeddingModel} - onChange={setSelectedEmbeddingModel} - placeholder={selectedEmbeddingModel || t('admin.ai.clickToLoadModels')} - searchPlaceholder={t('admin.ai.searchModel')} - emptyMessage={t('admin.ai.noModels')} - /> -

- {isLoadingEmbeddingsModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectEmbeddingModel')} -

-
-
- )} - - {embeddingsProvider === 'openai' && ( -
-
- - -

{t('admin.ai.openAIKeyDescription')}

-
-
- - -

text-embedding-3-small = {t('admin.ai.bestValue')} • text-embedding-3-large = {t('admin.ai.bestQuality')}

-
-
- )} - - {embeddingsProvider === 'custom' && ( -
-
- -
- - -
-
-
- - -
-
- - - 0 - ? customEmbeddingsModels.map((m) => ({ value: m, label: m })) - : selectedEmbeddingModel - ? [{ value: selectedEmbeddingModel, label: selectedEmbeddingModel }] - : [] - } - value={selectedEmbeddingModel} - onChange={setSelectedEmbeddingModel} - placeholder={selectedEmbeddingModel || t('admin.ai.clickToLoadModels')} - searchPlaceholder={t('admin.ai.searchModel')} - emptyMessage={t('admin.ai.noModels')} - /> -

- {isLoadingCustomEmbeddingsModels - ? t('admin.ai.fetchingModels') - : customEmbeddingsModels.length > 0 - ? t('admin.ai.modelsAvailable', { count: customEmbeddingsModels.length }) - : t('admin.ai.enterUrlToLoad')} -

-
-
- )} + + {renderProviderConfig(embeddingsProvider, 'embeddings', selectedEmbeddingModel, setSelectedEmbeddingModel)}
- {/* Chat Provider Section */} + {/* Chat Provider */}

💬 {t('admin.ai.chatProvider')} @@ -734,154 +663,20 @@ export function AdminSettingsForm({ config }: { config: Record } name="AI_PROVIDER_CHAT" value={chatProvider} onChange={(e) => { - const newProvider = e.target.value as AIProvider - setChatProvider(newProvider) - const defaultModels: Record = { - ollama: '', - openai: MODELS_2026.openai.tags[0], - custom: '', - } - setSelectedChatModel(defaultModels[newProvider] || '') + setChatProvider(e.target.value as AIProvider) + setSelectedChatModel('') + setDynamicModels(prev => ({ ...prev, chat: [] })) }} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" > - - - + {providerOptions.map(opt => ( + + ))}

- {chatProvider === 'ollama' && ( -
-
- -
- - -
-
-
- - - 0 - ? ollamaChatModels.map((m) => ({ value: m, label: m })) - : selectedChatModel - ? [{ value: selectedChatModel, label: `${selectedChatModel} (${t('admin.ai.saved')})` }] - : [] - } - value={selectedChatModel} - onChange={setSelectedChatModel} - placeholder={selectedChatModel || t('admin.ai.clickToLoadModels')} - searchPlaceholder={t('admin.ai.searchModel')} - emptyMessage={t('admin.ai.noModels')} - /> -

- {isLoadingChatModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectOllamaModel')} -

-
-
- )} - - {chatProvider === 'openai' && ( -
-
- - -

{t('admin.ai.openAIKeyDescription')}

-
-
- - -

gpt-4o-mini = {t('admin.ai.bestValue')} • gpt-4o = {t('admin.ai.bestQuality')}

-
-
- )} - - {chatProvider === 'custom' && ( -
-
- -
- - -
-
-
- - -
-
- - - 0 - ? customChatModels.map((m) => ({ value: m, label: m })) - : selectedChatModel - ? [{ value: selectedChatModel, label: selectedChatModel }] - : [] - } - value={selectedChatModel} - onChange={setSelectedChatModel} - placeholder={selectedChatModel || t('admin.ai.clickToLoadModels')} - searchPlaceholder={t('admin.ai.searchModel')} - emptyMessage={t('admin.ai.noModels')} - /> -

- {isLoadingCustomChatModels - ? t('admin.ai.fetchingModels') - : customChatModels.length > 0 - ? t('admin.ai.modelsAvailable', { count: customChatModels.length }) - : t('admin.ai.enterUrlToLoad')} -

-
-
- )} + + {renderProviderConfig(chatProvider, 'chat', selectedChatModel, setSelectedChatModel)} @@ -1083,7 +878,7 @@ export function AdminSettingsForm({ config }: { config: Record }

{t('admin.tools.jinaKeyDescription')}

- {/* Résultat du test */} + {/* Search test result */} {searchTestResult && (
diff --git a/memento-note/app/(admin)/layout.tsx b/memento-note/app/(admin)/layout.tsx index 8742b00..6313056 100644 --- a/memento-note/app/(admin)/layout.tsx +++ b/memento-note/app/(admin)/layout.tsx @@ -1,5 +1,6 @@ import { AdminProvidersWrapper } from '@/components/admin-providers-wrapper' -import { detectUserLanguage } from '@/lib/i18n/detect-user-language' +import { headers } from 'next/headers' +import { detectUserLanguage, parseAcceptLanguage } from '@/lib/i18n/detect-user-language' import { loadTranslations } from '@/lib/i18n/load-translations' // No here intentionally: combining a Suspense boundary with @@ -10,7 +11,9 @@ export default async function AdminGroupLayout({ }: { children: React.ReactNode }) { - const initialLanguage = await detectUserLanguage() + const headersList = await headers() + const browserLang = parseAcceptLanguage(headersList.get('accept-language')) + const initialLanguage = await detectUserLanguage(browserLang) const initialTranslations = await loadTranslations(initialLanguage) return ( diff --git a/memento-note/app/(main)/layout.tsx b/memento-note/app/(main)/layout.tsx index 726ead3..08947ff 100644 --- a/memento-note/app/(main)/layout.tsx +++ b/memento-note/app/(main)/layout.tsx @@ -3,7 +3,8 @@ import { HeaderWrapper } from "@/components/header-wrapper"; import { Sidebar } from "@/components/sidebar"; import { ProvidersWrapper } from "@/components/providers-wrapper"; import { auth } from "@/auth"; -import { detectUserLanguage } from "@/lib/i18n/detect-user-language"; +import { headers } from "next/headers"; +import { detectUserLanguage, parseAcceptLanguage } from "@/lib/i18n/detect-user-language"; import { loadTranslations } from "@/lib/i18n/load-translations"; import { getAISettings } from "@/app/actions/ai-settings"; @@ -14,10 +15,14 @@ export default async function MainLayout({ }: Readonly<{ children: React.ReactNode; }>) { + // Read browser language hint from Accept-Language header + const headersList = await headers(); + const browserLang = parseAcceptLanguage(headersList.get("accept-language")); + // Run auth + language detection + translation loading in parallel const [session, initialLanguage] = await Promise.all([ auth(), - detectUserLanguage(), + detectUserLanguage(browserLang), ]); // Load initial translations server-side to prevent hydration mismatch diff --git a/memento-note/app/(main)/settings/appearance/appearance-settings-client.tsx b/memento-note/app/(main)/settings/appearance/appearance-settings-client.tsx index 1ab7eab..1b3cba1 100644 --- a/memento-note/app/(main)/settings/appearance/appearance-settings-client.tsx +++ b/memento-note/app/(main)/settings/appearance/appearance-settings-client.tsx @@ -12,14 +12,16 @@ interface AppearanceSettingsClientProps { initialTheme: string initialNotesViewMode: 'masonry' | 'tabs' initialCardSizeMode?: 'variable' | 'uniform' + initialFontFamily?: string } -export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode, initialCardSizeMode = 'variable' }: AppearanceSettingsClientProps) { +export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode, initialCardSizeMode = 'variable', initialFontFamily = 'inter' }: AppearanceSettingsClientProps) { const { t } = useLanguage() const [theme, setTheme] = useState(initialTheme || 'light') const [fontSize, setFontSize] = useState(initialFontSize || 'medium') const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs'>(initialNotesViewMode) const [cardSizeMode, setCardSizeMode] = useState<'variable' | 'uniform'>(initialCardSizeMode) + const [fontFamily, setFontFamily] = useState(initialFontFamily) const handleThemeChange = async (value: string) => { setTheme(value) @@ -69,6 +71,20 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia toast.success(t('settings.settingsSaved') || 'Saved') } + const handleFontFamilyChange = async (value: string) => { + const font = value === 'system' ? 'system' : 'inter' + setFontFamily(font) + localStorage.setItem('font-family', font) + const root = document.documentElement + if (font === 'system') { + root.classList.add('font-system') + } else { + root.classList.remove('font-system') + } + await updateAISettings({ fontFamily: font }) + toast.success(t('settings.settingsSaved') || 'Saved') + } + return (
@@ -116,6 +132,23 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia /> + 🔤} + description={t('appearance.fontFamilyDescription') || 'Choose the font used throughout the app'} + > + + + 📋} diff --git a/memento-note/app/(main)/settings/appearance/page.tsx b/memento-note/app/(main)/settings/appearance/page.tsx index c602033..6bbeaec 100644 --- a/memento-note/app/(main)/settings/appearance/page.tsx +++ b/memento-note/app/(main)/settings/appearance/page.tsx @@ -21,6 +21,7 @@ export default async function AppearanceSettingsPage() { initialTheme={userSettings.theme} initialNotesViewMode={aiSettings.notesViewMode === 'masonry' ? 'masonry' : 'tabs'} initialCardSizeMode={userSettings.cardSizeMode} + initialFontFamily={aiSettings.fontFamily || 'inter'} /> ) } diff --git a/memento-note/app/actions/admin-settings.ts b/memento-note/app/actions/admin-settings.ts index 7fe11f7..d5860bb 100644 --- a/memento-note/app/actions/admin-settings.ts +++ b/memento-note/app/actions/admin-settings.ts @@ -3,7 +3,6 @@ import prisma from '@/lib/prisma' import { auth } from '@/auth' import { sendEmail } from '@/lib/mail' -import { updateTag } from 'next/cache' async function checkAdmin() { const session = await auth() @@ -62,9 +61,6 @@ export async function updateSystemConfig(data: Record) { await prisma.$transaction(operations) - // Invalidate cache after update - updateTag('system-config') - return { success: true } } catch (error) { console.error('Failed to update settings:', error) diff --git a/memento-note/app/actions/ai-settings.ts b/memento-note/app/actions/ai-settings.ts index d573986..bda3235 100644 --- a/memento-note/app/actions/ai-settings.ts +++ b/memento-note/app/actions/ai-settings.ts @@ -23,6 +23,7 @@ export type UserAISettingsData = { autoLabeling?: boolean noteHistory?: boolean noteHistoryMode?: 'manual' | 'auto' + fontFamily?: 'inter' | 'system' } /** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */ @@ -45,6 +46,7 @@ const USER_AI_SETTINGS_PRISMA_KEYS = [ 'autoLabeling', 'noteHistory', 'noteHistoryMode', + 'fontFamily', ] as const type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number] @@ -157,6 +159,7 @@ const getCachedAISettings = unstable_cache( autoLabeling: true, noteHistory: false, noteHistoryMode: 'manual' as const, + fontFamily: 'inter' as const, } } @@ -188,6 +191,7 @@ const getCachedAISettings = unstable_cache( autoLabeling: settings.autoLabeling ?? true, noteHistory: settings.noteHistory ?? false, noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto', + fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'system', } } catch (error) { console.error('Error getting AI settings:', error) @@ -212,6 +216,7 @@ const getCachedAISettings = unstable_cache( autoLabeling: true, noteHistory: false, noteHistoryMode: 'manual' as const, + fontFamily: 'inter' as const, } } }, @@ -249,6 +254,7 @@ export async function getAISettings(userId?: string) { autoLabeling: true, noteHistory: false, noteHistoryMode: 'manual' as const, + fontFamily: 'inter' as const, } } diff --git a/memento-note/app/actions/detect-language.ts b/memento-note/app/actions/detect-language.ts index be69688..f29982d 100644 --- a/memento-note/app/actions/detect-language.ts +++ b/memento-note/app/actions/detect-language.ts @@ -1,6 +1,7 @@ 'use server' -import { detectUserLanguage } from '@/lib/i18n/detect-user-language' +import { headers } from 'next/headers' +import { detectUserLanguage, parseAcceptLanguage } from '@/lib/i18n/detect-user-language' import { SupportedLanguage } from '@/lib/i18n/load-translations' /** @@ -8,5 +9,11 @@ import { SupportedLanguage } from '@/lib/i18n/load-translations' * Called on app load to set initial language */ export async function getInitialLanguage(): Promise { - return await detectUserLanguage() + try { + const headersList = await headers() + const browserLang = parseAcceptLanguage(headersList.get('accept-language')) + return await detectUserLanguage(browserLang) + } catch { + return await detectUserLanguage() + } } diff --git a/memento-note/app/actions/notifications.ts b/memento-note/app/actions/notifications.ts new file mode 100644 index 0000000..d7dc254 --- /dev/null +++ b/memento-note/app/actions/notifications.ts @@ -0,0 +1,76 @@ +'use server' + +import { prisma } from '@/lib/prisma' +import { auth } from '@/auth' + +export interface AppNotification { + id: string + type: string + title: string + message: string | null + read: boolean + actionUrl: string | null + relatedId: string | null + createdAt: Date +} + +export async function getUnreadNotifications(): Promise { + const session = await auth() + if (!session?.user?.id) return [] + + try { + const notifications = await prisma.notification.findMany({ + where: { userId: session.user.id, read: false }, + orderBy: { createdAt: 'desc' }, + take: 20, + }) + return notifications + } catch { + return [] + } +} + +export async function markNotificationRead(id: string): Promise { + const session = await auth() + if (!session?.user?.id) return + + await prisma.notification.updateMany({ + where: { id, userId: session.user.id }, + data: { read: true }, + }) +} + +export async function markAllNotificationsRead(): Promise { + const session = await auth() + if (!session?.user?.id) return + + await prisma.notification.updateMany({ + where: { userId: session.user.id, read: false }, + data: { read: true }, + }) +} + +/** Create a notification (called from server-side code, not exposed to client) */ +export async function createNotification(data: { + userId: string + type: string + title: string + message?: string + actionUrl?: string + relatedId?: string +}): Promise { + try { + await prisma.notification.create({ + data: { + userId: data.userId, + type: data.type, + title: data.title, + message: data.message || null, + actionUrl: data.actionUrl || null, + relatedId: data.relatedId || null, + }, + }) + } catch (e) { + console.error('[Notification] Failed to create:', e) + } +} diff --git a/memento-note/app/api/admin/models/route.ts b/memento-note/app/api/admin/models/route.ts index 361c8dc..995a8bc 100644 --- a/memento-note/app/api/admin/models/route.ts +++ b/memento-note/app/api/admin/models/route.ts @@ -10,13 +10,13 @@ async function requireAdmin() { /** * GET /api/admin/models?type=ollama&url= * GET /api/admin/models?type=custom&url=&key=&kind=tags|embeddings + * GET /api/admin/models?type=deepseek&key=&kind=tags|embeddings + * GET /api/admin/models?type=openrouter&key=&kind=tags|embeddings + * GET /api/admin/models?type=mistral&key=&kind=tags|embeddings + * GET /api/admin/models?type=zai&key=&kind=tags|embeddings + * GET /api/admin/models?type=lmstudio&url= * - * Route API (not a Server Action) for fetching AI model lists from Ollama or - * OpenAI-compatible providers. Using a Route Handler instead of a Server Action - * is the correct architecture for client-side GET requests: Server Actions are - * for data mutations, and calling them from useEffect pushes items into the - * App Router's internal action queue, which is drained during render (inside - * AppRouter's useMemo), triggering React Error #310 when multiple calls stack up. + * Route API for fetching AI model lists from providers. */ export async function GET(request: NextRequest) { if (!(await requireAdmin())) { @@ -29,14 +29,22 @@ export async function GET(request: NextRequest) { const apiKey = searchParams.get('key') ?? undefined const kind = searchParams.get('kind') ?? 'tags' - if (!rawUrl) { - return NextResponse.json({ success: false, models: [], error: 'url parameter is required' }) + // Provider-specific base URLs (used when url param is empty) + const PROVIDER_URLS: Record = { + deepseek: 'https://api.deepseek.com/v1', + openrouter: 'https://openrouter.ai/api/v1', + mistral: 'https://api.mistral.ai/v1', + zai: 'https://api.zukijourney.com/v1', + lmstudio: 'http://localhost:1234/v1', } - const baseUrl = rawUrl.replace(/\/$/, '').replace(/\/v1$/, '') - try { + // Ollama: uses native /api/tags endpoint if (type === 'ollama') { + if (!rawUrl) { + return NextResponse.json({ success: false, models: [], error: 'url parameter is required for Ollama' }) + } + const baseUrl = rawUrl.replace(/\/$/, '').replace(/\/api$/, '') const res = await fetch(`${baseUrl}/api/tags`, { headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(5000), @@ -49,58 +57,69 @@ export async function GET(request: NextRequest) { return NextResponse.json({ success: true, models }) } - if (type === 'custom') { - const headers: Record = { 'Content-Type': 'application/json' } - if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` + // All other providers: use OpenAI-compatible /v1/models endpoint + const baseUrl = rawUrl + ? rawUrl.replace(/\/$/, '') + : (PROVIDER_URLS[type || ''] || '') - if (kind === 'embeddings') { - // Try provider-specific embeddings endpoint first (e.g. OpenRouter) - try { - const embRes = await fetch(`${baseUrl}/v1/embeddings/models`, { - headers, - signal: AbortSignal.timeout(8000), - }) - if (embRes.ok) { - const embData = await embRes.json() - const embModels: string[] = (embData.data ?? []) - .map((m: { id: string }) => m.id) - .filter(Boolean) - .sort() - if (embModels.length > 0) { - return NextResponse.json({ success: true, models: embModels }) - } - } - } catch { - // Fall through to /v1/models with keyword filter - } - } - - const res = await fetch(`${baseUrl}/v1/models`, { - headers, - signal: AbortSignal.timeout(8000), - }) - if (!res.ok) { - return NextResponse.json({ success: false, models: [], error: `Provider ${res.status}` }) - } - - const data = await res.json() - let models: string[] = (data.data ?? []) - .map((m: { id: string }) => m.id) - .filter(Boolean) - .sort() - - if (kind === 'embeddings') { - const keywords = ['embed', 'embedding', 'ada', 'e5', 'bge', 'gte', 'minilm'] - const filtered = models.filter((id) => - keywords.some((kw) => id.toLowerCase().includes(kw)) - ) - if (filtered.length > 0) models = filtered - } - - return NextResponse.json({ success: true, models }) + if (!baseUrl) { + return NextResponse.json({ success: false, models: [], error: 'url parameter is required' }) } - return NextResponse.json({ success: false, models: [], error: `Unknown type: ${type}` }) + const headers: Record = { 'Content-Type': 'application/json' } + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` + + // For OpenRouter, add required headers + if (type === 'openrouter') { + headers['HTTP-Referer'] = 'https://localhost:3000' + headers['X-Title'] = 'Memento AI' + } + + // Try provider-specific embeddings endpoint first for embeddings kind + if (kind === 'embeddings') { + try { + const embRes = await fetch(`${baseUrl}/embeddings/models`, { + headers, + signal: AbortSignal.timeout(8000), + }) + if (embRes.ok) { + const embData = await embRes.json() + const embModels: string[] = (embData.data ?? []) + .map((m: { id: string }) => m.id) + .filter(Boolean) + .sort() + if (embModels.length > 0) { + return NextResponse.json({ success: true, models: embModels }) + } + } + } catch { + // Fall through to /v1/models with keyword filter + } + } + + const res = await fetch(`${baseUrl}/models`, { + headers, + signal: AbortSignal.timeout(8000), + }) + if (!res.ok) { + return NextResponse.json({ success: false, models: [], error: `Provider ${res.status}` }) + } + + const data = await res.json() + let models: string[] = (data.data ?? []) + .map((m: { id: string }) => m.id) + .filter(Boolean) + .sort() + + if (kind === 'embeddings') { + const keywords = ['embed', 'embedding', 'ada', 'e5', 'bge', 'gte', 'minilm'] + const filtered = models.filter((id) => + keywords.some((kw) => id.toLowerCase().includes(kw)) + ) + if (filtered.length > 0) models = filtered + } + + return NextResponse.json({ success: true, models }) } catch (err: any) { return NextResponse.json({ success: false, models: [], error: err.message ?? 'Request failed' }) } diff --git a/memento-note/app/globals.css b/memento-note/app/globals.css index 2b3f120..43d0d96 100644 --- a/memento-note/app/globals.css +++ b/memento-note/app/globals.css @@ -63,8 +63,8 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --font-sans: var(--font-inter); + --font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, Consolas, monospace; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -117,11 +117,11 @@ --secondary-foreground: #1e293b; --muted: #f1f5f9; --muted-foreground: #64748b; - --accent: #f8fafc; + --accent: #f1f5f9; --accent-foreground: #0284c7; --destructive: oklch(0.6 0.18 25); /* Rouge */ - --border: #e2e8f0; /* Gris-bleu très clair */ - --input: #ffffff; + --border: #cbd5e1; /* Gris-bleu visible */ + --input: #cbd5e1; /* Bordure visible pour inputs/checkbox */ --ring: #0284c7; --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); @@ -151,11 +151,11 @@ --secondary-foreground: oklch(0.2 0.02 230); --muted: oklch(0.92 0.005 230); --muted-foreground: oklch(0.6 0.01 230); - --accent: oklch(0.94 0.005 230); + --accent: oklch(0.92 0.005 230); --accent-foreground: oklch(0.2 0.02 230); --destructive: oklch(0.6 0.18 25); /* Rouge */ - --border: oklch(0.9 0.008 230); /* Gris-bleu très clair */ - --input: oklch(0.98 0.003 230); + --border: oklch(0.85 0.008 230); /* Gris-bleu visible */ + --input: oklch(0.85 0.008 230); /* Bordure visible */ --ring: oklch(0.7 0.005 230); --popover: oklch(1 0 0); --popover-foreground: oklch(0.2 0.02 230); @@ -165,7 +165,7 @@ --sidebar-primary-foreground: oklch(0.99 0 0); --sidebar-accent: oklch(0.94 0.005 230); --sidebar-accent-foreground: oklch(0.2 0.02 230); - --sidebar-border: oklch(0.9 0.008 230); + --sidebar-border: oklch(0.85 0.008 230); --sidebar-ring: oklch(0.7 0.005 230); } @@ -182,11 +182,11 @@ --secondary-foreground: oklch(0.97 0.003 230); --muted: oklch(0.22 0.006 230); --muted-foreground: oklch(0.55 0.01 230); - --accent: oklch(0.24 0.006 230); + --accent: oklch(0.26 0.008 230); --accent-foreground: oklch(0.97 0.003 230); --destructive: oklch(0.65 0.18 25); - --border: oklch(0.28 0.01 230); - --input: oklch(0.2 0.006 230); + --border: oklch(0.33 0.01 230); + --input: oklch(0.33 0.01 230); --ring: oklch(0.6 0.01 230); --popover: oklch(0.18 0.006 230); --popover-foreground: oklch(0.97 0.003 230); @@ -213,11 +213,11 @@ --secondary-foreground: oklch(0.97 0.003 230); --muted: oklch(0.22 0.006 230); --muted-foreground: oklch(0.55 0.01 230); - --accent: oklch(0.24 0.006 230); + --accent: oklch(0.26 0.008 230); --accent-foreground: oklch(0.97 0.003 230); --destructive: oklch(0.65 0.18 25); - --border: oklch(0.28 0.01 230); - --input: oklch(0.2 0.006 230); + --border: oklch(0.33 0.01 230); + --input: oklch(0.33 0.01 230); --ring: oklch(0.6 0.01 230); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); @@ -248,8 +248,8 @@ --accent: oklch(0.25 0.015 250); --accent-foreground: oklch(0.18 0.03 250); --destructive: oklch(0.6 0.22 25); - --border: oklch(0.85 0.015 250); - --input: oklch(0.25 0.01 250); + --border: oklch(0.82 0.015 250); + --input: oklch(0.82 0.015 250); --ring: oklch(0.65 0.015 250); --popover: oklch(0.97 0.006 250); --popover-foreground: oklch(0.18 0.03 250); @@ -274,11 +274,11 @@ --secondary-foreground: oklch(0.96 0.005 250); --muted: oklch(0.2 0.015 250); --muted-foreground: oklch(0.5 0.02 250); - --accent: oklch(0.22 0.02 250); + --accent: oklch(0.26 0.02 250); --accent-foreground: oklch(0.96 0.005 250); --destructive: oklch(0.65 0.2 25); - --border: oklch(0.3 0.02 250); - --input: oklch(0.22 0.02 250); + --border: oklch(0.33 0.02 250); + --input: oklch(0.33 0.02 250); --ring: oklch(0.55 0.02 250); --popover: oklch(0.15 0.015 250); --popover-foreground: oklch(0.96 0.005 250); @@ -306,8 +306,8 @@ --accent: oklch(0.93 0.01 225); --accent-foreground: oklch(0.18 0.035 225); --destructive: oklch(0.6 0.2 25); - --border: oklch(0.87 0.012 225); - --input: oklch(0.95 0.01 225); + --border: oklch(0.83 0.012 225); + --input: oklch(0.83 0.012 225); --ring: oklch(0.65 0.015 225); --popover: oklch(1 0 0); --popover-foreground: oklch(0.18 0.035 225); @@ -332,11 +332,11 @@ --secondary-foreground: oklch(0.97 0.006 225); --muted: oklch(0.25 0.02 225); --muted-foreground: oklch(0.52 0.018 225); - --accent: oklch(0.25 0.025 225); + --accent: oklch(0.28 0.025 225); --accent-foreground: oklch(0.97 0.006 225); --destructive: oklch(0.65 0.22 25); - --border: oklch(0.32 0.018 225); - --input: oklch(0.25 0.02 225); + --border: oklch(0.35 0.018 225); + --input: oklch(0.35 0.018 225); --ring: oklch(0.55 0.02 225); --popover: oklch(0.17 0.01 225); --popover-foreground: oklch(0.97 0.006 225); @@ -364,8 +364,8 @@ --accent: oklch(0.93 0.01 45); --accent-foreground: oklch(0.2 0.015 45); --destructive: oklch(0.6 0.2 25); - --border: oklch(0.88 0.012 45); - --input: oklch(0.97 0.008 45); + --border: oklch(0.83 0.012 45); + --input: oklch(0.83 0.012 45); --ring: oklch(0.68 0.01 45); --popover: oklch(1 0 0); --popover-foreground: oklch(0.2 0.015 45); @@ -390,11 +390,11 @@ --secondary-foreground: oklch(0.97 0.005 45); --muted: oklch(0.23 0.02 45); --muted-foreground: oklch(0.55 0.012 45); - --accent: oklch(0.27 0.018 45); + --accent: oklch(0.29 0.018 45); --accent-foreground: oklch(0.97 0.005 45); --destructive: oklch(0.65 0.2 25); - --border: oklch(0.3 0.018 45); - --input: oklch(0.27 0.02 45); + --border: oklch(0.33 0.018 45); + --input: oklch(0.33 0.018 45); --ring: oklch(0.58 0.02 45); --popover: oklch(0.19 0.01 45); --popover-foreground: oklch(0.97 0.005 45); @@ -408,6 +408,16 @@ --sidebar-ring: oklch(0.58 0.02 45); } +/* System font mode — override Inter with native OS fonts. + Must be outside @layer base to win over next/font's generated class. */ +html.font-system { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important; +} +html.font-system body, +html.font-system * { + font-family: inherit !important; +} + @layer base { * { @apply border-border outline-ring/50; diff --git a/memento-note/app/layout.tsx b/memento-note/app/layout.tsx index dec534f..8cd7a2a 100644 --- a/memento-note/app/layout.tsx +++ b/memento-note/app/layout.tsx @@ -89,7 +89,7 @@ export default async function RootLayout({ - + {children} diff --git a/memento-note/components/home-client.tsx b/memento-note/components/home-client.tsx index 0aead66..d62de5f 100644 --- a/memento-note/components/home-client.tsx +++ b/memento-note/components/home-client.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { useSearchParams, useRouter } from 'next/navigation' import dynamic from 'next/dynamic' import { Note } from '@/lib/types' -import { getAllNotes, searchNotes, enableNoteHistory } from '@/app/actions/notes' +import { getAllNotes, searchNotes, enableNoteHistory, getNoteById } from '@/app/actions/notes' import { NoteInput } from '@/components/note-input' import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section' import { NotesViewToggle } from '@/components/notes-view-toggle' @@ -165,6 +165,33 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { useReminderCheck(notes) + // Handle ?openNote=ID — open a note from a notification click + useEffect(() => { + const openNoteId = searchParams.get('openNote') + if (!openNoteId) return + + const openNote = async () => { + // Try to find the note in current state first + const existing = notes.find(n => n.id === openNoteId) + if (existing) { + setEditingNote({ note: existing, readOnly: false }) + } else { + // Fetch from server + const fetched = await getNoteById(openNoteId) + if (fetched) { + setEditingNote({ note: fetched, readOnly: false }) + } + } + // Clean URL — remove openNote param + const params = new URLSearchParams(searchParams.toString()) + params.delete('openNote') + router.replace(params.toString() ? `/?${params.toString()}` : '/', { scroll: false }) + } + + openNote() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]) + // Listen for global label deletion and immediately update local state useEffect(() => { const handler = (e: Event) => { @@ -321,6 +348,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { })()}

{currentNotebook.name}

+ ({notes.length})
@@ -351,6 +379,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {

{t('notes.title')}

+ {notes.length} {notes.length === 1 ? (t('notes.note') || 'note') : (t('notes.notes') || 'notes')}
diff --git a/memento-note/components/note-card.tsx b/memento-note/components/note-card.tsx index 5502843..dda8a22 100644 --- a/memento-note/components/note-card.tsx +++ b/memento-note/components/note-card.tsx @@ -218,6 +218,13 @@ export const NoteCard = memo(function NoteCard({ // Local color state so color persists after transition ends const [localColor, setLocalColor] = useState(note.color) + // Local checkItems state so checklist toggles persist after transition ends + const [localCheckItems, setLocalCheckItems] = useState(note.checkItems) + + // Sync local state when parent data refreshes + useEffect(() => { + setLocalCheckItems(note.checkItems) + }, [note.checkItems]) const colorClasses = NOTE_COLORS[(localColor || optimisticNote.color) as NoteColor] || NOTE_COLORS.default @@ -373,14 +380,15 @@ export const NoteCard = memo(function NoteCard({ } const handleCheckItem = async (checkItemId: string) => { - if (note.type === 'checklist' && Array.isArray(note.checkItems)) { - const updatedItems = note.checkItems.map(item => + if (note.type === 'checklist') { + const currentItems = localCheckItems || note.checkItems || [] + const updatedItems = currentItems.map(item => item.id === checkItemId ? { ...item, checked: !item.checked } : item ) + setLocalCheckItems(updatedItems) // instant visual update, survives transition startTransition(async () => { addOptimisticNote({ checkItems: updatedItems }) await updateNote(note.id, { checkItems: updatedItems }) - // No router.refresh() — optimistic update is sufficient and avoids grid rebuild }) } } @@ -621,7 +629,7 @@ export const NoteCard = memo(function NoteCard({ {/* Content */} {optimisticNote.type === 'checklist' ? ( ) : optimisticNote.type === 'richtext' ? ( diff --git a/memento-note/components/note-inline-editor.tsx b/memento-note/components/note-inline-editor.tsx index 73386e7..79f051a 100644 --- a/memento-note/components/note-inline-editor.tsx +++ b/memento-note/components/note-inline-editor.tsx @@ -497,8 +497,14 @@ export function NoteInlineEditor({ value={noteType} onChange={(newType) => { setNoteType(newType) - if (newType !== 'markdown') setShowMarkdownPreview(false) - onChange?.(note.id, { isMarkdown: newType === 'markdown' }) + if (newType === 'markdown') setShowMarkdownPreview(true) + else setShowMarkdownPreview(false) + // Persist both type and isMarkdown immediately + saveInline(note.id, { + type: newType, + isMarkdown: newType === 'markdown', + }).catch(() => {}) + onChange?.(note.id, { type: newType, isMarkdown: newType === 'markdown' }) }} compact /> diff --git a/memento-note/components/notes-tabs-view.tsx b/memento-note/components/notes-tabs-view.tsx index 23cfa70..de038d3 100644 --- a/memento-note/components/notes-tabs-view.tsx +++ b/memento-note/components/notes-tabs-view.tsx @@ -19,7 +19,7 @@ import { useSortable, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' -import { Note, NOTE_COLORS, NoteColor } from '@/lib/types' +import { Note, NOTE_COLORS, NoteColor, NoteType } from '@/lib/types' import { cn } from '@/lib/utils' import { NoteInlineEditor } from '@/components/note-inline-editor' import { useLanguage } from '@/lib/i18n' @@ -65,6 +65,7 @@ import { import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, @@ -737,11 +738,13 @@ export function NotesTabsView({ const selected = items.find((n) => n.id === selectedId) ?? null const colorKey = selected ? getColorKey(selected) : 'default' - const handleCreateNote = () => { + const handleCreateNote = (noteType: NoteType = 'richtext') => { startCreating(async () => { try { const newNote = await createNote({ - content: '', + content: noteType === 'checklist' ? '' : '', + type: noteType, + checkItems: noteType === 'checklist' ? [{ id: Date.now().toString(), text: '', checked: false }] : undefined, title: undefined, notebookId: currentNotebookId || undefined, skipRevalidation: true @@ -843,19 +846,32 @@ export function NotesTabsView({ - {/* New note button */} - + {/* New note button — dropdown to choose type */} + + + + + + handleCreateNote('richtext')}> + + {t('notes.newNote') || 'Note'} + + handleCreateNote('checklist')}> + + {t('notes.newChecklist') || 'Checklist'} + + +
diff --git a/memento-note/components/notification-panel.tsx b/memento-note/components/notification-panel.tsx index 92426c3..6cfa6ec 100644 --- a/memento-note/components/notification-panel.tsx +++ b/memento-note/components/notification-panel.tsx @@ -1,15 +1,17 @@ 'use client' import { useState, useEffect, useCallback } from 'react' +import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2 } from 'lucide-react' +import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2 } from 'lucide-react' import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover' import { getPendingShareRequests, respondToShareRequest, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes' +import { getUnreadNotifications, markNotificationRead, markAllNotificationsRead, type AppNotification } from '@/app/actions/notifications' import { toast } from 'sonner' import { useNoteRefreshOptional } from '@/context/NoteRefreshContext' import { cn } from '@/lib/utils' @@ -47,19 +49,23 @@ interface ReminderNote { export function NotificationPanel() { const { triggerRefresh } = useNoteRefreshOptional() const { t } = useLanguage() + const router = useRouter() const [requests, setRequests] = useState([]) const [reminders, setReminders] = useState([]) + const [appNotifications, setAppNotifications] = useState([]) const [isLoading, setIsLoading] = useState(false) const [open, setOpen] = useState(false) const loadData = useCallback(async () => { try { - const [shareData, reminderData] = await Promise.all([ + const [shareData, reminderData, notifData] = await Promise.all([ getPendingShareRequests(), getNotesWithReminders(), + getUnreadNotifications(), ]) setRequests(shareData as any) setReminders((reminderData as any) || []) + setAppNotifications(notifData || []) } catch (error: any) { console.error('Failed to load notifications:', error) } @@ -81,7 +87,7 @@ export function NotificationPanel() { const overdueReminders = activeReminders.filter(r => new Date(r.reminder!) < now) const upcomingReminders = activeReminders.filter(r => new Date(r.reminder!) >= now) - const pendingCount = requests.length + overdueReminders.length + const pendingCount = requests.length + overdueReminders.length + appNotifications.length const handleAccept = async (shareId: string) => { try { @@ -121,7 +127,17 @@ export function NotificationPanel() { } } - const hasContent = requests.length > 0 || activeReminders.length > 0 + const handleMarkNotifRead = async (notifId: string) => { + await markNotificationRead(notifId) + setAppNotifications(prev => prev.filter(n => n.id !== notifId)) + } + + const handleMarkAllRead = async () => { + await markAllNotificationsRead() + setAppNotifications([]) + } + + const hasContent = requests.length > 0 || activeReminders.length > 0 || appNotifications.length > 0 return ( @@ -149,11 +165,22 @@ export function NotificationPanel() { {t('notification.notifications')} - {pendingCount > 0 && ( - - {pendingCount} - - )} +
+ {appNotifications.length > 0 && ( + + )} + {pendingCount > 0 && ( + + {pendingCount} + + )} +
@@ -168,6 +195,65 @@ export function NotificationPanel() { ) : (
+ {/* App notifications (agents, system) */} + {appNotifications.map((notif) => ( +
{ + if (notif.actionUrl) { + handleMarkNotifRead(notif.id) + setOpen(false) + router.push(notif.actionUrl) + } + }} + > +
+
+ {notif.type.startsWith('agent') ? ( + + ) : ( + + )} +
+
+
+ + {notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent completed')} + {notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent failed')} + {notif.type === 'system' && 'System'} + +
+

{notif.title}

+ {notif.message && ( +

{notif.message}

+ )} +
+ + {formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })} +
+
+ +
+
+ ))} + {/* Overdue reminders */} {overdueReminders.map((note) => (
{ // Helper to apply theme @@ -77,7 +78,20 @@ export function ThemeInitializer({ theme, fontSize }: ThemeInitializerProps) { } applyFontSize(fontSize) - }, [theme, fontSize]) + + // Apply font family + const localFontFamily = localStorage.getItem('font-family') + const effectiveFontFamily = localFontFamily || fontFamily || 'inter' + const root = document.documentElement + if (effectiveFontFamily === 'system') { + root.classList.add('font-system') + } else { + root.classList.remove('font-system') + } + if (!localFontFamily && fontFamily) { + localStorage.setItem('font-family', fontFamily) + } + }, [theme, fontSize, fontFamily]) return null } diff --git a/memento-note/components/ui/checkbox.tsx b/memento-note/components/ui/checkbox.tsx index cb0b07b..6c19e41 100644 --- a/memento-note/components/ui/checkbox.tsx +++ b/memento-note/components/ui/checkbox.tsx @@ -14,7 +14,7 @@ function Checkbox({ { + if (allowCustomValue && search.trim()) { + onChange(search.trim()) + setOpen(false) + setSearch('') + } + } + return ( { setOpen(v); if (!v) setSearch('') }}> @@ -85,14 +96,26 @@ export function Combobox({ placeholder={searchPlaceholder} value={search} onChange={(e) => setSearch(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleConfirmCustom() } }} autoFocus />
{filtered.length === 0 ? ( -
- {emptyMessage} -
+ allowCustomValue && search.trim() ? ( + + ) : ( +
+ {emptyMessage} +
+ ) ) : ( filtered.map((option) => { const isSelected = option.value === value diff --git a/memento-note/components/ui/input.tsx b/memento-note/components/ui/input.tsx index 8916905..af5ec16 100644 --- a/memento-note/components/ui/input.tsx +++ b/memento-note/components/ui/input.tsx @@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/50 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className diff --git a/memento-note/docker-compose.yml b/memento-note/docker-compose.yml index b4cf199..b1faf99 100644 --- a/memento-note/docker-compose.yml +++ b/memento-note/docker-compose.yml @@ -38,6 +38,7 @@ services: # Application (IMPORTANT: Change these!) - NEXTAUTH_URL=http://your-domain.com:3000 - NEXTAUTH_SECRET=change-this-to-a-random-secret-string + - ADMIN_EMAIL=admin@yourdomain.com # REQUIRED: first user with this email gets ADMIN role # Disable Next.js telemetry - NEXT_TELEMETRY_DISABLED=1 diff --git a/memento-note/docker-entrypoint.sh b/memento-note/docker-entrypoint.sh index 216d150..6a3dbd4 100644 --- a/memento-note/docker-entrypoint.sh +++ b/memento-note/docker-entrypoint.sh @@ -1,8 +1,11 @@ #!/bin/sh # ============================================================ # Memento Note — Docker Entrypoint -# Automatic DB migration with backup, retry, and cleanup. -# Safe for production: no data loss on image updates. +# Reliable DB migration for fresh installs and upgrades. +# ============================================================ +# Strategy: +# 1. prisma migrate deploy → fresh DB, normal upgrades +# 2. prisma db push → fallback for DBs without migration history # ============================================================ set -e @@ -26,13 +29,12 @@ case "$DATABASE_URL" in esac log "Database type: $DB_TYPE" -# --- Wait for database to be reachable --- -wait_for_db() { - if [ "$DB_TYPE" = "sqlite" ]; then - log "SQLite — no connection check needed." - return 0 - fi +# --- Ensure data directories exist --- +mkdir -p "$BACKUP_DIR" 2>/dev/null || true +mkdir -p /app/data/uploads/notes 2>/dev/null || true +# --- Wait for database to be reachable --- +if [ "$DB_TYPE" != "sqlite" ]; then log "Waiting for database connection..." i=0 while [ "$i" -lt "$DB_WAIT_RETRIES" ]; do @@ -45,107 +47,74 @@ wait_for_db() { s.on('error', () => process.exit(1)); " 2>/dev/null; then log "Database is reachable." - return 0 + break fi i=$((i + 1)) log " Attempt $i/$DB_WAIT_RETRIES — retrying in ${DB_WAIT_INTERVAL}s..." sleep "$DB_WAIT_INTERVAL" done - err "Database unreachable after $((DB_WAIT_RETRIES * DB_WAIT_INTERVAL)) seconds. Aborting." - exit 1 -} - -# --- Create backup before migration --- -create_backup() { - mkdir -p "$BACKUP_DIR" - ts=$(date '+%Y%m%d_%H%M%S') - - case "$DB_TYPE" in - postgres) - backup_file="$BACKUP_DIR/pre_migrate_${ts}.sql.gz" - log "Creating PostgreSQL backup: $backup_file" - if command -v pg_dump >/dev/null 2>&1; then - if pg_dump --no-owner --no-privileges --format=plain "$DATABASE_URL" 2>/dev/null | gzip > "$backup_file" 2>/dev/null; then - size=$(du -h "$backup_file" | cut -f1) - log "Backup created successfully ($size)" - else - warn "pg_dump failed. Continuing without backup." - rm -f "$backup_file" - fi - else - warn "pg_dump not available. Skipping PostgreSQL backup." - warn "Install postgresql-client in the Docker image for automatic backups." - fi - ;; - sqlite) - db_path="${DATABASE_URL#file:}" - # Handle relative paths - case "$db_path" in - /*) ;; - *) db_path="/app/$db_path" ;; - esac - backup_file="$BACKUP_DIR/pre_migrate_${ts}.sqlite" - if [ -f "$db_path" ]; then - log "Creating SQLite backup: $backup_file" - cp "$db_path" "$backup_file" - size=$(du -h "$backup_file" | cut -f1) - log "Backup created successfully ($size)" - else - warn "SQLite file not found at $db_path — skipping backup (first run)." - fi - ;; - *) - warn "Unknown database type '$DB_TYPE'. Skipping backup." - ;; - esac -} - -# --- Clean up old backups (keep last N) --- -cleanup_old_backups() { - count=$(ls -1 "$BACKUP_DIR"/pre_migrate_* 2>/dev/null | wc -l) - if [ "$count" -gt "$MAX_BACKUPS" ]; then - to_remove=$((count - MAX_BACKUPS)) - log "Cleaning up $to_remove old backup(s), keeping last $MAX_BACKUPS..." - ls -1t "$BACKUP_DIR"/pre_migrate_* 2>/dev/null | tail -n "$to_remove" | xargs rm -f + if [ "$i" -ge "$DB_WAIT_RETRIES" ]; then + err "Database unreachable after $((DB_WAIT_RETRIES * DB_WAIT_INTERVAL)) seconds." + exit 1 fi -} - -# --- Run Prisma migrations --- -run_migrations() { - log "Running Prisma migrations..." - if timeout "$MIGRATE_TIMEOUT" node ./node_modules/prisma/build/index.js migrate deploy 2>&1; then - log "Migrations applied successfully." - return 0 - else - exit_code=$? - err "Migration failed (exit code $exit_code)." - return "$exit_code" - fi -} - -# ============================================================ -# Main flow -# ============================================================ - -# Step 1: Wait for database -wait_for_db - -# Step 2: Backup -create_backup - -# Step 3: Cleanup old backups -cleanup_old_backups - -# Step 4: Migrate -if ! run_migrations; then - err "Migration failed — server will NOT start to prevent data corruption." - err "A backup was saved in $BACKUP_DIR (if backup was successful)." - err "To restore: gunzip the .sql.gz file, then: psql DATABASE_URL < backup.sql" - exit 1 fi -# Background scheduler: call /api/cron/agents every 5 minutes +# --- Create backup before migration (best-effort) --- +if [ "$DB_TYPE" = "postgres" ] && command -v pg_dump >/dev/null 2>&1; then + ts=$(date '+%Y%m%d_%H%M%S') + backup_file="$BACKUP_DIR/pre_migrate_${ts}.sql.gz" + log "Creating backup: $backup_file" + if pg_dump --no-owner --no-privileges --format=plain "$DATABASE_URL" 2>/dev/null | gzip > "$backup_file" 2>/dev/null; then + size=$(du -h "$backup_file" | cut -f1) + log "Backup OK ($size)" + else + warn "pg_dump failed (DB may be empty). Continuing without backup." + rm -f "$backup_file" + fi +fi + +# --- Cleanup old backups --- +count=$(ls -1 "$BACKUP_DIR"/pre_migrate_* 2>/dev/null | wc -l) +if [ "$count" -gt "$MAX_BACKUPS" ]; then + to_remove=$((count - MAX_BACKUPS)) + log "Cleaning up $to_remove old backup(s)..." + ls -1t "$BACKUP_DIR"/pre_migrate_* 2>/dev/null | tail -n "$to_remove" | xargs rm -f +fi + +# ============================================================ +# Migration — two strategies +# ============================================================ + +PRISMA="node ./node_modules/prisma/build/index.js" + +log "Running database migrations..." + +# Strategy 1: prisma migrate deploy +# Works for: fresh DB (empty), existing DB with migration history +if timeout "$MIGRATE_TIMEOUT" $PRISMA migrate deploy 2>&1; then + log "Migrations applied successfully." +else + migrate_exit=$? + log "prisma migrate deploy failed (exit $migrate_exit)." + + # Strategy 2: prisma db push + # Works for: existing DB without migration history (created with db push), + # schema drift, or any other case where migrate deploy fails. + # This syncs schema.prisma → database, preserving existing data. + log "Falling back to prisma db push..." + if timeout "$MIGRATE_TIMEOUT" $PRISMA db push --skip-generate --accept-data-loss 2>&1; then + log "db push completed successfully." + else + err "All migration strategies failed." + err "To fix manually:" + err " docker compose exec memento-note npx prisma migrate deploy" + err " docker compose exec memento-note npx prisma db push --skip-generate" + exit 1 + fi +fi + +# --- Background cron scheduler --- ( sleep 60 while true; do @@ -163,6 +132,6 @@ fi done ) & -# Step 5: Start server +# --- Start server --- log "Starting server..." exec node server.js diff --git a/memento-note/lib/ai/factory.ts b/memento-note/lib/ai/factory.ts index 3c2d96f..39c5351 100644 --- a/memento-note/lib/ai/factory.ts +++ b/memento-note/lib/ai/factory.ts @@ -3,7 +3,36 @@ import { OllamaProvider } from './providers/ollama'; import { CustomOpenAIProvider } from './providers/custom-openai'; import { AIProvider } from './types'; -type ProviderType = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter'; +type ProviderType = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter' | 'mistral' | 'zai' | 'lmstudio'; + +// --- Provider defaults --- +const PROVIDER_DEFAULTS: Record = { + deepseek: { + baseUrl: 'https://api.deepseek.com/v1', + model: 'deepseek-chat', + embeddingModel: '', + }, + openrouter: { + baseUrl: 'https://openrouter.ai/api/v1', + model: 'openai/gpt-4o-mini', + embeddingModel: 'openai/text-embedding-3-small', + }, + mistral: { + baseUrl: 'https://api.mistral.ai/v1', + model: 'mistral-small-latest', + embeddingModel: 'mistral-embed', + }, + zai: { + baseUrl: 'https://api.zukijourney.com/v1', + model: 'gpt-4o-mini', + embeddingModel: 'text-embedding-3-small', + }, + lmstudio: { + baseUrl: 'http://localhost:1234/v1', + model: '', + embeddingModel: '', + }, +}; function createOllamaProvider(config: Record, modelName: string, embeddingModelName: string, baseUrlOverride?: string): OllamaProvider { let baseUrl = baseUrlOverride || config?.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL @@ -19,7 +48,7 @@ function createOllamaProvider(config: Record, modelName: string, // Ensure baseUrl doesn't end with /api, we'll add it in OllamaProvider if (baseUrl.endsWith('/api')) { - baseUrl = baseUrl.slice(0, -4); // Remove /api + baseUrl = baseUrl.slice(0, -4); } return new OllamaProvider(baseUrl, modelName, embeddingModelName); @@ -51,15 +80,39 @@ function createCustomOpenAIProvider(config: Record, modelName: s } function createDeepSeekProvider(config: Record, 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 || ''; + const apiKey = config?.DEEPSEEK_API_KEY || process.env.DEEPSEEK_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); + const defaults = PROVIDER_DEFAULTS.deepseek; + return new CustomOpenAIProvider(apiKey, defaults.baseUrl, modelName || defaults.model, embeddingModelName || defaults.embeddingModel); } function createOpenRouterProvider(config: Record, 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 || ''; + const apiKey = config?.OPENROUTER_API_KEY || process.env.OPENROUTER_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); + const defaults = PROVIDER_DEFAULTS.openrouter; + return new CustomOpenAIProvider(apiKey, defaults.baseUrl, modelName || defaults.model, embeddingModelName || defaults.embeddingModel); +} + +function createMistralProvider(config: Record, modelName: string, embeddingModelName: string): CustomOpenAIProvider { + const apiKey = config?.MISTRAL_API_KEY || process.env.MISTRAL_API_KEY || ''; + if (!apiKey) throw new Error('MISTRAL_API_KEY is required when using Mistral provider'); + const defaults = PROVIDER_DEFAULTS.mistral; + return new CustomOpenAIProvider(apiKey, defaults.baseUrl, modelName || defaults.model, embeddingModelName || defaults.embeddingModel); +} + +function createZAIProvider(config: Record, modelName: string, embeddingModelName: string): CustomOpenAIProvider { + const apiKey = config?.ZAI_API_KEY || process.env.ZAI_API_KEY || ''; + if (!apiKey) throw new Error('ZAI_API_KEY is required when using Z.AI provider'); + const defaults = PROVIDER_DEFAULTS.zai; + return new CustomOpenAIProvider(apiKey, defaults.baseUrl, modelName || defaults.model, embeddingModelName || defaults.embeddingModel); +} + +function createLMStudioProvider(config: Record, modelName: string, embeddingModelName: string): CustomOpenAIProvider { + const baseUrl = config?.LMSTUDIO_BASE_URL || process.env.LMSTUDIO_BASE_URL || PROVIDER_DEFAULTS.lmstudio.baseUrl; + // LM Studio doesn't require an API key, but the CustomOpenAI provider needs one + // Use a dummy key if not provided + const apiKey = config?.LMSTUDIO_API_KEY || process.env.LMSTUDIO_API_KEY || 'lm-studio'; + return new CustomOpenAIProvider(apiKey, baseUrl, modelName, embeddingModelName); } function getProviderInstance(providerType: ProviderType, config: Record, modelName: string, embeddingModelName: string, ollamaBaseUrl?: string): AIProvider { @@ -74,28 +127,47 @@ function getProviderInstance(providerType: ProviderType, config: Record): AIProvider { - // Check database config first, then environment variables const providerType = ( - config?.AI_PROVIDER_TAGS || - config?.AI_PROVIDER_EMBEDDING || + config?.AI_PROVIDER_TAGS || + config?.AI_PROVIDER_EMBEDDING || config?.AI_PROVIDER || process.env.AI_PROVIDER_TAGS || process.env.AI_PROVIDER_EMBEDDING || process.env.AI_PROVIDER ); - // If no provider is configured, throw a clear error if (!providerType) { console.error('[getTagsProvider] FATAL: No provider configured. Config received:', config); throw new Error( 'AI_PROVIDER_TAGS is not configured. Please set it in the admin settings or environment variables. ' + - 'Options: ollama, openai, custom' + 'Options: ollama, openai, deepseek, openrouter, mistral, zai, lmstudio, custom' ); } @@ -108,22 +180,20 @@ export function getTagsProvider(config?: Record): AIProvider { } export function getEmbeddingsProvider(config?: Record): AIProvider { - // Check database config first, then environment variables const providerType = ( - config?.AI_PROVIDER_EMBEDDING || - config?.AI_PROVIDER_TAGS || + config?.AI_PROVIDER_EMBEDDING || + config?.AI_PROVIDER_TAGS || config?.AI_PROVIDER || process.env.AI_PROVIDER_EMBEDDING || process.env.AI_PROVIDER_TAGS || process.env.AI_PROVIDER ); - // If no provider is configured, throw a clear error if (!providerType) { console.error('[getEmbeddingsProvider] FATAL: No provider configured. Config received:', config); throw new Error( 'AI_PROVIDER_EMBEDDING is not configured. Please set it in the admin settings or environment variables. ' + - 'Options: ollama, openai, custom' + 'Options: ollama, openai, deepseek, openrouter, mistral, zai, lmstudio, custom' ); } @@ -140,8 +210,6 @@ export function getAIProvider(config?: Record): AIProvider { } export function getChatProvider(config?: Record): AIProvider { - // Check database config first, then environment variables - // Fallback cascade: chat -> tags -> embeddings const providerType = ( config?.AI_PROVIDER_CHAT || config?.AI_PROVIDER_TAGS || @@ -153,12 +221,11 @@ export function getChatProvider(config?: Record): AIProvider { process.env.AI_PROVIDER ); - // If no provider is configured, throw a clear error if (!providerType) { console.error('[getChatProvider] FATAL: No provider configured. Config received:', config); throw new Error( 'AI_PROVIDER_CHAT is not configured. Please set it in the admin settings or environment variables. ' + - 'Options: ollama, openai, custom' + 'Options: ollama, openai, deepseek, openrouter, mistral, zai, lmstudio, custom' ); } @@ -173,3 +240,6 @@ export function getChatProvider(config?: Record): AIProvider { return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl); } + +// Export for use by admin settings form and deploy scripts +export { PROVIDER_DEFAULTS, getProviderConfigKeys }; diff --git a/memento-note/lib/ai/services/agent-executor.service.ts b/memento-note/lib/ai/services/agent-executor.service.ts index adba701..02122ef 100644 --- a/memento-note/lib/ai/services/agent-executor.service.ts +++ b/memento-note/lib/ai/services/agent-executor.service.ts @@ -15,6 +15,8 @@ import { sendEmail } from '@/lib/mail' import { getAgentEmailTemplate } from '@/lib/agent-email-template' import { extractAndDownloadImages, extractImageUrlsFromHtml, downloadImage } from '../tools/extract-images' import { calculateNextRun } from '@/lib/agents/schedule' +import { markdownToHtml } from '@/lib/markdown-to-html' +import { createNotification } from '@/app/actions/notifications' // Import tools for side-effect registration import '../tools' @@ -30,6 +32,32 @@ export interface AgentExecutionResult { error?: string } +// --- Note creation helper --- + +/** Create an agent note as rich text (TipTap-compatible HTML). + * Converts the markdown content to HTML and sets type='richtext'. */ +async function createAgentNote(data: { + title: string + content: string + userId: string + notebookId: string | null + autoGenerated?: boolean +}) { + const htmlContent = markdownToHtml(data.content) + return prisma.note.create({ + data: { + title: data.title, + content: htmlContent, + type: 'richtext', + isMarkdown: false, + autoGenerated: data.autoGenerated ?? true, + userId: data.userId, + notebookId: data.notebookId, + }, + select: { id: true }, + }) +} + // --- Language Helper --- type Lang = 'fr' | 'en' @@ -324,15 +352,11 @@ async function executeScraperAgent( const title = await generateTitle(fullContent, agent.name, lang) - const note = await prisma.note.create({ - data: { - title, - content: fullContent, - isMarkdown: true, - autoGenerated: true, - userId: agent.userId, - notebookId: agent.targetNotebookId, - } + const note = await createAgentNote({ + title, + content: fullContent, + userId: agent.userId, + notebookId: agent.targetNotebookId, }) const logMsg = lang === 'fr' @@ -424,15 +448,11 @@ async function executeResearcherAgent( const title = await generateTitle(fullContent, agent.name, lang) - const note = await prisma.note.create({ - data: { - title, - content: fullContent, - isMarkdown: true, - autoGenerated: true, - userId: agent.userId, - notebookId: agent.targetNotebookId, - } + const note = await createAgentNote({ + title, + content: fullContent, + userId: agent.userId, + notebookId: agent.targetNotebookId, }) const logMsg = lang === 'fr' @@ -526,15 +546,11 @@ async function executeMonitorAgent( const title = await generateTitle(fullContent, agent.name, lang) - const note = await prisma.note.create({ - data: { - title, - content: fullContent, - isMarkdown: true, - autoGenerated: true, - userId: agent.userId, - notebookId: agent.targetNotebookId, - } + const note = await createAgentNote({ + title, + content: fullContent, + userId: agent.userId, + notebookId: agent.targetNotebookId, }) const logMsg = lang === 'fr' ? `Analyse de ${notes.length} notes. Note créée: ${note.id}` : `Analyzed ${notes.length} notes. Note created: ${note.id}` @@ -597,15 +613,11 @@ async function executeCustomAgent( const title = await generateTitle(fullContent, agent.name, lang) - const note = await prisma.note.create({ - data: { - title, - content: fullContent, - isMarkdown: true, - autoGenerated: true, - userId: agent.userId, - notebookId: agent.targetNotebookId, - } + const note = await createAgentNote({ + title, + content: fullContent, + userId: agent.userId, + notebookId: agent.targetNotebookId, }) const toolLogData = JSON.stringify([{ @@ -966,15 +978,11 @@ async function executeToolUseAgent( const fullContent = `# ${agent.name}\n\n${text}\n\n---\n\n_Agent execution: ${totalToolCalls} tool calls in ${Math.round(duration / 1000)}s_` const title = await generateTitle(fullContent, agent.name, lang) - const note = await prisma.note.create({ - data: { - title, - content: fullContent, - isMarkdown: true, - autoGenerated: true, - userId: agent.userId, - notebookId: agent.targetNotebookId || null, - } + const note = await createAgentNote({ + title, + content: fullContent, + userId: agent.userId, + notebookId: agent.targetNotebookId || null, }) noteId = note.id } @@ -1119,6 +1127,20 @@ export async function executeAgent(agentId: string, userId: string, promptOverri } } + // Create in-app notification for agent result + if (result.success) { + await createNotification({ + userId, + type: 'agent_success', + title: agent.name, + message: result.noteId + ? (lang === 'fr' ? `L'agent a terminé avec succès — note créée.` : `Agent completed successfully — note created.`) + : (lang === 'fr' ? `L'agent a terminé avec succès.` : `Agent completed successfully.`), + actionUrl: result.noteId ? `/?openNote=${result.noteId}` : '/agents', + relatedId: result.noteId || agentId, + }) + } + return result } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' @@ -1129,6 +1151,16 @@ export async function executeAgent(agentId: string, userId: string, promptOverri data: { status: 'failure', log: message } }) + // Notify user of agent failure + await createNotification({ + userId, + type: 'agent_failure', + title: agent?.name || 'Agent', + message: message.length > 200 ? message.substring(0, 200) + '...' : message, + actionUrl: '/agents', + relatedId: agentId, + }) + return { success: false, actionId: action.id, error: message } } } diff --git a/memento-note/lib/ai/services/image-description.service.ts b/memento-note/lib/ai/services/image-description.service.ts index 473b154..0048f38 100644 --- a/memento-note/lib/ai/services/image-description.service.ts +++ b/memento-note/lib/ai/services/image-description.service.ts @@ -19,23 +19,43 @@ export interface ImageDescriptionResult { const UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads') -async function resolveImageAsBase64(imageUrl: string): Promise { +async function resolveImageAsBase64(imageUrl: string): Promise { const localMatch = imageUrl.match(/\/uploads\/(.+)/) if (localMatch) { - const filePath = path.join(UPLOAD_DIR, localMatch[1]) - const buffer = await readFile(filePath) - const ext = path.extname(imageUrl).toLowerCase() - const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg' - return `data:${mime};base64,${buffer.toString('base64')}` + // Try reading from filesystem first + try { + const filePath = path.join(UPLOAD_DIR, localMatch[1]) + const buffer = await readFile(filePath) + const ext = path.extname(imageUrl).toLowerCase() + const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg' + return `data:${mime};base64,${buffer.toString('base64')}` + } catch { + // File not on disk — fallback to internal HTTP API (same path the browser uses) + try { + const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + const res = await fetch(`${baseUrl}${imageUrl}`) + if (!res.ok) return null + const contentType = res.headers.get('content-type') || 'image/jpeg' + const arrayBuffer = await res.arrayBuffer() + const base64 = Buffer.from(arrayBuffer).toString('base64') + return `data:${contentType};base64,${base64}` + } catch { + return null + } + } } // Remote URL — fetch and convert - const res = await fetch(imageUrl) - if (!res.ok) throw new Error(`Failed to fetch image: ${imageUrl}`) - const contentType = res.headers.get('content-type') || 'image/jpeg' - const arrayBuffer = await res.arrayBuffer() - const base64 = Buffer.from(arrayBuffer).toString('base64') - return `data:${contentType};base64,${base64}` + try { + const res = await fetch(imageUrl) + if (!res.ok) return null + const contentType = res.headers.get('content-type') || 'image/jpeg' + const arrayBuffer = await res.arrayBuffer() + const base64 = Buffer.from(arrayBuffer).toString('base64') + return `data:${contentType};base64,${base64}` + } catch { + return null + } } export async function describeImages( @@ -55,8 +75,9 @@ export async function describeImages( } const langName = langMap[language] || 'English' - // Resolve all images as base64 data URLs (same approach as the chat route) - const imageDataUrls = await Promise.all(imageUrls.map(url => resolveImageAsBase64(url))) + // Resolve all images as base64 data URLs — skip any that can't be found + const resolved = await Promise.all(imageUrls.map(url => resolveImageAsBase64(url))) + const imageDataUrls = resolved.filter((d): d is string => d !== null) if (isTitleMode) { const prompt = imageUrls.length === 1 diff --git a/memento-note/lib/ai/tools/note-crud.tool.ts b/memento-note/lib/ai/tools/note-crud.tool.ts index eb9d878..9bfd450 100644 --- a/memento-note/lib/ai/tools/note-crud.tool.ts +++ b/memento-note/lib/ai/tools/note-crud.tool.ts @@ -7,6 +7,7 @@ import { tool } from 'ai' import { z } from 'zod' import { toolRegistry } from './registry' import { prisma } from '@/lib/prisma' +import { markdownToHtml } from '@/lib/markdown-to-html' // --- note_read --- toolRegistry.register({ @@ -50,11 +51,13 @@ toolRegistry.register({ }), execute: async ({ title, content, notebookId, images }) => { try { + const htmlContent = markdownToHtml(content) const note = await prisma.note.create({ data: { title, - content, - isMarkdown: true, + content: htmlContent, + type: 'richtext', + isMarkdown: false, autoGenerated: true, userId: ctx.userId, notebookId: notebookId || null, diff --git a/memento-note/lib/config.ts b/memento-note/lib/config.ts index 5f79950..5e61208 100644 --- a/memento-note/lib/config.ts +++ b/memento-note/lib/config.ts @@ -1,24 +1,32 @@ import prisma from './prisma' -// "openrouter" était une ancienne valeur de provider — on la normalise en "custom" -function normalizeProvider(val: string | undefined): string { - if (!val) return '' - return val === 'openrouter' ? 'custom' : val -} - // Environment variable fallbacks for system config keys const ENV_FALLBACKS: Record = { - // AI providers (openrouter → custom) - AI_PROVIDER_TAGS: normalizeProvider(process.env.AI_PROVIDER_TAGS), + // AI providers + AI_PROVIDER_TAGS: process.env.AI_PROVIDER_TAGS || '', AI_MODEL_TAGS: process.env.AI_MODEL_TAGS || '', - AI_PROVIDER_EMBEDDING: normalizeProvider(process.env.AI_PROVIDER_EMBEDDING), + AI_PROVIDER_EMBEDDING: process.env.AI_PROVIDER_EMBEDDING || '', AI_MODEL_EMBEDDING: process.env.AI_MODEL_EMBEDDING || '', - AI_PROVIDER_CHAT: normalizeProvider(process.env.AI_PROVIDER_CHAT), + AI_PROVIDER_CHAT: process.env.AI_PROVIDER_CHAT || '', AI_MODEL_CHAT: process.env.AI_MODEL_CHAT || '', + // Ollama OLLAMA_BASE_URL: process.env.OLLAMA_BASE_URL || '', + // OpenAI OPENAI_API_KEY: process.env.OPENAI_API_KEY || '', + // Custom OpenAI CUSTOM_OPENAI_API_KEY: process.env.CUSTOM_OPENAI_API_KEY || '', CUSTOM_OPENAI_BASE_URL: process.env.CUSTOM_OPENAI_BASE_URL || '', + // DeepSeek + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY || '', + // OpenRouter + OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY || '', + // Mistral + MISTRAL_API_KEY: process.env.MISTRAL_API_KEY || '', + // Z.AI + ZAI_API_KEY: process.env.ZAI_API_KEY || '', + // LM Studio + LMSTUDIO_BASE_URL: process.env.LMSTUDIO_BASE_URL || '', + LMSTUDIO_API_KEY: process.env.LMSTUDIO_API_KEY || '', // Email EMAIL_PROVIDER: process.env.EMAIL_PROVIDER || (process.env.RESEND_API_KEY ? 'resend' : 'smtp'), RESEND_API_KEY: process.env.RESEND_API_KEY || '', @@ -51,11 +59,6 @@ export async function getSystemConfig() { console.error('Failed to load system config from DB:', e) } - // Normalise les valeurs openrouter → custom dans la DB aussi - for (const key of ['AI_PROVIDER_TAGS', 'AI_PROVIDER_EMBEDDING', 'AI_PROVIDER_CHAT']) { - if (dbConfig[key] === 'openrouter') dbConfig[key] = 'custom' - } - // Merge: DB values take precedence, env vars as fallback const merged = { ...ENV_FALLBACKS, ...dbConfig } return merged diff --git a/memento-note/lib/i18n/detect-user-language.ts b/memento-note/lib/i18n/detect-user-language.ts index 2d7c18d..3e370c1 100644 --- a/memento-note/lib/i18n/detect-user-language.ts +++ b/memento-note/lib/i18n/detect-user-language.ts @@ -1,6 +1,9 @@ /** - * Detect user's preferred language from their existing notes - * Uses a single DB-level GROUP BY query — no note content is loaded + * Detect user's preferred language. + * Priority: + * 1. Most common language among user's notes (DB GROUP BY) + * 2. Browser language hint (passed from server component via Accept-Language) + * 3. Default: 'en' */ import { auth } from '@/auth' @@ -10,10 +13,37 @@ import { SupportedLanguage } from './load-translations' const SUPPORTED_LANGUAGES = new Set(['en', 'fr', 'es', 'de', 'fa', 'it', 'pt', 'ru', 'zh', 'ja', 'ko', 'ar', 'hi', 'nl', 'pl']) +/** + * Parse an Accept-Language header string and find the best matching supported language + */ +export function parseAcceptLanguage(acceptLanguage: string | null): SupportedLanguage | null { + if (!acceptLanguage) return null + + // Parse Accept-Language: "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7" + const languages = acceptLanguage + .split(',') + .map(lang => { + const [code, q] = lang.trim().split(';q=') + return { code: code.trim().toLowerCase(), quality: q ? parseFloat(q) : 1.0 } + }) + .sort((a, b) => b.quality - a.quality) + + for (const { code } of languages) { + if (SUPPORTED_LANGUAGES.has(code)) { + return code as SupportedLanguage + } + const base = code.split('-')[0] + if (SUPPORTED_LANGUAGES.has(base)) { + return base as SupportedLanguage + } + } + + return null +} + const getCachedUserLanguage = unstable_cache( - async (userId: string): Promise => { + async (userId: string): Promise => { try { - // Single aggregated query — no notes are fetched, only language counts const result = await prisma.note.groupBy({ by: ['language'], where: { @@ -33,22 +63,39 @@ const getCachedUserLanguage = unstable_cache( } } - return 'en' + return null } catch (error) { - console.error('Error detecting user language:', error) - return 'en' + console.error('Error detecting user language from notes:', error) + return null } }, ['user-language'], { tags: ['user-language'] } ) -export async function detectUserLanguage(): Promise { +/** + * Detect user language. + * @param browserLanguageHint - Optional browser language parsed from Accept-Language header. + * Should be passed from server components that have access to headers(). + */ +export async function detectUserLanguage(browserLanguageHint?: SupportedLanguage | null): Promise { const session = await auth() if (!session?.user?.id) { - return 'en' + return browserLanguageHint || 'en' } - return getCachedUserLanguage(session.user.id) + // 1. Try to detect from user's notes + const noteLanguage = await getCachedUserLanguage(session.user.id) + if (noteLanguage) { + return noteLanguage + } + + // 2. Fall back to browser language hint + if (browserLanguageHint) { + return browserLanguageHint + } + + // 3. Default + return 'en' } diff --git a/memento-note/lib/markdown-to-html.ts b/memento-note/lib/markdown-to-html.ts new file mode 100644 index 0000000..8f0b68a --- /dev/null +++ b/memento-note/lib/markdown-to-html.ts @@ -0,0 +1,225 @@ +/** + * Server-side Markdown → HTML converter. + * Converts AI-generated markdown notes into TipTap-compatible rich text HTML. + * Uses a lightweight regex-based approach to avoid heavy remark/rehype dependencies. + * + * Handles: headings, bold, italic, strikethrough, code blocks, inline code, + * links, images, lists (ul/ol), blockquotes, horizontal rules, tables, paragraphs. + */ + +export function markdownToHtml(markdown: string): string { + if (!markdown || !markdown.trim()) return '' + + let html = markdown + + // Escape HTML entities (but preserve markdown) + html = html.replace(/&/g, '&') + html = html.replace(//g, '>') + + // Code blocks (``` ... ```) — protect from further processing + const codeBlocks: string[] = [] + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => { + const idx = codeBlocks.length + codeBlocks.push(`
${code.trim()}
`) + return `%%CODEBLOCK_${idx}%%` + }) + + // Inline code (`...`) + html = html.replace(/`([^`]+)`/g, '$1') + + // Images (![alt](url)) + html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1') + + // Links ([text](url)) + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + + // Headings (h1-h6) + html = html.replace(/^######\s+(.+)$/gm, '
$1
') + html = html.replace(/^#####\s+(.+)$/gm, '
$1
') + html = html.replace(/^####\s+(.+)$/gm, '

$1

') + html = html.replace(/^###\s+(.+)$/gm, '

$1

') + html = html.replace(/^##\s+(.+)$/gm, '

$1

') + html = html.replace(/^#\s+(.+)$/gm, '

$1

') + + // Bold (**text** or __text__) + html = html.replace(/\*\*([^*]+)\*\*/g, '$1') + html = html.replace(/__([^_]+)__/g, '$1') + + // Italic (*text* or _text_) + html = html.replace(/(?$1') + html = html.replace(/(?$1') + + // Strikethrough (~~text~~) + html = html.replace(/~~([^~]+)~~/g, '$1') + + // Horizontal rules (---, ***, ___) + html = html.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '
') + + // Tables + html = convertTables(html) + + // Blockquotes (> text) + html = html.replace(/^>\s+(.+)$/gm, '

$1

') + // Merge consecutive blockquotes + html = html.replace(/<\/blockquote>\n
/g, '\n') + + // Unordered lists (- item or * item) + html = convertUnorderedLists(html) + + // Ordered lists (1. item) + html = convertOrderedLists(html) + + // Restore code blocks + codeBlocks.forEach((block, idx) => { + html = html.replace(`%%CODEBLOCK_${idx}%%`, block) + }) + + // Paragraphs — wrap remaining loose text in

tags + html = wrapParagraphs(html) + + // Clean up empty paragraphs + html = html.replace(/

\s*<\/p>/g, '') + + return html.trim() +} + +function convertTables(html: string): string { + // Simple table conversion: | header | header |\n| --- | --- |\n| cell | cell | + const tableRegex = /(?:^|\n)((?:\|[^\n]+\|\n)+)/g + + return html.replace(tableRegex, (match) => { + const rows = match.trim().split('\n').filter(r => r.trim()) + if (rows.length < 2) return match + + // Check if second row is separator + const separator = rows[1].trim() + if (!/^[\s|:-]+$/.test(separator)) return match + + let table = '' + + // Header row + const headers = parseTableRow(rows[0]) + if (headers.length > 0) { + table += '' + headers.forEach(h => { table += `` }) + table += '' + } + + // Body rows (skip separator) + const bodyRows = rows.slice(2) + if (bodyRows.length > 0) { + table += '' + bodyRows.forEach(row => { + const cells = parseTableRow(row) + table += '' + cells.forEach(c => { table += `` }) + table += '' + }) + table += '' + } + + table += '
${h}
${c}
' + return '\n' + table + '\n' + }) +} + +function parseTableRow(row: string): string[] { + return row.split('|') + .map(cell => cell.trim()) + .filter((_, i, arr) => i > 0 && i < arr.length) // Skip first and last empty from leading/trailing | +} + +function convertUnorderedLists(html: string): string { + const lines = html.split('\n') + const result: string[] = [] + let inList = false + + for (const line of lines) { + const listMatch = line.match(/^(\s*)[-*]\s+(.+)$/) + if (listMatch) { + if (!inList) { + result.push('

    ') + inList = true + } + result.push(`
  • ${listMatch[2]}
  • `) + } else { + if (inList) { + result.push('
') + inList = false + } + result.push(line) + } + } + if (inList) result.push('') + + return result.join('\n') +} + +function convertOrderedLists(html: string): string { + const lines = html.split('\n') + const result: string[] = [] + let inList = false + + for (const line of lines) { + const listMatch = line.match(/^(\s*)\d+\.\s+(.+)$/) + if (listMatch) { + if (!inList) { + result.push('
    ') + inList = true + } + result.push(`
  1. ${listMatch[2]}
  2. `) + } else { + if (inList) { + result.push('
') + inList = false + } + result.push(line) + } + } + if (inList) result.push('') + + return result.join('\n') +} + +function wrapParagraphs(html: string): string { + const blockTags = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'p', 'div', 'img']) + + const lines = html.split('\n') + const result: string[] = [] + let buffer: string[] = [] + + const flushBuffer = () => { + const text = buffer.join('\n').trim() + if (text) { + // Don't double-wrap if already starts with a block tag + const firstTag = text.match(/^<(\w+)/) + if (firstTag && blockTags.has(firstTag[1].toLowerCase())) { + result.push(text) + } else { + result.push(`

${text}

`) + } + } + buffer = [] + } + + for (const line of lines) { + const trimmed = line.trim() + + // Check if this line is a block-level element + const isBlockLine = trimmed.startsWith('<') && (() => { + const tag = trimmed.match(/^<(\w+)/) + return tag ? blockTags.has(tag[1].toLowerCase()) : false + })() + + if (isBlockLine || trimmed === '') { + flushBuffer() + if (isBlockLine) result.push(trimmed) + } else { + buffer.push(trimmed) + } + } + flushBuffer() + + return result.join('\n') +} diff --git a/memento-note/locales/ar.json b/memento-note/locales/ar.json index 6d11fa0..d5d0c9d 100644 --- a/memento-note/locales/ar.json +++ b/memento-note/locales/ar.json @@ -837,6 +837,11 @@ "providerOllamaOption": "🦙 Ollama (Local & Free)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 Custom OpenAI-Compatible", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (محلي)", "bestValue": "أفضل قيمة", "bestQuality": "أفضل جودة", "saved": "(تم الحفظ)", @@ -1175,7 +1180,11 @@ "notesViewLabel": "عرض الملاحظات", "notesViewTabs": "علامات تبويب (نمط OneNote)", "notesViewMasonry": "بطاقات (شبكة)", - "selectTheme": "Select theme" + "selectTheme": "Select theme", + "fontFamilyLabel": "عائلة الخطوط", + "fontFamilyDescription": "اختر الخط المستخدم في جميع أنحاء التطبيق", + "selectFontFamily": "Inter مُحسّن لسهولة القراءة، النظام يستخدم الخط الأصلي لنظام التشغيل", + "fontSystem": "الخط الافتراضي للنظام" }, "generalSettings": { "title": "الإعدادات العامة", diff --git a/memento-note/locales/de.json b/memento-note/locales/de.json index 86d0113..a6cf0ac 100644 --- a/memento-note/locales/de.json +++ b/memento-note/locales/de.json @@ -837,6 +837,11 @@ "providerOllamaOption": "🦙 Ollama (Local & Free)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 Custom OpenAI-Compatible", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (Lokal)", "bestValue": "Bestes Preis-Leistungs-Verhältnis", "bestQuality": "Beste Qualität", "saved": "(Gespeichert)", @@ -1175,7 +1180,11 @@ "notesViewLabel": "Notizen-Ansicht", "notesViewTabs": "Tabs (OneNote-Stil)", "notesViewMasonry": "Karten (Raster)", - "selectTheme": "Select theme" + "selectTheme": "Select theme", + "fontFamilyLabel": "Schriftfamilie", + "fontFamilyDescription": "Wählen Sie die im gesamten Programm verwendete Schriftart", + "selectFontFamily": "Inter ist für Lesbarkeit optimiert, System verwendet die native Schriftart Ihres Betriebssystems", + "fontSystem": "Standardsystemschriftart" }, "generalSettings": { "title": "Allgemeine Einstellungen", diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index e2aa42e..528a2b3 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -841,6 +841,11 @@ "providerOllamaOption": "🦙 Ollama (Local & Free)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 Custom OpenAI-Compatible", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (Local)", "bestValue": "Best value", "bestQuality": "Best quality", "saved": "(Saved)", @@ -1179,7 +1184,11 @@ "notesViewLabel": "Notes layout", "notesViewTabs": "Tabs (OneNote-style)", "notesViewMasonry": "Cards (grid)", - "selectTheme": "Select theme" + "selectTheme": "Select theme", + "fontFamilyLabel": "Font Family", + "fontFamilyDescription": "Choose the font used throughout the app", + "selectFontFamily": "Inter is optimized for readability, System uses your OS native font", + "fontSystem": "System Default" }, "generalSettings": { "title": "General Settings", diff --git a/memento-note/locales/es.json b/memento-note/locales/es.json index faba4d9..3ab8c97 100644 --- a/memento-note/locales/es.json +++ b/memento-note/locales/es.json @@ -837,6 +837,11 @@ "providerOllamaOption": "🦙 Ollama (Local & Free)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 Custom OpenAI-Compatible", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (Local)", "bestValue": "Mejor relación calidad/precio", "bestQuality": "Mejor calidad", "saved": "(Guardado)", @@ -1175,7 +1180,11 @@ "notesViewLabel": "Vista de notas", "notesViewTabs": "Pestañas (estilo OneNote)", "notesViewMasonry": "Tarjetas (cuadrícula)", - "selectTheme": "Select theme" + "selectTheme": "Select theme", + "fontFamilyLabel": "Familia de fuentes", + "fontFamilyDescription": "Elige la fuente utilizada en toda la aplicación", + "selectFontFamily": "Inter está optimizado para la legibilidad, Sistema usa la fuente nativa de tu sistema operativo", + "fontSystem": "Fuente del sistema predeterminada" }, "generalSettings": { "title": "Configuración general", diff --git a/memento-note/locales/fa.json b/memento-note/locales/fa.json index 02cf2b9..83e7534 100644 --- a/memento-note/locales/fa.json +++ b/memento-note/locales/fa.json @@ -837,6 +837,11 @@ "providerOllamaOption": "🦙 Ollama (محلی و رایگان)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 سفارشی سازگار با OpenAI", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (محلی)", "bestValue": "بهترین ارزش", "bestQuality": "بهترین کیفیت", "saved": "(ذخیره شد)", @@ -1175,7 +1180,11 @@ "notesViewLabel": "چیدمان یادداشت‌ها", "notesViewTabs": "زبانه‌ها (سبک OneNote)", "notesViewMasonry": "کارت‌ها (شبکه‌ای)", - "selectTheme": "Select theme" + "selectTheme": "Select theme", + "fontFamilyLabel": "خانواده فونت", + "fontFamilyDescription": "فونت استفاده شده در سراسر برنامه را انتخاب کنید", + "selectFontFamily": "Inter برای خوانایی بهینه شده است، سیستم از فونت بومی سیستم‌عامل شما استفاده می‌کند", + "fontSystem": "فونت پیش‌فرض سیستم" }, "generalSettings": { "title": "تنظیمات عمومی", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index 807de40..e3728e5 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -841,6 +841,11 @@ "providerOllamaOption": "🦙 Ollama (Local & Gratuit)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 Custom Compatible OpenAI", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (Local)", "bestValue": "Meilleur rapport qualité/prix", "bestQuality": "Meilleure qualité", "saved": "(Enregistré)", @@ -1179,7 +1184,11 @@ "notesViewLabel": "Affichage des notes", "notesViewTabs": "Onglets (type OneNote)", "notesViewMasonry": "Cartes (grille)", - "selectTheme": "Sélectionner le thème" + "selectTheme": "Sélectionner le thème", + "fontFamilyLabel": "Famille de polices", + "fontFamilyDescription": "Choisissez la police utilisée dans toute l'application", + "selectFontFamily": "Inter est optimisé pour la lisibilité, Système utilise la police native de votre système d'exploitation", + "fontSystem": "Police système par défaut" }, "generalSettings": { "title": "Paramètres généraux", diff --git a/memento-note/locales/hi.json b/memento-note/locales/hi.json index 60da8a8..18c8112 100644 --- a/memento-note/locales/hi.json +++ b/memento-note/locales/hi.json @@ -837,6 +837,11 @@ "providerOllamaOption": "🦙 Ollama (Local & Free)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 Custom OpenAI-Compatible", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (स्थानीय)", "bestValue": "सर्वोत्तम मूल्य", "bestQuality": "सर्वोत्तम गुणवत्ता", "saved": "(सहेजा गया)", @@ -1175,7 +1180,11 @@ "notesViewLabel": "नोट्स दृश्य", "notesViewTabs": "टैब (OneNote-शैली)", "notesViewMasonry": "कार्ड (ग्रिड)", - "selectTheme": "Select theme" + "selectTheme": "Select theme", + "fontFamilyLabel": "फ़ॉन्ट परिवार", + "fontFamilyDescription": "पूरे ऐप में उपयोग किए जाने वाले फ़ॉन्ट का चयन करें", + "selectFontFamily": "Inter पठनीयता के लिए अनुकूलित है, सिस्टम आपके OS के मूल फ़ॉन्ट का उपयोग करता है", + "fontSystem": "सिस्टम डिफ़ॉल्ट" }, "generalSettings": { "title": "सामान्य सेटिंग्स", diff --git a/memento-note/locales/it.json b/memento-note/locales/it.json index d6e29aa..4448763 100644 --- a/memento-note/locales/it.json +++ b/memento-note/locales/it.json @@ -837,6 +837,11 @@ "providerOllamaOption": "🦙 Ollama (Local & Free)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 Custom OpenAI-Compatible", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (Locale)", "bestValue": "Miglior rapporto qualità/prezzo", "bestQuality": "Miglior qualità", "saved": "(Salvato)", @@ -1175,7 +1180,11 @@ "notesViewLabel": "Vista note", "notesViewTabs": "Schede (stile OneNote)", "notesViewMasonry": "Schede (griglia)", - "selectTheme": "Select theme" + "selectTheme": "Select theme", + "fontFamilyLabel": "Famiglia di caratteri", + "fontFamilyDescription": "Scegli il carattere utilizzato in tutta l'app", + "selectFontFamily": "Inter è ottimizzato per la leggibilità, Sistema usa il carattere nativo del tuo sistema operativo", + "fontSystem": "Carattere predefinito di sistema" }, "generalSettings": { "title": "Impostazioni generali", diff --git a/memento-note/locales/ja.json b/memento-note/locales/ja.json index 1ed96f3..b63bc5b 100644 --- a/memento-note/locales/ja.json +++ b/memento-note/locales/ja.json @@ -837,6 +837,11 @@ "providerOllamaOption": "🦙 Ollama (Local & Free)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 Custom OpenAI-Compatible", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (ローカル)", "bestValue": "最もコスパが良い", "bestQuality": "最高品質", "saved": "(保存済み)", @@ -1175,7 +1180,11 @@ "notesViewLabel": "ノートのレイアウト", "notesViewTabs": "タブ(OneNote風)", "notesViewMasonry": "カード(グリッド)", - "selectTheme": "Select theme" + "selectTheme": "Select theme", + "fontFamilyLabel": "フォントファミリー", + "fontFamilyDescription": "アプリ全体で使用するフォントを選択してください", + "selectFontFamily": "Inter は読みやすさに最適化されています。システムはOSのネイティブフォントを使用します", + "fontSystem": "システムデフォルト" }, "generalSettings": { "title": "一般設定", diff --git a/memento-note/locales/ko.json b/memento-note/locales/ko.json index 9be7daf..1a35e99 100644 --- a/memento-note/locales/ko.json +++ b/memento-note/locales/ko.json @@ -837,6 +837,11 @@ "providerOllamaOption": "🦙 Ollama (로컬 및 무료)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 사용자 정의 OpenAI 호환", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (로컬)", "bestValue": "최고 가성비", "bestQuality": "최고 품질", "saved": "(저장됨)", @@ -1175,7 +1180,11 @@ "notesViewLabel": "메모 레이아웃", "notesViewTabs": "탭 (OneNote 스타일)", "notesViewMasonry": "카드 (그리드)", - "selectTheme": "Select theme" + "selectTheme": "Select theme", + "fontFamilyLabel": "글꼴 패밀리", + "fontFamilyDescription": "앱 전체에서 사용할 글꼴을 선택하세요", + "selectFontFamily": "Inter는 가독성에 최적화되어 있으며, 시스템은 OS 기본 글꼴을 사용합니다", + "fontSystem": "시스템 기본 글꼴" }, "generalSettings": { "title": "일반 설정", diff --git a/memento-note/locales/nl.json b/memento-note/locales/nl.json index a1bb284..81b02fc 100644 --- a/memento-note/locales/nl.json +++ b/memento-note/locales/nl.json @@ -837,6 +837,11 @@ "providerOllamaOption": "🦙 Ollama (Local & Free)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 Custom OpenAI-Compatible", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (Lokaal)", "bestValue": "Beste prijs-kwaliteit", "bestQuality": "Beste kwaliteit", "saved": "(Opgeslagen)", @@ -1175,7 +1180,11 @@ "notesViewLabel": "Notities weergave", "notesViewTabs": "Tabbladen (OneNote-stijl)", "notesViewMasonry": "Kaarten (raster)", - "selectTheme": "Select theme" + "selectTheme": "Select theme", + "fontFamilyLabel": "Lettertypefamilie", + "fontFamilyDescription": "Kies het lettertype dat in de hele app wordt gebruikt", + "selectFontFamily": "Inter is geoptimaliseerd voor leesbaarheid, Systeem gebruikt het native lettertype van uw besturingssysteem", + "fontSystem": "Standaard systeemlettertype" }, "generalSettings": { "title": "Algemene instellingen", diff --git a/memento-note/locales/pl.json b/memento-note/locales/pl.json index 3661b4e..b63ce57 100644 --- a/memento-note/locales/pl.json +++ b/memento-note/locales/pl.json @@ -837,6 +837,11 @@ "providerOllamaOption": "🦙 Ollama (lokalny i darmowy)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 Niestandardowy (kompatybilny z OpenAI)", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (Lokalny)", "bestValue": "Najlepszy stosunek jakości do ceny", "bestQuality": "Najwyższa jakość", "saved": "(Zapisano)", @@ -1175,7 +1180,11 @@ "notesViewLabel": "Układ notatek", "notesViewTabs": "Karty (styl OneNote)", "notesViewMasonry": "Karty (siatka)", - "selectTheme": "Select theme" + "selectTheme": "Select theme", + "fontFamilyLabel": "Rodzina czcionek", + "fontFamilyDescription": "Wybierz czcionkę używaną w całej aplikacji", + "selectFontFamily": "Inter jest zoptymalizowany pod kątem czytelności, Systemowa używa natywnej czcionki systemu operacyjnego", + "fontSystem": "Domyślna czcionka systemowa" }, "generalSettings": { "title": "Ustawienia ogólne", diff --git a/memento-note/locales/pt.json b/memento-note/locales/pt.json index c257960..5b14597 100644 --- a/memento-note/locales/pt.json +++ b/memento-note/locales/pt.json @@ -837,6 +837,11 @@ "providerOllamaOption": "🦙 Ollama (Local e Gratuito)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 Compatível com OpenAI (Personalizado)", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (Local)", "bestValue": "Melhor custo-benefício", "bestQuality": "Melhor qualidade", "saved": "(Salvo)", @@ -1175,7 +1180,11 @@ "notesViewLabel": "Layout das notas", "notesViewTabs": "Abas (estilo OneNote)", "notesViewMasonry": "Cartões (grade)", - "selectTheme": "Select theme" + "selectTheme": "Select theme", + "fontFamilyLabel": "Família de fontes", + "fontFamilyDescription": "Escolha a fonte usada em todo o aplicativo", + "selectFontFamily": "Inter é otimizado para legibilidade, Sistema usa a fonte nativa do seu sistema operacional", + "fontSystem": "Fonte padrão do sistema" }, "generalSettings": { "title": "Configurações gerais", diff --git a/memento-note/locales/ru.json b/memento-note/locales/ru.json index c744052..e19358f 100644 --- a/memento-note/locales/ru.json +++ b/memento-note/locales/ru.json @@ -837,6 +837,11 @@ "providerOllamaOption": "🦙 Ollama (Локальный и бесплатный)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 Пользовательский (совместимый с OpenAI)", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (Локальный)", "bestValue": "Лучшее соотношение цена/качество", "bestQuality": "Лучшее качество", "saved": "(Сохранено)", @@ -1175,7 +1180,11 @@ "notesViewLabel": "Макет заметок", "notesViewTabs": "Вкладки (в стиле OneNote)", "notesViewMasonry": "Карточки (сетка)", - "selectTheme": "Select theme" + "selectTheme": "Select theme", + "fontFamilyLabel": "Семейство шрифтов", + "fontFamilyDescription": "Выберите шрифт, используемый во всём приложении", + "selectFontFamily": "Inter оптимизирован для читаемости, Системный использует нативный шрифт вашей ОС", + "fontSystem": "Системный шрифт по умолчанию" }, "generalSettings": { "title": "Общие настройки", diff --git a/memento-note/locales/zh.json b/memento-note/locales/zh.json index 437a54d..54a6314 100644 --- a/memento-note/locales/zh.json +++ b/memento-note/locales/zh.json @@ -837,6 +837,11 @@ "providerOllamaOption": "🦙 Ollama (Local & Free)", "providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)", "providerCustomOption": "🔧 Custom OpenAI-Compatible", + "providerDeepSeekOption": "🔍 DeepSeek", + "providerOpenRouterOption": "🌐 OpenRouter", + "providerMistralOption": "🌀 Mistral AI", + "providerZAIOption": "✨ Z.AI", + "providerLMStudioOption": "🖥️ LM Studio (本地)", "bestValue": "最佳性价比", "bestQuality": "最佳质量", "saved": "(已保存)", @@ -1175,7 +1180,11 @@ "notesViewLabel": "笔记布局", "notesViewTabs": "标签页(OneNote 风格)", "notesViewMasonry": "卡片(网格)", - "selectTheme": "Select theme" + "selectTheme": "Select theme", + "fontFamilyLabel": "字体系列", + "fontFamilyDescription": "选择应用程序中使用的字体", + "selectFontFamily": "Inter 针对可读性进行了优化,系统使用您操作系统的原生字体", + "fontSystem": "系统默认字体" }, "generalSettings": { "title": "常规设置", diff --git a/memento-note/package-lock.json b/memento-note/package-lock.json index f1abe0d..c0ea86e 100644 --- a/memento-note/package-lock.json +++ b/memento-note/package-lock.json @@ -501,7 +501,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -548,7 +547,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -576,7 +574,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -640,6 +637,28 @@ "integrity": "sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==", "license": "MIT" }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1601,7 +1620,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" @@ -2357,337 +2375,12 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/@parcel/watcher": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", - "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.3", - "is-glob": "^4.0.3", - "node-addon-api": "^7.0.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.6", - "@parcel/watcher-darwin-arm64": "2.5.6", - "@parcel/watcher-darwin-x64": "2.5.6", - "@parcel/watcher-freebsd-x64": "2.5.6", - "@parcel/watcher-linux-arm-glibc": "2.5.6", - "@parcel/watcher-linux-arm-musl": "2.5.6", - "@parcel/watcher-linux-arm64-glibc": "2.5.6", - "@parcel/watcher-linux-arm64-musl": "2.5.6", - "@parcel/watcher-linux-x64-glibc": "2.5.6", - "@parcel/watcher-linux-x64-musl": "2.5.6", - "@parcel/watcher-win32-arm64": "2.5.6", - "@parcel/watcher-win32-ia32": "2.5.6", - "@parcel/watcher-win32-x64": "2.5.6" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", - "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", - "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", - "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", - "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", - "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", - "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", - "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", - "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", - "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", - "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", - "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", - "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", - "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@playwright/test": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.59.1" }, @@ -2714,7 +2407,6 @@ "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=16.13" }, @@ -6215,7 +5907,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz", "integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -6477,7 +6168,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz", "integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -6650,7 +6340,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.22.5.tgz", "integrity": "sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -6690,7 +6379,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz", "integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -6705,7 +6393,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz", "integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", @@ -7215,7 +6902,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -7225,7 +6911,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -7286,7 +6971,6 @@ "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.4", @@ -7632,7 +7316,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -7804,7 +7487,6 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -8065,7 +7747,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz", "integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -8475,7 +8156,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -9570,7 +9250,6 @@ "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.20.0" }, @@ -10546,7 +10225,6 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", @@ -11348,14 +11026,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/node-releases": { "version": "2.0.37", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", @@ -11367,7 +11037,6 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", "license": "MIT-0", - "peer": true, "engines": { "node": ">=6.0.0" } @@ -11426,7 +11095,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -11523,7 +11191,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz", "integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -11843,7 +11510,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", "integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -11883,7 +11549,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -12357,7 +12022,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12392,7 +12056,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -12414,7 +12077,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -12432,7 +12094,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -12565,7 +12226,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -12595,7 +12255,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -12656,7 +12315,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -12857,7 +12515,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12877,7 +12534,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13595,8 +13251,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.3.tgz", "integrity": "sha512-fA/NX5gMf0ooCLISgB0wScaWgaj6rjTN2SVAwleURjiya7ITNkV+VMmoHtKkldP6CIZoYCZyxb8zP/e2TWoEtQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.2", @@ -13681,7 +13336,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14185,7 +13839,6 @@ "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.4", "@vitest/mocker": "4.1.4", @@ -14297,23 +13950,6 @@ } } }, - "node_modules/vitest/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/vitest/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -14329,14 +13965,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/vitest/node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -14350,28 +13978,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vitest/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/vitest/node_modules/vite": { "version": "8.0.9", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -14646,7 +14258,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/memento-note/prisma/migrations/20260501000000_add_notification_model/migration.sql b/memento-note/prisma/migrations/20260501000000_add_notification_model/migration.sql new file mode 100644 index 0000000..580f48b --- /dev/null +++ b/memento-note/prisma/migrations/20260501000000_add_notification_model/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "message" TEXT, + "read" BOOLEAN NOT NULL DEFAULT false, + "actionUrl" TEXT, + "relatedId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Notification_userId_read_idx" ON "Notification"("userId", "read"); + +-- CreateIndex +CREATE INDEX "Notification_userId_createdAt_idx" ON "Notification"("userId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/memento-note/prisma/migrations/20260501020000_add_font_family/migration.sql b/memento-note/prisma/migrations/20260501020000_add_font_family/migration.sql new file mode 100644 index 0000000..39911d7 --- /dev/null +++ b/memento-note/prisma/migrations/20260501020000_add_font_family/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "UserAISettings" ADD COLUMN "fontFamily" TEXT NOT NULL DEFAULT 'inter'; diff --git a/memento-note/prisma/schema.prisma b/memento-note/prisma/schema.prisma index 1dce3be..c4281a9 100644 --- a/memento-note/prisma/schema.prisma +++ b/memento-note/prisma/schema.prisma @@ -37,6 +37,7 @@ model User { sessions Session[] aiSettings UserAISettings? workflows Workflow[] + notifications Notification[] } model Account { @@ -278,6 +279,7 @@ model UserAISettings { noteHistory Boolean @default(false) noteHistoryMode String @default("manual") languageDetection Boolean @default(true) + fontFamily String @default("inter") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([memoryEcho]) @@ -409,3 +411,19 @@ model WorkflowRun { @@index([workflowId]) @@index([status]) } + +model Notification { + id String @id @default(cuid()) + userId String + type String // "agent_success", "agent_failure", "share", "reminder", "system" + title String + message String? + read Boolean @default(false) + actionUrl String? // e.g. "/agents" or "/notes/xxx" + relatedId String? // e.g. agentId or noteId + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, read]) + @@index([userId, createdAt]) +} diff --git a/scripts/deploy-docker.ps1 b/scripts/deploy-docker.ps1 new file mode 100644 index 0000000..8591837 --- /dev/null +++ b/scripts/deploy-docker.ps1 @@ -0,0 +1,474 @@ +# ============================================================ +# Memento - Docker Deploy Script (Windows PowerShell) +# ============================================================ +# Usage: +# .\scripts\deploy-docker.ps1 # Full setup +# .\scripts\deploy-docker.ps1 -EnvOnly # Generate .env.docker only +# .\scripts\deploy-docker.ps1 -Build # Build + deploy (no env setup) +# .\scripts\deploy-docker.ps1 -Stop # Stop all containers +# .\scripts\deploy-docker.ps1 -Logs # Show logs +# ============================================================ + +[CmdletBinding()] +param( + [switch]$EnvOnly = $false, + [switch]$Build = $false, + [switch]$Full = $false, + [switch]$Stop = $false, + [switch]$Logs = $false +) + +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ProjectDir = Split-Path -Parent $ScriptDir +$EnvFile = Join-Path $ProjectDir ".env.docker" + +# ----------------------------------------------------------- +# Helpers +# ----------------------------------------------------------- +function Write-Info($msg) { Write-Host "[INFO] $msg" -ForegroundColor Blue } +function Write-Ok($msg) { Write-Host "[OK] $msg" -ForegroundColor Green } +function Write-Warn($msg) { Write-Host "[WARN] $msg" -ForegroundColor Yellow } +function Write-Err($msg) { Write-Host "[ERROR] $msg" -ForegroundColor Red; exit 1 } +function Write-Step($msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan } + +function Get-RandomSecret { + $bytes = New-Object byte[] 32 + [Security.Cryptography.RNGCryptoServiceProvider]::Create().GetBytes($bytes) + [Convert]::ToBase64String($bytes) +} + +function Get-RandomPassword { + $bytes = New-Object byte[] 16 + [Security.Cryptography.RNGCryptoServiceProvider]::Create().GetBytes($bytes) + ($bytes | ForEach-Object { $_.ToString("x2") }) -join "" +} + +function Ask-Input { + param([string]$Prompt, [string]$Default = "") + if ($Default) { + Write-Host " ? $Prompt [$Default]: " -ForegroundColor Cyan -NoNewline + } else { + Write-Host " ? $Prompt: " -ForegroundColor Cyan -NoNewline + } + $result = Read-Host + if ([string]::IsNullOrWhiteSpace($result)) { $result = $Default } + return $result +} + +function Ask-Required { + param([string]$Prompt) + while ($true) { + Write-Host " ? $Prompt (required): " -ForegroundColor Cyan -NoNewline + $result = Read-Host + if (-not [string]::IsNullOrWhiteSpace($result)) { return $result } + Write-Host " This field is required." -ForegroundColor Red + } +} + +function Ask-Email { + param([string]$Prompt) + while ($true) { + Write-Host " ? $Prompt (required): " -ForegroundColor Cyan -NoNewline + $result = Read-Host + if (-not [string]::IsNullOrWhiteSpace($result) -and $result -match '^[^@]+@[^@]+\.[^@]+$') { + return $result + } + Write-Host " Please enter a valid email address." -ForegroundColor Red + } +} + +# ----------------------------------------------------------- +# Check dependencies +# ----------------------------------------------------------- +function Check-Deps { + Write-Step "Checking dependencies..." + + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + Write-Err "Docker is not installed. Install from: https://docs.docker.com/desktop/install/windows-install/" + } + + try { + docker info 2>&1 | Out-Null + } catch { + Write-Err "Docker daemon is not running. Start Docker Desktop first." + } + + try { + docker compose version 2>&1 | Out-Null + $script:ComposeCmd = "docker compose" + } catch { + if (Get-Command docker-compose -ErrorAction SilentlyContinue) { + $script:ComposeCmd = "docker-compose" + } else { + Write-Err "Docker Compose is not installed. Install from: https://docs.docker.com/compose/install/" + } + } + + Write-Ok "All dependencies met" +} + +# ----------------------------------------------------------- +# Generate .env.docker +# ----------------------------------------------------------- +function Generate-Env { + Write-Step "Configuring Memento for Docker deployment" + + if (Test-Path $EnvFile) { + Write-Warn ".env.docker already exists." + $confirm = Ask-Input "Overwrite?" "N" + if ($confirm -notmatch "^[Yy]") { + Write-Info "Keeping existing .env.docker" + return + } + } + + Write-Host "" + Write-Host " This wizard will guide you through the configuration." -ForegroundColor White + Write-Host " Press Enter to accept defaults in [brackets]." -ForegroundColor White + Write-Host "" + + # ---- Core ---- + Write-Step "Core configuration" + + $url = Ask-Input "App URL (NEXTAUTH_URL)" "http://localhost:3000" + $secret = Get-RandomSecret + Write-Info "Auto-generated NEXTAUTH_SECRET" + $adminEmail = Ask-Email "Admin email (first user with this email becomes ADMIN)" + + $allowReg = Ask-Input "Allow public registration" "true" + if ($allowReg -match "^[Yy]|^[Yy]es|^true|^1") { $allowReg = "true" } else { $allowReg = "false" } + + # ---- PostgreSQL ---- + Write-Step "PostgreSQL configuration" + + $pgPort = Ask-Input "PostgreSQL exposed port" "5433" + $pgDb = Ask-Input "PostgreSQL database name" "memento" + $pgUser = Ask-Input "PostgreSQL username" "memento" + $pgPass = Get-RandomPassword + Write-Info "Auto-generated secure PostgreSQL password" + + # ---- AI Provider ---- + Write-Step "AI Provider configuration" + + Write-Host " Choose your AI provider:" + Write-Host " 1) OpenAI" + Write-Host " 2) Ollama (local, requires Ollama container)" + Write-Host " 3) OpenRouter" + Write-Host " 4) Custom OpenAI-compatible (Groq, Together, Mistral, etc.)" + Write-Host " 5) Skip AI configuration" + Write-Host "" + $aiChoice = Ask-Input "Choice" "5" + + $aiTagsProvider = $aiTagsModel = $aiEmbedProvider = $aiEmbedModel = "" + $aiChatProvider = $aiChatModel = $openaiKey = $customKey = $customUrl = $ollamaUrl = "" + + switch ($aiChoice) { + "1" { + $aiTagsProvider = "openai"; $aiTagsModel = "gpt-4o-mini" + $aiEmbedProvider = "openai"; $aiEmbedModel = "text-embedding-3-small" + $aiChatProvider = "openai"; $aiChatModel = "gpt-4o-mini" + $openaiKey = Ask-Required "OpenAI API Key" + } + "2" { + $aiTagsProvider = "ollama"; $aiTagsModel = "granite4:latest" + $aiEmbedProvider = "ollama"; $aiEmbedModel = "embeddinggemma:latest" + $aiChatProvider = "ollama"; $aiChatModel = "granite4:latest" + $ollamaUrl = Ask-Input "Ollama base URL" "http://ollama:11434" + } + "3" { + $aiTagsProvider = "custom"; $aiTagsModel = "google/gemma-3-27b-it" + $aiEmbedProvider = "custom"; $aiEmbedModel = "text-embedding-3-small" + $aiChatProvider = "custom"; $aiChatModel = "google/gemma-3-27b-it" + $customUrl = "https://openrouter.ai/api/v1" + $customKey = Ask-Required "OpenRouter API Key" + $customUrl = Ask-Input "OpenRouter base URL" $customUrl + } + "4" { + $aiTagsProvider = "custom" + $aiEmbedProvider = "custom" + $aiChatProvider = "custom" + $customKey = Ask-Required "Custom provider API Key" + $customUrl = Ask-Required "Custom provider base URL" + $aiTagsModel = Ask-Input "Model for tags" "gpt-4o-mini" + $aiEmbedModel = Ask-Input "Model for embeddings" "text-embedding-3-small" + $aiChatModel = Ask-Input "Model for chat" "gpt-4o-mini" + } + "5" { + Write-Info "Skipping AI configuration. You can configure it later in the admin panel." + } + default { Write-Err "Invalid choice" } + } + + # ---- MCP Server ---- + Write-Step "MCP Server configuration" + + Write-Host " The MCP server allows external tools (Claude, Cline, N8N) to interact with Memento." + Write-Host "" + + $mcpEnable = Ask-Input "Enable MCP server?" "yes" + $mcpEnable = ($mcpEnable -match "^[Yy]|^[Yy]es|^true|^1") + $mcpPort = "3001"; $mcpServerMode = "sse"; $mcpServerUrl = ""; $mcpApiKey = "" + + if ($mcpEnable) { + $mcpPort = Ask-Input "MCP server port" "3001" + $mcpServerMode = Ask-Input "MCP server mode (sse or stdio)" "sse" + $mcpServerUrl = "$($url -replace ':\d+$',''):$mcpPort" + + $mcpAuth = Ask-Input "Require MCP authentication?" "yes" + if ($mcpAuth -match "^[Yy]|^[Yy]es|^true|^1") { + $mcpApiKey = Get-RandomPassword + Write-Info "Auto-generated MCP API key" + } + } + + # ---- Email ---- + Write-Step "Email configuration (optional, needed for password reset)" + + Write-Host " Choose an email provider:" + Write-Host " 1) Resend" + Write-Host " 2) SMTP" + Write-Host " 3) Skip" + Write-Host "" + $emailChoice = Ask-Input "Choice" "3" + + $resendKey = $smtpHost = $smtpPort = $smtpUser = $smtpPass = $smtpFrom = "" + + switch ($emailChoice) { + "1" { $resendKey = Ask-Required "Resend API Key" } + "2" { + $smtpHost = Ask-Required "SMTP Host" + $smtpPort = Ask-Input "SMTP Port" "587" + $smtpUser = Ask-Required "SMTP Username" + $smtpPass = Ask-Required "SMTP Password" + $smtpFrom = Ask-Required "SMTP From email" + } + } + + # ---- Ollama container ---- + $enableOllama = "no" + if ($aiChoice -eq "2") { + $enableOllama = "yes" + } else { + Write-Step "Ollama container (optional)" + $enableOllama = Ask-Input "Also start Ollama container?" "no" + $enableOllama = $(if ($enableOllama -match "^[Yy]|^[Yy]es|^true|^1") { "yes" } else { "no" }) + } + + # ---- Web Search ---- + Write-Step "Web Search configuration (optional)" + + Write-Host " Choose a web search provider:" + Write-Host " 1) SearXNG (self-hosted)" + Write-Host " 2) Brave Search" + Write-Host " 3) Skip" + Write-Host "" + $searchChoice = Ask-Input "Choice" "3" + + $searxngUrl = $braveKey = "" + + switch ($searchChoice) { + "1" { $searxngUrl = Ask-Required "SearXNG URL" } + "2" { $braveKey = Ask-Required "Brave Search API Key" } + } + + # ---- Write .env.docker ---- + Write-Step "Writing .env.docker" + + $envContent = @" +# ============================================================================= +# Memento - Docker Environment (auto-generated by deploy-docker.ps1) +# ============================================================================= +# Generated on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') +# ============================================================================= + +# Core +NEXTAUTH_URL="$url" +NEXTAUTH_SECRET="$secret" +ADMIN_EMAIL="$adminEmail" +ALLOW_REGISTRATION="$allowReg" + +# PostgreSQL +POSTGRES_PORT=$pgPort +POSTGRES_DB=$pgDb +POSTGRES_USER=$pgUser +POSTGRES_PASSWORD="$pgPass" +"@ + + if ($aiChoice -ne "5") { + $envContent += @" + +# AI - Tags +AI_PROVIDER_TAGS=$aiTagsProvider +AI_MODEL_TAGS="$aiTagsModel" + +# AI - Embeddings +AI_PROVIDER_EMBEDDING=$aiEmbedProvider +AI_MODEL_EMBEDDING="$aiEmbedModel" + +# AI - Chat +AI_PROVIDER_CHAT=$aiChatProvider +AI_MODEL_CHAT="$aiChatModel" +"@ + if ($openaiKey) { $envContent += "`nOPENAI_API_KEY=`"$openaiKey`"" } + if ($customKey) { $envContent += "`nCUSTOM_OPENAI_API_KEY=`"$customKey`"`nCUSTOM_OPENAI_BASE_URL=`"$customUrl`"" } + if ($ollamaUrl) { $envContent += "`nOLLAMA_BASE_URL=`"$ollamaUrl`"" } + } + + if ($mcpEnable) { + $envContent += @" + +# MCP Server +MCP_MODE="$mcpServerMode" +MCP_PORT="$mcpPort" +MCP_SERVER_MODE="$mcpServerMode" +MCP_SERVER_URL="$mcpServerUrl" +"@ + if ($mcpApiKey) { $envContent += "`nMCP_API_KEY=`"$mcpApiKey`"" } + } else { + $envContent += @" + +# MCP Server (disabled) +MCP_SERVER_MODE="disabled" +"@ + } + + if ($resendKey) { $envContent += "`n`n# Email - Resend`nRESEND_API_KEY=`"$resendKey`"" } + if ($smtpHost) { + $envContent += @" + +# Email - SMTP +SMTP_HOST="$smtpHost" +SMTP_PORT="$smtpPort" +SMTP_USER="$smtpUser" +SMTP_PASS="$smtpPass" +SMTP_FROM="$smtpFrom" +"@ + } + + if ($searxngUrl) { $envContent += "`n`n# Web Search - SearXNG`nWEB_SEARCH_PROVIDER=`"searxng`"`nSEARXNG_URL=`"$searxngUrl`"" } + if ($braveKey) { $envContent += "`n`n# Web Search - Brave`nWEB_SEARCH_PROVIDER=`"brave`"`nBRAVE_SEARCH_API_KEY=`"$braveKey`"" } + + $envContent | Set-Content -Path $EnvFile -Encoding UTF8 + + Write-Ok ".env.docker created at $EnvFile" + Write-Host "" + Write-Host " Configuration summary:" -ForegroundColor White + Write-Host " URL: $url" + Write-Host " Admin email: $adminEmail" + Write-Host " Registration: $allowReg" + Write-Host " PostgreSQL user: $pgUser / db: $pgDb" + Write-Host " AI provider: $(if ($aiChoice -eq '5') { 'skipped' } else { $aiTagsProvider })" + Write-Host " MCP server: $(if ($mcpEnable) { "enabled ($mcpServerMode)" } else { 'disabled' })" + Write-Host " Email: $(if ($emailChoice -eq '3') { 'skipped' } else { 'configured' })" + Write-Host " Ollama container: $enableOllama" + Write-Host " (sensitive values are hidden)" +} + +# ----------------------------------------------------------- +# Build and deploy +# ----------------------------------------------------------- +function Deploy-Containers { + if (-not (Test-Path $EnvFile)) { + Write-Err ".env.docker not found. Run: .\scripts\deploy-docker.ps1 -EnvOnly" + } + + Push-Location $ProjectDir + + # Determine Ollama profile + $envContent = Get-Content $EnvFile -Raw + $ollamaProfile = "" + if ($envContent -match 'OLLAMA_BASE_URL="http://ollama' -or $envContent -match 'AI_PROVIDER_TAGS=ollama') { + $ollamaProfile = "--profile ollama" + } + + Write-Step "Building Docker containers..." + Invoke-Expression "$ComposeCmd build --parallel" 2>&1 + + Write-Step "Starting containers..." + Invoke-Expression "$ComposeCmd up -d $ollamaProfile" 2>&1 + + Write-Step "Waiting for services to be healthy..." + $retries = 0 + $maxRetries = 45 + while ($retries -lt $maxRetries) { + $status = Invoke-Expression "$ComposeCmd ps --format '{{.Status}}'" 2>$null + $unhealthy = ($status | Where-Object { $_ -notmatch "healthy|Up" }).Count + if ($unhealthy -eq 0) { break } + $retries++ + Start-Sleep -Seconds 2 + Write-Host -NoNewline "." + } + Write-Host "" + + if ($retries -ge $maxRetries) { + Write-Warn "Some containers may still be starting. Check status with: .\scripts\deploy-docker.ps1 -Logs" + } + + Write-Step "Waiting for database migrations (handled by entrypoint)..." + # The docker-entrypoint.sh runs prisma migrate deploy automatically on every start. + # It handles: fresh DB, existing DB with migrations, and P3005 baseline recovery. + # No manual migration step needed here. + + Write-Host "" + Write-Host "============================================" -ForegroundColor Green + Write-Host " Memento is running!" -ForegroundColor Green + Write-Host "============================================" -ForegroundColor Green + Write-Host "" + + Invoke-Expression "$ComposeCmd ps" + + $appUrl = (Select-String -Path $EnvFile -Pattern '^NEXTAUTH_URL=(.+)$').Matches[0].Groups[1].Value -replace '"','' + $admEmail = (Select-String -Path $EnvFile -Pattern '^ADMIN_EMAIL=(.+)$').Matches[0].Groups[1].Value -replace '"','' + + Write-Host " App: $appUrl" + Write-Host " Admin: Register with $admEmail to get admin access" + Write-Host "" + Write-Host " Useful commands:" -ForegroundColor Cyan + Write-Host " .\scripts\deploy-docker.ps1 -Logs View logs" + Write-Host " .\scripts\deploy-docker.ps1 -Stop Stop containers" + Write-Host " .\scripts\deploy-docker.ps1 -EnvOnly Reconfigure .env.docker" + + Pop-Location +} + +# ----------------------------------------------------------- +# Stop containers +# ----------------------------------------------------------- +function Stop-Containers { + Push-Location $ProjectDir + Write-Step "Stopping containers..." + Invoke-Expression "$ComposeCmd down" 2>&1 + Write-Ok "Containers stopped" + Pop-Location +} + +# ----------------------------------------------------------- +# Show logs +# ----------------------------------------------------------- +function Show-Logs { + Push-Location $ProjectDir + Invoke-Expression "$ComposeCmd logs -f --tail=100" + Pop-Location +} + +# ----------------------------------------------------------- +# Main +# ----------------------------------------------------------- +Check-Deps + +if ($EnvOnly) { + Generate-Env +} elseif ($Build) { + Deploy-Containers +} elseif ($Stop) { + Stop-Containers +} elseif ($Logs) { + Show-Logs +} else { + # Default: full setup + Generate-Env + Write-Host "" + Deploy-Containers +} diff --git a/scripts/deploy-docker.sh b/scripts/deploy-docker.sh new file mode 100755 index 0000000..59a74c4 --- /dev/null +++ b/scripts/deploy-docker.sh @@ -0,0 +1,592 @@ +#!/bin/bash +set -euo pipefail + +# ============================================================ +# Memento - Docker Deploy Script (macOS / Linux) +# ============================================================ +# Usage: +# ./scripts/deploy-docker.sh # Full setup +# ./scripts/deploy-docker.sh --env-only # Generate .env.docker only +# ./scripts/deploy-docker.sh --build # Build + deploy (no env setup) +# ./scripts/deploy-docker.sh --stop # Stop all containers +# ./scripts/deploy-docker.sh --logs # Show logs +# ============================================================ + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +ENV_FILE="$PROJECT_DIR/.env.docker" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } +step() { echo -e "\n${CYAN}${BOLD}==>${NC} ${BOLD}$*${NC}"; } + +# ----------------------------------------------------------- +# Check dependencies +# ----------------------------------------------------------- +check_deps() { + step "Checking dependencies..." + + if ! command -v docker &>/dev/null; then + error "Docker is not installed. + macOS: https://docs.docker.com/desktop/install/mac-install/ + Linux: https://docs.docker.com/engine/install/" + fi + + if ! docker info &>/dev/null 2>&1; then + error "Docker daemon is not running. Start Docker first." + fi + + if docker compose version &>/dev/null 2>&1; then + COMPOSE_CMD="docker compose" + elif command -v docker-compose &>/dev/null; then + COMPOSE_CMD="docker-compose" + else + error "Docker Compose is not installed. + Install: https://docs.docker.com/compose/install/" + fi + + if ! command -v openssl &>/dev/null; then + warn "openssl not found. Will use /dev/urandom for secrets." + fi + + ok "All dependencies met" +} + +# ----------------------------------------------------------- +# Helper: generate random secret +# ----------------------------------------------------------- +gen_secret() { + if command -v openssl &>/dev/null; then + openssl rand -base64 32 2>/dev/null + else + head -c 32 /dev/urandom | base64 + fi +} + +gen_password() { + if command -v openssl &>/dev/null; then + openssl rand -hex 16 2>/dev/null + else + head -c 16 /dev/urandom | hexdump -v -e '/1 "%02x"' + fi +} + +# ----------------------------------------------------------- +# Ask a question with default +# ----------------------------------------------------------- +ask() { + local prompt="$1" + local default="${2:-}" + local var="$3" + local result + + if [ -n "$default" ]; then + echo -ne " ${CYAN}?${NC} ${prompt} [${default}]: " + else + echo -ne " ${CYAN}?${NC} ${prompt}: " + fi + read -r result + result="${result:-$default}" + eval "$var=\"\$result\"" +} + +ask_required() { + local prompt="$1" + local var="$2" + local result + + while true; do + echo -ne " ${CYAN}?${NC} ${prompt} (required): " + read -r result + if [ -n "$result" ]; then + eval "$var=\"\$result\"" + return + fi + echo -e " ${RED}This field is required.${NC}" + done +} + +ask_email() { + local prompt="$1" + local var="$2" + local result + + while true; do + echo -ne " ${CYAN}?${NC} ${prompt} (required): " + read -r result + if [ -n "$result" ] && echo "$result" | grep -qE '^[^@]+@[^@]+\.[^@]+$'; then + eval "$var=\"\$result\"" + return + fi + echo -e " ${RED}Please enter a valid email address.${NC}" + done +} + +# ----------------------------------------------------------- +# Generate .env.docker interactively +# ----------------------------------------------------------- +generate_env() { + step "Configuring Memento for Docker deployment" + + if [ -f "$ENV_FILE" ]; then + warn ".env.docker already exists." + echo -ne " ${CYAN}?${NC} Overwrite? [y/N]: " + read -r confirm + [[ "$confirm" != "y" && "$confirm" != "Y" ]] && { info "Keeping existing .env.docker"; return 0; } + fi + + echo "" + echo -e "${BOLD} This wizard will guide you through the configuration.${NC}" + echo -e "${BOLD} Press Enter to accept defaults in [brackets].${NC}" + echo "" + + # ---- Core ---- + step "Core configuration" + + local url="http://localhost:3000" + ask "App URL (NEXTAUTH_URL)" "$url" url + + local secret + secret=$(gen_secret) + info "Auto-generated NEXTAUTH_SECRET" + + local admin_email + ask_email "Admin email (first user with this email becomes ADMIN)" admin_email + + local allow_reg="true" + ask "Allow public registration" "$allow_reg" allow_reg + # Normalize + case "$allow_reg" in + [Yy]|[Yy]es|true|1) allow_reg="true" ;; + *) allow_reg="false" ;; + esac + + # ---- PostgreSQL ---- + step "PostgreSQL configuration" + + local pg_port="5433" + local pg_db="memento" + local pg_user="memento" + local pg_pass + pg_pass=$(gen_password) + + ask "PostgreSQL exposed port" "$pg_port" pg_port + ask "PostgreSQL database name" "$pg_db" pg_db + ask "PostgreSQL username" "$pg_user" pg_user + info "Auto-generated secure PostgreSQL password" + + # ---- AI Provider ---- + step "AI Provider configuration" + + echo " Choose your AI provider:" + echo " 1) OpenAI" + echo " 2) Ollama (local, requires Ollama container)" + echo " 3) OpenRouter" + echo " 4) Custom OpenAI-compatible (Groq, Together, Mistral, etc.)" + echo " 5) Skip AI configuration" + echo "" + local ai_choice="5" + ask "Choice" "$ai_choice" ai_choice + + local ai_tags_provider="" ai_tags_model="" + local ai_embed_provider="" ai_embed_model="" + local ai_chat_provider="" ai_chat_model="" + local openai_key="" custom_key="" custom_url="" ollama_url="" + + case "$ai_choice" in + 1) + ai_tags_provider="openai"; ai_tags_model="gpt-4o-mini" + ai_embed_provider="openai"; ai_embed_model="text-embedding-3-small" + ai_chat_provider="openai"; ai_chat_model="gpt-4o-mini" + ask_required "OpenAI API Key" openai_key + ;; + 2) + ai_tags_provider="ollama"; ai_tags_model="granite4:latest" + ai_embed_provider="ollama"; ai_embed_model="embeddinggemma:latest" + ai_chat_provider="ollama"; ai_chat_model="granite4:latest" + ollama_url="http://ollama:11434" + ask "Ollama base URL" "$ollama_url" ollama_url + ;; + 3) + ai_tags_provider="custom"; ai_tags_model="google/gemma-3-27b-it" + ai_embed_provider="custom"; ai_embed_model="text-embedding-3-small" + ai_chat_provider="custom"; ai_chat_model="google/gemma-3-27b-it" + custom_url="https://openrouter.ai/api/v1" + ask_required "OpenRouter API Key" custom_key + ask "OpenRouter base URL" "$custom_url" custom_url + ;; + 4) + ai_tags_provider="custom" + ai_embed_provider="custom" + ai_chat_provider="custom" + ask_required "Custom provider API Key" custom_key + ask_required "Custom provider base URL" custom_url + ask "Model for tags" "gpt-4o-mini" ai_tags_model + ask "Model for embeddings" "text-embedding-3-small" ai_embed_model + ask "Model for chat" "gpt-4o-mini" ai_chat_model + ;; + 5) + info "Skipping AI configuration. You can configure it later in the admin panel." + ;; + *) + error "Invalid choice" + ;; + esac + + # ---- MCP Server ---- + step "MCP Server configuration" + + echo " The MCP server allows external tools (Claude, Cline, N8N) to interact with Memento." + echo "" + + local mcp_enable="yes" + ask "Enable MCP server?" "$mcp_enable" mcp_enable + case "$mcp_enable" in + [Nn]|[Nn]o|false|0) + mcp_enable="false" + ;; + *) + mcp_enable="true" + ;; + esac + + local mcp_port="3001" + local mcp_server_mode="sse" + local mcp_server_url="" + local mcp_api_key="" + + if [ "$mcp_enable" = "true" ]; then + ask "MCP server port" "$mcp_port" mcp_port + ask "MCP server mode (sse or stdio)" "$mcp_server_mode" mcp_server_mode + mcp_server_url="${url%:*}:${mcp_port}" + + local mcp_auth="yes" + ask "Require MCP authentication?" "$mcp_auth" mcp_auth + case "$mcp_auth" in + [Nn]|[Nn]o|false|0) ;; + *) + mcp_api_key=$(gen_password) + info "Auto-generated MCP API key" + ;; + esac + fi + + # ---- Email ---- + step "Email configuration (optional, needed for password reset)" + + echo " Choose an email provider:" + echo " 1) Resend" + echo " 2) SMTP" + echo " 3) Skip" + echo "" + local email_choice="3" + ask "Choice" "$email_choice" email_choice + + local resend_key="" smtp_host="" smtp_port="" smtp_user="" smtp_pass="" smtp_from="" + + case "$email_choice" in + 1) + ask_required "Resend API Key" resend_key + ;; + 2) + ask_required "SMTP Host" smtp_host + ask "SMTP Port" "587" smtp_port + ask_required "SMTP Username" smtp_user + ask_required "SMTP Password" smtp_pass + ask_required "SMTP From email" smtp_from + ;; + esac + + # ---- Ollama container ---- + local enable_ollama="no" + if [ "$ai_choice" = "2" ]; then + enable_ollama="yes" + else + step "Ollama container (optional)" + ask "Also start Ollama container?" "$enable_ollama" enable_ollama + case "$enable_ollama" in + [Yy]|[Yy]es|true|1) enable_ollama="yes" ;; + *) enable_ollama="no" ;; + esac + fi + + # ---- Web Search (optional) ---- + step "Web Search configuration (optional)" + + echo " Choose a web search provider:" + echo " 1) SearXNG (self-hosted)" + echo " 2) Brave Search" + echo " 3) Skip" + echo "" + local search_choice="3" + ask "Choice" "$search_choice" search_choice + + local searxng_url="" brave_key="" + + case "$search_choice" in + 1) + ask_required "SearXNG URL" searxng_url + ;; + 2) + ask_required "Brave Search API Key" brave_key + ;; + esac + + # ---- Write .env.docker ---- + step "Writing .env.docker" + + cat > "$ENV_FILE" << EOF +# ============================================================================= +# Memento - Docker Environment (auto-generated by deploy-docker.sh) +# ============================================================================= +# Generated on $(date '+%Y-%m-%d %H:%M:%S') +# ============================================================================= + +# Core +NEXTAUTH_URL="${url}" +NEXTAUTH_SECRET="${secret}" +ADMIN_EMAIL="${admin_email}" +ALLOW_REGISTRATION="${allow_reg}" + +# PostgreSQL +POSTGRES_PORT=${pg_port} +POSTGRES_DB=${pg_db} +POSTGRES_USER=${pg_user} +POSTGRES_PASSWORD="${pg_pass}" +EOF + + # AI config + if [ "$ai_choice" != "5" ]; then + cat >> "$ENV_FILE" << EOF + +# AI - Tags +AI_PROVIDER_TAGS=${ai_tags_provider} +AI_MODEL_TAGS="${ai_tags_model}" + +# AI - Embeddings +AI_PROVIDER_EMBEDDING=${ai_embed_provider} +AI_MODEL_EMBEDDING="${ai_embed_model}" + +# AI - Chat +AI_PROVIDER_CHAT=${ai_chat_provider} +AI_MODEL_CHAT="${ai_chat_model}" +EOF + + if [ -n "$openai_key" ]; then + echo "OPENAI_API_KEY=\"${openai_key}\"" >> "$ENV_FILE" + fi + if [ -n "$custom_key" ]; then + echo "CUSTOM_OPENAI_API_KEY=\"${custom_key}\"" >> "$ENV_FILE" + echo "CUSTOM_OPENAI_BASE_URL=\"${custom_url}\"" >> "$ENV_FILE" + fi + if [ -n "$ollama_url" ]; then + echo "OLLAMA_BASE_URL=\"${ollama_url}\"" >> "$ENV_FILE" + fi + fi + + # MCP config + if [ "$mcp_enable" = "true" ]; then + cat >> "$ENV_FILE" << EOF + +# MCP Server +MCP_MODE="${mcp_server_mode}" +MCP_PORT="${mcp_port}" +MCP_SERVER_MODE="${mcp_server_mode}" +MCP_SERVER_URL="${mcp_server_url}" +EOF + if [ -n "$mcp_api_key" ]; then + echo "MCP_API_KEY=\"${mcp_api_key}\"" >> "$ENV_FILE" + fi + else + cat >> "$ENV_FILE" << EOF + +# MCP Server (disabled) +MCP_SERVER_MODE="disabled" +EOF + fi + + # Email config + if [ -n "$resend_key" ]; then + cat >> "$ENV_FILE" << EOF + +# Email - Resend +RESEND_API_KEY="${resend_key}" +EOF + fi + if [ -n "$smtp_host" ]; then + cat >> "$ENV_FILE" << EOF + +# Email - SMTP +SMTP_HOST="${smtp_host}" +SMTP_PORT="${smtp_port}" +SMTP_USER="${smtp_user}" +SMTP_PASS="${smtp_pass}" +SMTP_FROM="${smtp_from}" +EOF + fi + + # Web Search + if [ -n "$searxng_url" ]; then + cat >> "$ENV_FILE" << EOF + +# Web Search - SearXNG +WEB_SEARCH_PROVIDER="searxng" +SEARXNG_URL="${searxng_url}" +EOF + fi + if [ -n "$brave_key" ]; then + cat >> "$ENV_FILE" << EOF + +# Web Search - Brave +WEB_SEARCH_PROVIDER="brave" +BRAVE_SEARCH_API_KEY="${brave_key}" +EOF + fi + + ok ".env.docker created at $ENV_FILE" + echo "" + echo -e " ${BOLD}Configuration summary:${NC}" + echo " URL: $url" + echo " Admin email: $admin_email" + echo " Registration: $allow_reg" + echo " PostgreSQL user: $pg_user / db: $pg_db" + echo " AI provider: $([ "$ai_choice" = "5" ] && echo "skipped" || echo "$ai_tags_provider")" + echo " MCP server: $([ "$mcp_enable" = "true" ] && echo "enabled ($mcp_server_mode)" || echo "disabled")" + echo " Email: $([ "$email_choice" = "3" ] && echo "skipped" || echo "configured")" + echo " Ollama container: $enable_ollama" + echo " (sensitive values are hidden)" +} + +# ----------------------------------------------------------- +# Build and deploy +# ----------------------------------------------------------- +deploy() { + [ -f "$ENV_FILE" ] || error ".env.docker not found. Run: $0 --env-only" + + cd "$PROJECT_DIR" + + # Determine Ollama profile + local ollama_profile="" + if grep -q 'OLLAMA_BASE_URL="http://ollama' "$ENV_FILE" 2>/dev/null || \ + grep -q 'AI_PROVIDER_TAGS=ollama' "$ENV_FILE" 2>/dev/null; then + ollama_profile="--profile ollama" + fi + + step "Building Docker containers..." + $COMPOSE_CMD build --parallel 2>&1 + + step "Starting containers..." + $COMPOSE_CMD up -d $ollama_profile 2>&1 + + step "Waiting for services to be healthy..." + local retries=0 + local max_retries=45 + while [ $retries -lt $max_retries ]; do + local unhealthy + unhealthy=$($COMPOSE_CMD ps --format '{{.Status}}' 2>/dev/null | grep -c -v "healthy\|Up" || true) + if [ "$unhealthy" -eq 0 ]; then + break + fi + retries=$((retries + 1)) + sleep 2 + printf "." + done + echo "" + + if [ $retries -ge $max_retries ]; then + warn "Some containers may still be starting. Check status with: $0 --logs" + fi + + step "Waiting for database migrations (handled by entrypoint)..." + # The docker-entrypoint.sh runs prisma migrate deploy automatically on every start. + # It handles: fresh DB, existing DB with migrations, and P3005 baseline recovery. + # No manual migration step needed here. + + echo "" + echo -e "${GREEN}${BOLD}============================================${NC}" + echo -e "${GREEN}${BOLD} Memento is running!${NC}" + echo -e "${GREEN}${BOLD}============================================${NC}" + echo "" + $COMPOSE_CMD ps + echo "" + + local app_url + app_url=$(grep '^NEXTAUTH_URL=' "$ENV_FILE" | cut -d= -f2 | tr -d '"') + local admin_email + admin_email=$(grep '^ADMIN_EMAIL=' "$ENV_FILE" | cut -d= -f2 | tr -d '"') + + echo -e " ${BOLD}App:${NC} $app_url" + echo -e " ${BOLD}Admin:${NC} Register with $admin_email to get admin access" + echo -e " ${BOLD}MCP:${NC} $([ -n "$(grep '^MCP_SERVER_MODE=sse' "$ENV_FILE" 2>/dev/null)" ] && echo "$app_url:$(grep '^MCP_PORT=' "$ENV_FILE" | cut -d= -f2 | tr -d '"')/mcp" || echo "disabled")" + echo "" + echo -e " ${CYAN}Useful commands:${NC}" + echo " $0 --logs View logs" + echo " $0 --stop Stop containers" + echo " $0 --env-only Reconfigure .env.docker" +} + +# ----------------------------------------------------------- +# Stop containers +# ----------------------------------------------------------- +stop_containers() { + cd "$PROJECT_DIR" + step "Stopping containers..." + $COMPOSE_CMD down + ok "Containers stopped" +} + +# ----------------------------------------------------------- +# Show logs +# ----------------------------------------------------------- +show_logs() { + cd "$PROJECT_DIR" + $COMPOSE_CMD logs -f --tail=100 +} + +# ----------------------------------------------------------- +# Main +# ----------------------------------------------------------- +check_deps + +ACTION="${1:---full}" + +case "$ACTION" in + --env-only) + generate_env + ;; + --build) + deploy + ;; + --full) + generate_env + echo "" + deploy + ;; + --stop) + stop_containers + ;; + --logs) + show_logs + ;; + *) + echo "Usage: $0 [--env-only | --build | --full | --stop | --logs]" + echo "" + echo " --env-only Generate .env.docker interactively" + echo " --build Build and start containers (requires existing .env.docker)" + echo " --full Generate .env.docker + build + deploy (default)" + echo " --stop Stop all containers" + echo " --logs Show container logs" + exit 1 + ;; +esac diff --git a/scripts/deploy-local.ps1 b/scripts/deploy-local.ps1 new file mode 100644 index 0000000..61137e2 --- /dev/null +++ b/scripts/deploy-local.ps1 @@ -0,0 +1,510 @@ +# ============================================================ +# Memento - Local Deploy Script (Windows PowerShell) +# ============================================================ +# Usage: +# .\scripts\deploy-local.ps1 # Full setup +# .\scripts\deploy-local.ps1 -EnvOnly # Generate .env only +# .\scripts\deploy-local.ps1 -Start # Start the app (dev or prod) +# .\scripts\deploy-local.ps1 -Migrate # Run database migrations +# .\scripts\deploy-local.ps1 -Install # Install npm dependencies +# ============================================================ + +[CmdletBinding()] +param( + [switch]$EnvOnly = $false, + [switch]$Start = $false, + [switch]$Migrate = $false, + [switch]$Install = $false, + [switch]$Full = $false +) + +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ProjectDir = Split-Path -Parent $ScriptDir +$AppDir = Join-Path $ProjectDir "memento-note" +$McpDir = Join-Path $ProjectDir "mcp-server" +$EnvFile = Join-Path $AppDir ".env" +$McpEnvFile = Join-Path $McpDir ".env" + +# ----------------------------------------------------------- +# Helpers +# ----------------------------------------------------------- +function Write-Info($msg) { Write-Host "[INFO] $msg" -ForegroundColor Blue } +function Write-Ok($msg) { Write-Host "[OK] $msg" -ForegroundColor Green } +function Write-Warn($msg) { Write-Host "[WARN] $msg" -ForegroundColor Yellow } +function Write-Err($msg) { Write-Host "[ERROR] $msg" -ForegroundColor Red; exit 1 } +function Write-Step($msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan } + +function Get-RandomSecret { + $bytes = New-Object byte[] 32 + [Security.Cryptography.RNGCryptoServiceProvider]::Create().GetBytes($bytes) + [Convert]::ToBase64String($bytes) +} + +function Ask-Input { + param([string]$Prompt, [string]$Default = "") + if ($Default) { + Write-Host " ? $Prompt [$Default]: " -ForegroundColor Cyan -NoNewline + } else { + Write-Host " ? $Prompt: " -ForegroundColor Cyan -NoNewline + } + $result = Read-Host + if ([string]::IsNullOrWhiteSpace($result)) { $result = $Default } + return $result +} + +function Ask-Required { + param([string]$Prompt) + while ($true) { + Write-Host " ? $Prompt (required): " -ForegroundColor Cyan -NoNewline + $result = Read-Host + if (-not [string]::IsNullOrWhiteSpace($result)) { return $result } + Write-Host " This field is required." -ForegroundColor Red + } +} + +function Ask-Email { + param([string]$Prompt) + while ($true) { + Write-Host " ? $Prompt (required): " -ForegroundColor Cyan -NoNewline + $result = Read-Host + if (-not [string]::IsNullOrWhiteSpace($result) -and $result -match '^[^@]+@[^@]+\.[^@]+$') { + return $result + } + Write-Host " Please enter a valid email address." -ForegroundColor Red + } +} + +# ----------------------------------------------------------- +# Check dependencies +# ----------------------------------------------------------- +function Check-Deps { + Write-Step "Checking dependencies..." + + $missing = @() + + # Node.js + $node = Get-Command node -ErrorAction SilentlyContinue + if ($node) { + $nodeVersion = & node -v 2>$null + Write-Ok "Node.js $nodeVersion" + } else { + $missing += "node" + } + + # npm + $npm = Get-Command npm -ErrorAction SilentlyContinue + if ($npm) { + $npmVersion = & npm -v 2>$null + Write-Ok "npm $npmVersion" + } else { + $missing += "npm" + } + + # PostgreSQL + $psql = Get-Command psql -ErrorAction SilentlyContinue + if ($psql) { + $pgVersion = & psql --version 2>$null | Select-Object -First 1 + Write-Ok "PostgreSQL client $pgVersion" + } else { + $missing += "psql" + } + + if ($missing.Count -gt 0) { + Write-Host "" + Write-Warn "Missing dependencies: $($missing -join ', ')" + Write-Host "" + Write-Host " Installation instructions:" -ForegroundColor White + Write-Host "" + + foreach ($dep in $missing) { + switch ($dep) { + "node" { + Write-Host " Node.js + npm:" + Write-Host " Download: https://nodejs.org/" + Write-Host " Winget: winget install OpenJS.NodeJS.LTS" + Write-Host "" + } + "npm" { + Write-Host " npm:" + Write-Host " Usually included with Node.js installer" + Write-Host "" + } + "psql" { + Write-Host " PostgreSQL:" + Write-Host " Download: https://www.postgresql.org/download/windows/" + Write-Host " Winget: winget install PostgreSQL.PostgreSQL" + Write-Host "" + } + } + } + + $cont = Ask-Input "Continue anyway?" "N" + if ($cont -notmatch "^[Yy]") { exit 1 } + } +} + +# ----------------------------------------------------------- +# Generate .env +# ----------------------------------------------------------- +function Generate-Env { + Write-Step "Configuring Memento for local development" + + if (Test-Path $EnvFile) { + Write-Warn ".env already exists at $EnvFile" + $confirm = Ask-Input "Overwrite?" "N" + if ($confirm -notmatch "^[Yy]") { + Write-Info "Keeping existing .env" + return + } + } + + Write-Host "" + Write-Host " This wizard will guide you through the configuration." -ForegroundColor White + Write-Host " Press Enter to accept defaults in [brackets]." -ForegroundColor White + Write-Host "" + + # ---- Database ---- + Write-Step "Database configuration" + + $dbHost = Ask-Input "PostgreSQL host" "localhost" + $dbPort = Ask-Input "PostgreSQL port" "5432" + $dbName = Ask-Input "PostgreSQL database name" "memento" + $dbUser = Ask-Input "PostgreSQL username" "memento" + $dbPass = Ask-Input "PostgreSQL password" "memento" + + $dbUrl = "postgresql://${dbUser}:${dbPass}@${dbHost}:${dbPort}/${dbName}" + + # Check DB connectivity + if (Get-Command psql -ErrorAction SilentlyContinue) { + Write-Step "Testing database connection..." + $env:PGPASSWORD = $dbPass + try { + & psql -h $dbHost -p $dbPort -U $dbUser -d $dbName -c "SELECT 1" 2>&1 | Out-Null + Write-Ok "Database connection successful" + } catch { + Write-Warn "Could not connect to database. It may not exist yet." + Write-Host "" + Write-Host " Create it with:" + Write-Host " createdb $dbName" + Write-Host " psql -c `"CREATE USER $dbUser WITH PASSWORD '$dbPass';`"" + Write-Host " psql -c `"GRANT ALL PRIVILEGES ON DATABASE $dbName TO $dbUser;`"" + Write-Host "" + $cont = Ask-Input "Continue anyway?" "Y" + if ($cont -match "^[Nn]") { exit 1 } + } + Remove-Item Env:PGPASSWORD + } + + # ---- Core ---- + Write-Step "Core configuration" + + $url = Ask-Input "App URL (NEXTAUTH_URL)" "http://localhost:3000" + $secret = Get-RandomSecret + Write-Info "Auto-generated NEXTAUTH_SECRET" + $adminEmail = Ask-Email "Admin email (first user with this email becomes ADMIN)" + + $allowReg = Ask-Input "Allow public registration" "true" + if ($allowReg -match "^[Yy]|^[Yy]es|^true|^1") { $allowReg = "true" } else { $allowReg = "false" } + + # ---- AI Provider ---- + Write-Step "AI Provider configuration" + + Write-Host " Choose your AI provider:" + Write-Host " 1) OpenAI" + Write-Host " 2) Ollama (local)" + Write-Host " 3) Custom OpenAI-compatible (OpenRouter, Groq, Together, etc.)" + Write-Host " 4) Skip AI configuration" + Write-Host "" + $aiChoice = Ask-Input "Choice" "4" + + $aiTagsProvider = $aiTagsModel = $aiEmbedProvider = $aiEmbedModel = "" + $aiChatProvider = $aiChatModel = $openaiKey = $customKey = $customUrl = $ollamaUrl = "" + + switch ($aiChoice) { + "1" { + $aiTagsProvider = "openai"; $aiTagsModel = "gpt-4o-mini" + $aiEmbedProvider = "openai"; $aiEmbedModel = "text-embedding-3-small" + $aiChatProvider = "openai"; $aiChatModel = "gpt-4o-mini" + $openaiKey = Ask-Required "OpenAI API Key" + } + "2" { + $aiTagsProvider = "ollama"; $aiTagsModel = "granite4:latest" + $aiEmbedProvider = "ollama"; $aiEmbedModel = "embeddinggemma:latest" + $aiChatProvider = "ollama"; $aiChatModel = "granite4:latest" + $ollamaUrl = Ask-Input "Ollama base URL" "http://localhost:11434" + } + "3" { + $aiTagsProvider = "custom" + $aiEmbedProvider = "custom" + $aiChatProvider = "custom" + $customKey = Ask-Required "Custom provider API Key" + $customUrl = Ask-Required "Custom provider base URL" + $aiTagsModel = Ask-Input "Model for tags" "gpt-4o-mini" + $aiEmbedModel = Ask-Input "Model for embeddings" "text-embedding-3-small" + $aiChatModel = Ask-Input "Model for chat" "gpt-4o-mini" + } + "4" { + Write-Info "Skipping AI configuration. You can configure it later in the admin panel." + } + default { Write-Err "Invalid choice" } + } + + # ---- MCP ---- + Write-Step "MCP Server configuration (optional)" + + $mcpEnable = Ask-Input "Configure MCP server?" "no" + $mcpEnable = ($mcpEnable -match "^[Yy]|^[Yy]es|^true|^1") + $mcpMode = "sse"; $mcpPort = "3001"; $mcpServerUrl = "" + + if ($mcpEnable) { + $mcpMode = Ask-Input "MCP mode (sse or stdio)" "sse" + $mcpPort = Ask-Input "MCP port" "3001" + $mcpServerUrl = "http://localhost:${mcpPort}" + } + + # ---- Email ---- + Write-Step "Email configuration (optional, needed for password reset)" + + Write-Host " Choose an email provider:" + Write-Host " 1) Resend" + Write-Host " 2) SMTP" + Write-Host " 3) Skip" + Write-Host "" + $emailChoice = Ask-Input "Choice" "3" + + $resendKey = $smtpHost = $smtpPort = $smtpUser = $smtpPass = $smtpFrom = "" + + switch ($emailChoice) { + "1" { $resendKey = Ask-Required "Resend API Key" } + "2" { + $smtpHost = Ask-Required "SMTP Host" + $smtpPort = Ask-Input "SMTP Port" "587" + $smtpUser = Ask-Required "SMTP Username" + $smtpPass = Ask-Required "SMTP Password" + $smtpFrom = Ask-Required "SMTP From email" + } + } + + # ---- Write .env ---- + Write-Step "Writing .env" + + $envContent = @" +# ============================================================================= +# Memento - Local Environment (auto-generated by deploy-local.ps1) +# ============================================================================= +# Generated on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') +# ============================================================================= + +# Core +DATABASE_URL="$dbUrl" +NEXTAUTH_SECRET="$secret" +NEXTAUTH_URL="$url" +ADMIN_EMAIL="$adminEmail" +ALLOW_REGISTRATION="$allowReg" +"@ + + if ($aiChoice -ne "4") { + $envContent += @" + +# AI - Tags +AI_PROVIDER_TAGS=$aiTagsProvider +AI_MODEL_TAGS="$aiTagsModel" + +# AI - Embeddings +AI_PROVIDER_EMBEDDING=$aiEmbedProvider +AI_MODEL_EMBEDDING="$aiEmbedModel" + +# AI - Chat +AI_PROVIDER_CHAT=$aiChatProvider +AI_MODEL_CHAT="$aiChatModel" +"@ + if ($openaiKey) { $envContent += "`nOPENAI_API_KEY=`"$openaiKey`"" } + if ($customKey) { $envContent += "`nCUSTOM_OPENAI_API_KEY=`"$customKey`"`nCUSTOM_OPENAI_BASE_URL=`"$customUrl`"" } + if ($ollamaUrl) { $envContent += "`nOLLAMA_BASE_URL=`"$ollamaUrl`"" } + } + + if ($mcpEnable) { + $envContent += @" + +# MCP Server +MCP_SERVER_MODE="$mcpMode" +MCP_SERVER_URL="$mcpServerUrl" +"@ + } else { + $envContent += @" + +# MCP Server (disabled) +MCP_SERVER_MODE="disabled" +"@ + } + + if ($resendKey) { $envContent += "`n`n# Email - Resend`nRESEND_API_KEY=`"$resendKey`"" } + if ($smtpHost) { + $envContent += @" + +# Email - SMTP +SMTP_HOST="$smtpHost" +SMTP_PORT="$smtpPort" +SMTP_USER="$smtpUser" +SMTP_PASS="$smtpPass" +SMTP_FROM="$smtpFrom" +"@ + } + + $envContent | Set-Content -Path $EnvFile -Encoding UTF8 + Write-Ok ".env created at $EnvFile" + + # MCP server .env + if ($mcpEnable) { + $mcpContent = @" +# ============================================================================= +# MCP Server - Local Environment (auto-generated by deploy-local.ps1) +# ============================================================================= +DATABASE_URL="$dbUrl" +MCP_MODE="$mcpMode" +PORT="$mcpPort" +APP_BASE_URL="$url" +"@ + $mcpContent | Set-Content -Path $McpEnvFile -Encoding UTF8 + Write-Ok ".env created at $McpEnvFile" + } + + Write-Host "" + Write-Host " Configuration summary:" -ForegroundColor White + Write-Host " URL: $url" + Write-Host " Admin email: $adminEmail" + Write-Host " Database: $dbHost`:$dbPort/$dbName" + Write-Host " AI provider: $(if ($aiChoice -eq '4') { 'skipped' } else { $aiTagsProvider })" + Write-Host " MCP server: $(if ($mcpEnable) { "enabled ($mcpMode)" } else { 'disabled' })" + Write-Host " Email: $(if ($emailChoice -eq '3') { 'skipped' } else { 'configured' })" +} + +# ----------------------------------------------------------- +# Install dependencies +# ----------------------------------------------------------- +function Install-Deps { + Write-Step "Installing dependencies..." + Push-Location $AppDir + + if (-not (Test-Path "node_modules")) { + Write-Info "Running npm install..." + & npm install + Write-Ok "Dependencies installed" + } else { + Write-Info "node_modules exists, checking for updates..." + & npm install + } + + Pop-Location +} + +# ----------------------------------------------------------- +# Run migrations +# ----------------------------------------------------------- +function Run-Migrations { + if (-not (Test-Path $EnvFile)) { + Write-Err ".env not found. Run: .\scripts\deploy-local.ps1 -EnvOnly" + } + + Write-Step "Running database migrations..." + Push-Location $AppDir + + try { + & npx prisma migrate deploy 2>&1 + } catch { + Write-Warn "migrate deploy failed, trying db push..." + try { + & npx prisma db push --skip-generate 2>&1 + } catch { + Write-Err "Database migration failed" + } + } + + Write-Ok "Database migrations complete" + Pop-Location +} + +# ----------------------------------------------------------- +# Start app +# ----------------------------------------------------------- +function Start-App { + if (-not (Test-Path $EnvFile)) { + Write-Err ".env not found. Run: .\scripts\deploy-local.ps1 -EnvOnly" + } + + Push-Location $AppDir + + Write-Host "" + Write-Host " Choose mode:" + Write-Host " 1) Development (npm run dev, with hot reload)" + Write-Host " 2) Production (npm run build + npm start)" + Write-Host "" + $mode = Ask-Input "Choice" "1" + + switch ($mode) { + "2" { + Write-Step "Building for production..." + & npm run build + + Write-Step "Starting in production mode..." + Write-Info "Starting server on http://localhost:3000" + Write-Info "Press Ctrl+C to stop" + Write-Host "" + & npm start + } + default { + Write-Step "Starting in development mode..." + Write-Info "Starting dev server on http://localhost:3000" + Write-Info "Press Ctrl+C to stop" + Write-Host "" + & npm run dev + } + } + + Pop-Location +} + +# ----------------------------------------------------------- +# Full setup +# ----------------------------------------------------------- +function Full-Setup { + Check-Deps + Generate-Env + Install-Deps + Run-Migrations + + Write-Host "" + Write-Host "============================================" -ForegroundColor Green + Write-Host " Setup complete!" -ForegroundColor Green + Write-Host "============================================" -ForegroundColor Green + Write-Host "" + + $admEmail = (Select-String -Path $EnvFile -Pattern '^ADMIN_EMAIL=(.+)$').Matches[0].Groups[1].Value -replace '"','' + $appUrl = (Select-String -Path $EnvFile -Pattern '^NEXTAUTH_URL=(.+)$').Matches[0].Groups[1].Value -replace '"','' + + Write-Host " Next steps:" -ForegroundColor White + Write-Host " 1. Start the app: .\scripts\deploy-local.ps1 -Start" + Write-Host " 2. Open: $appUrl" + Write-Host " 3. Register with: $admEmail" -ForegroundColor White + Write-Host " 4. That account will automatically get ADMIN role" + Write-Host "" +} + +# ----------------------------------------------------------- +# Main +# ----------------------------------------------------------- +if ($EnvOnly) { + Check-Deps + Generate-Env +} elseif ($Start) { + Start-App +} elseif ($Migrate) { + Run-Migrations +} elseif ($Install) { + Install-Deps +} else { + # Default: full setup + Full-Setup +} diff --git a/scripts/deploy-local.sh b/scripts/deploy-local.sh new file mode 100755 index 0000000..792318f --- /dev/null +++ b/scripts/deploy-local.sh @@ -0,0 +1,582 @@ +#!/bin/bash +set -euo pipefail + +# ============================================================ +# Memento - Local Deploy Script (macOS / Linux) +# ============================================================ +# Usage: +# ./scripts/deploy-local.sh # Full setup +# ./scripts/deploy-local.sh --env-only # Generate .env only +# ./scripts/deploy-local.sh --start # Start the app (dev or prod) +# ./scripts/deploy-local.sh --stop # Stop the app +# ./scripts/deploy-local.sh --migrate # Run database migrations +# ============================================================ + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +APP_DIR="$PROJECT_DIR/memento-note" +ENV_FILE="$APP_DIR/.env" +MCP_DIR="$PROJECT_DIR/mcp-server" +MCP_ENV_FILE="$MCP_DIR/.env" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } +step() { echo -e "\n${CYAN}${BOLD}==>${NC} ${BOLD}$*${NC}"; } + +# ----------------------------------------------------------- +# Generate random secret +# ----------------------------------------------------------- +gen_secret() { + if command -v openssl &>/dev/null; then + openssl rand -base64 32 2>/dev/null + else + head -c 32 /dev/urandom | base64 + fi +} + +# ----------------------------------------------------------- +# Ask helpers +# ----------------------------------------------------------- +ask() { + local prompt="$1" + local default="${2:-}" + local var="$3" + local result + + if [ -n "$default" ]; then + echo -ne " ${CYAN}?${NC} ${prompt} [${default}]: " + else + echo -ne " ${CYAN}?${NC} ${prompt}: " + fi + read -r result + result="${result:-$default}" + eval "$var=\"\$result\"" +} + +ask_required() { + local prompt="$1" + local var="$2" + local result + + while true; do + echo -ne " ${CYAN}?${NC} ${prompt} (required): " + read -r result + if [ -n "$result" ]; then + eval "$var=\"\$result\"" + return + fi + echo -e " ${RED}This field is required.${NC}" + done +} + +ask_email() { + local prompt="$1" + local var="$2" + local result + + while true; do + echo -ne " ${CYAN}?${NC} ${prompt} (required): " + read -r result + if [ -n "$result" ] && echo "$result" | grep -qE '^[^@]+@[^@]+\.[^@]+$'; then + eval "$var=\"\$result\"" + return + fi + echo -e " ${RED}Please enter a valid email address.${NC}" + done +} + +# ----------------------------------------------------------- +# Check dependencies +# ----------------------------------------------------------- +check_deps() { + step "Checking dependencies..." + + local missing=() + + # Node.js + if command -v node &>/dev/null; then + local node_version + node_version=$(node -v 2>/dev/null) + ok "Node.js $node_version" + else + missing+=("node") + fi + + # npm + if command -v npm &>/dev/null; then + ok "npm $(npm -v 2>/dev/null)" + else + missing+=("npm") + fi + + # PostgreSQL + if command -v psql &>/dev/null; then + ok "PostgreSQL client $(psql --version 2>/dev/null | head -1)" + else + missing+=("psql") + fi + + # Check if PostgreSQL server is running + if command -v pg_isready &>/dev/null; then + if pg_isready &>/dev/null 2>&1; then + ok "PostgreSQL server is running" + else + warn "PostgreSQL server does not seem to be running." + echo " Try: brew services start postgresql (macOS) or sudo systemctl start postgresql (Linux)" + fi + fi + + if [ ${#missing[@]} -gt 0 ]; then + echo "" + warn "Missing dependencies: ${missing[*]}" + echo "" + echo -e " ${BOLD}Installation instructions:${NC}" + echo "" + + for dep in "${missing[@]}"; do + case "$dep" in + node|npm) + echo " Node.js + npm:" + echo " macOS: brew install node" + echo " Linux: curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt install -y nodejs" + echo " Or: https://nodejs.org/" + echo "" + ;; + psql) + echo " PostgreSQL:" + echo " macOS: brew install postgresql@16 && brew services start postgresql@16" + echo " Linux: sudo apt install -y postgresql postgresql-contrib" + echo " Or: https://www.postgresql.org/download/" + echo "" + ;; + esac + done + + echo -ne " ${CYAN}?${NC} Continue anyway? [y/N]: " + read -r cont + [[ "$cont" != "y" && "$cont" != "Y" ]] && exit 1 + fi +} + +# ----------------------------------------------------------- +# Generate .env interactively +# ----------------------------------------------------------- +generate_env() { + step "Configuring Memento for local development" + + if [ -f "$ENV_FILE" ]; then + warn ".env already exists at $ENV_FILE" + echo -ne " ${CYAN}?${NC} Overwrite? [y/N]: " + read -r confirm + [[ "$confirm" != "y" && "$confirm" != "Y" ]] && { info "Keeping existing .env"; return 0; } + fi + + echo "" + echo -e "${BOLD} This wizard will guide you through the configuration.${NC}" + echo -e "${BOLD} Press Enter to accept defaults in [brackets].${NC}" + echo "" + + # ---- Database ---- + step "Database configuration" + + local db_host="localhost" + local db_port="5432" + local db_name="memento" + local db_user="memento" + local db_pass="memento" + + ask "PostgreSQL host" "$db_host" db_host + ask "PostgreSQL port" "$db_port" db_port + ask "PostgreSQL database name" "$db_name" db_name + ask "PostgreSQL username" "$db_user" db_user + ask "PostgreSQL password" "$db_pass" db_pass + + local db_url="postgresql://${db_user}:${db_pass}@${db_host}:${db_port}/${db_name}" + + # Check DB connectivity + if command -v pg_isready &>/dev/null; then + step "Testing database connection..." + if PGPASSWORD="$db_pass" psql -h "$db_host" -p "$db_port" -U "$db_user" -d "$db_name" -c "SELECT 1" &>/dev/null 2>&1; then + ok "Database connection successful" + else + warn "Could not connect to database. It may not exist yet." + echo "" + echo " Create it with:" + echo " createdb $db_name" + echo " psql -c \"CREATE USER $db_user WITH PASSWORD '$db_pass';\"" + echo " psql -c \"GRANT ALL PRIVILEGES ON DATABASE $db_name TO $db_user;\"" + echo "" + echo -ne " ${CYAN}?${NC} Continue anyway? [Y/n]: " + read -r cont + [[ "$cont" == "n" || "$cont" == "N" ]] && exit 1 + fi + fi + + # ---- Core ---- + step "Core configuration" + + local url="http://localhost:3000" + ask "App URL (NEXTAUTH_URL)" "$url" url + + local secret + secret=$(gen_secret) + info "Auto-generated NEXTAUTH_SECRET" + + local admin_email + ask_email "Admin email (first user with this email becomes ADMIN)" admin_email + + local allow_reg="true" + ask "Allow public registration" "$allow_reg" allow_reg + case "$allow_reg" in + [Yy]|[Yy]es|true|1) allow_reg="true" ;; + *) allow_reg="false" ;; + esac + + # ---- AI Provider ---- + step "AI Provider configuration" + + echo " Choose your AI provider:" + echo " 1) OpenAI" + echo " 2) Ollama (local)" + echo " 3) Custom OpenAI-compatible (OpenRouter, Groq, Together, etc.)" + echo " 4) Skip AI configuration" + echo "" + local ai_choice="4" + ask "Choice" "$ai_choice" ai_choice + + local ai_tags_provider="" ai_tags_model="" + local ai_embed_provider="" ai_embed_model="" + local ai_chat_provider="" ai_chat_model="" + local openai_key="" custom_key="" custom_url="" ollama_url="" + + case "$ai_choice" in + 1) + ai_tags_provider="openai"; ai_tags_model="gpt-4o-mini" + ai_embed_provider="openai"; ai_embed_model="text-embedding-3-small" + ai_chat_provider="openai"; ai_chat_model="gpt-4o-mini" + ask_required "OpenAI API Key" openai_key + ;; + 2) + ai_tags_provider="ollama"; ai_tags_model="granite4:latest" + ai_embed_provider="ollama"; ai_embed_model="embeddinggemma:latest" + ai_chat_provider="ollama"; ai_chat_model="granite4:latest" + ollama_url="http://localhost:11434" + ask "Ollama base URL" "$ollama_url" ollama_url + ;; + 3) + ai_tags_provider="custom" + ai_embed_provider="custom" + ai_chat_provider="custom" + ask_required "Custom provider API Key" custom_key + ask_required "Custom provider base URL" custom_url + ask "Model for tags" "gpt-4o-mini" ai_tags_model + ask "Model for embeddings" "text-embedding-3-small" ai_embed_model + ask "Model for chat" "gpt-4o-mini" ai_chat_model + ;; + 4) + info "Skipping AI configuration. You can configure it later in the admin panel." + ;; + *) + error "Invalid choice" + ;; + esac + + # ---- MCP ---- + step "MCP Server configuration (optional)" + + local mcp_enable="no" + ask "Configure MCP server?" "$mcp_enable" mcp_enable + case "$mcp_enable" in + [Yy]|[Yy]es|true|1) mcp_enable="yes" ;; + *) mcp_enable="no" ;; + esac + + local mcp_mode="sse" mcp_port="3001" mcp_server_url="" + + if [ "$mcp_enable" = "yes" ]; then + ask "MCP mode (sse or stdio)" "$mcp_mode" mcp_mode + ask "MCP port" "$mcp_port" mcp_port + mcp_server_url="http://localhost:${mcp_port}" + fi + + # ---- Email ---- + step "Email configuration (optional, needed for password reset)" + + echo " Choose an email provider:" + echo " 1) Resend" + echo " 2) SMTP" + echo " 3) Skip" + echo "" + local email_choice="3" + ask "Choice" "$email_choice" email_choice + + local resend_key="" smtp_host="" smtp_port="" smtp_user="" smtp_pass="" smtp_from="" + + case "$email_choice" in + 1) + ask_required "Resend API Key" resend_key + ;; + 2) + ask_required "SMTP Host" smtp_host + ask "SMTP Port" "587" smtp_port + ask_required "SMTP Username" smtp_user + ask_required "SMTP Password" smtp_pass + ask_required "SMTP From email" smtp_from + ;; + esac + + # ---- Write .env ---- + step "Writing .env" + + cat > "$ENV_FILE" << EOF +# ============================================================================= +# Memento - Local Environment (auto-generated by deploy-local.sh) +# ============================================================================= +# Generated on $(date '+%Y-%m-%d %H:%M:%S') +# ============================================================================= + +# Core +DATABASE_URL="${db_url}" +NEXTAUTH_SECRET="${secret}" +NEXTAUTH_URL="${url}" +ADMIN_EMAIL="${admin_email}" +ALLOW_REGISTRATION="${allow_reg}" +EOF + + # AI config + if [ "$ai_choice" != "4" ]; then + cat >> "$ENV_FILE" << EOF + +# AI - Tags +AI_PROVIDER_TAGS=${ai_tags_provider} +AI_MODEL_TAGS="${ai_tags_model}" + +# AI - Embeddings +AI_PROVIDER_EMBEDDING=${ai_embed_provider} +AI_MODEL_EMBEDDING="${ai_embed_model}" + +# AI - Chat +AI_PROVIDER_CHAT=${ai_chat_provider} +AI_MODEL_CHAT="${ai_chat_model}" +EOF + + if [ -n "$openai_key" ]; then + echo "OPENAI_API_KEY=\"${openai_key}\"" >> "$ENV_FILE" + fi + if [ -n "$custom_key" ]; then + echo "CUSTOM_OPENAI_API_KEY=\"${custom_key}\"" >> "$ENV_FILE" + echo "CUSTOM_OPENAI_BASE_URL=\"${custom_url}\"" >> "$ENV_FILE" + fi + if [ -n "$ollama_url" ]; then + echo "OLLAMA_BASE_URL=\"${ollama_url}\"" >> "$ENV_FILE" + fi + fi + + # MCP config + if [ "$mcp_enable" = "yes" ]; then + cat >> "$ENV_FILE" << EOF + +# MCP Server +MCP_SERVER_MODE="${mcp_mode}" +MCP_SERVER_URL="${mcp_server_url}" +EOF + else + cat >> "$ENV_FILE" << EOF + +# MCP Server (disabled) +MCP_SERVER_MODE="disabled" +EOF + fi + + # Email config + if [ -n "$resend_key" ]; then + cat >> "$ENV_FILE" << EOF + +# Email - Resend +RESEND_API_KEY="${resend_key}" +EOF + fi + if [ -n "$smtp_host" ]; then + cat >> "$ENV_FILE" << EOF + +# Email - SMTP +SMTP_HOST="${smtp_host}" +SMTP_PORT="${smtp_port}" +SMTP_USER="${smtp_user}" +SMTP_PASS="${smtp_pass}" +SMTP_FROM="${smtp_from}" +EOF + fi + + ok ".env created at $ENV_FILE" + + # Also generate MCP server .env if MCP enabled + if [ "$mcp_enable" = "yes" ]; then + cat > "$MCP_ENV_FILE" << EOF +# ============================================================================= +# MCP Server - Local Environment (auto-generated by deploy-local.sh) +# ============================================================================= +DATABASE_URL="${db_url}" +MCP_MODE="${mcp_mode}" +PORT="${mcp_port}" +APP_BASE_URL="${url}" +EOF + ok ".env created at $MCP_ENV_FILE" + fi + + echo "" + echo -e " ${BOLD}Configuration summary:${NC}" + echo " URL: $url" + echo " Admin email: $admin_email" + echo " Database: $db_host:$db_port/$db_name" + echo " AI provider: $([ "$ai_choice" = "4" ] && echo "skipped" || echo "$ai_tags_provider")" + echo " MCP server: $([ "$mcp_enable" = "yes" ] && echo "enabled ($mcp_mode)" || echo "disabled")" + echo " Email: $([ "$email_choice" = "3" ] && echo "skipped" || echo "configured")" +} + +# ----------------------------------------------------------- +# Install dependencies +# ----------------------------------------------------------- +install_deps() { + step "Installing dependencies..." + + cd "$APP_DIR" + + if [ ! -d "node_modules" ]; then + info "Running npm install..." + npm install + ok "Dependencies installed" + else + info "node_modules exists, checking for updates..." + npm install + fi +} + +# ----------------------------------------------------------- +# Run migrations +# ----------------------------------------------------------- +run_migrations() { + [ -f "$ENV_FILE" ] || error ".env not found. Run: $0 --env-only" + + step "Running database migrations..." + cd "$APP_DIR" + + npx prisma migrate deploy 2>&1 || { + warn "migrate deploy failed, trying db push..." + npx prisma db push --skip-generate 2>&1 || error "Database migration failed" + } + + ok "Database migrations complete" +} + +# ----------------------------------------------------------- +# Start the app +# ----------------------------------------------------------- +start_app() { + [ -f "$ENV_FILE" ] || error ".env not found. Run: $0 --env-only" + + cd "$APP_DIR" + + echo "" + echo " Choose mode:" + echo " 1) Development (npm run dev, with hot reload)" + echo " 2) Production (npm run build + npm start)" + echo "" + local mode="1" + ask "Choice" "$mode" mode + + case "$mode" in + 2) + step "Building for production..." + npm run build + + step "Starting in production mode..." + info "Starting server on http://localhost:3000" + info "Press Ctrl+C to stop" + echo "" + npm start + ;; + *) + step "Starting in development mode..." + info "Starting dev server on http://localhost:3000" + info "Press Ctrl+C to stop" + echo "" + npm run dev + ;; + esac +} + +# ----------------------------------------------------------- +# Full setup +# ----------------------------------------------------------- +full_setup() { + check_deps + generate_env + install_deps + run_migrations + + echo "" + echo -e "${GREEN}${BOLD}============================================${NC}" + echo -e "${GREEN}${BOLD} Setup complete!${NC}" + echo -e "${GREEN}${BOLD}============================================${NC}" + echo "" + + local admin_email + admin_email=$(grep '^ADMIN_EMAIL=' "$ENV_FILE" | cut -d= -f2 | tr -d '"') + + echo -e " ${BOLD}Next steps:${NC}" + echo " 1. Start the app: $0 --start" + echo " 2. Open: $(grep '^NEXTAUTH_URL=' "$ENV_FILE" | cut -d= -f2 | tr -d '"')" + echo -e " 3. Register with: ${BOLD}${admin_email}${NC}" + echo " 4. That account will automatically get ADMIN role" + echo "" +} + +# ----------------------------------------------------------- +# Main +# ----------------------------------------------------------- +ACTION="${1:---full}" + +case "$ACTION" in + --env-only) + check_deps + generate_env + ;; + --start) + start_app + ;; + --migrate) + run_migrations + ;; + --install) + install_deps + ;; + --stop) + warn "For local deployment, stop the server with Ctrl+C" + ;; + --full) + full_setup + ;; + *) + echo "Usage: $0 [--env-only | --start | --migrate | --install | --full | --stop]" + echo "" + echo " --env-only Generate .env interactively" + echo " --start Start the app (dev or prod mode)" + echo " --migrate Run database migrations" + echo " --install Install npm dependencies" + echo " --full Full setup: env + install + migrate (default)" + echo " --stop Reminder to use Ctrl+C" + exit 1 + ;; +esac diff --git a/scripts/deploy.sh b/scripts/deploy.sh index b40b11d..3b499fb 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -251,9 +251,8 @@ deploy() { done echo "" - info "Initializing database..." - docker compose exec memento-note npx prisma db push --skip-generate 2>/dev/null || \ - warn "DB push failed (may already be synced)" + info "Database migrations are handled by the container entrypoint on every start." + info "The entrypoint handles fresh installs, updates, and P3005 baseline recovery automatically." echo "" echo "==========================================" @@ -266,10 +265,18 @@ deploy() { # Show admin setup hint if first time local user_count user_count=$(docker compose exec -T postgres psql -U memento -d memento -t -c 'SELECT COUNT(*) FROM "User"' 2>/dev/null | tr -d ' ' || echo "0") + local admin_email + admin_email=$(grep '^ADMIN_EMAIL=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "") if [ "$user_count" = "0" ]; then echo "" - warn "No users found. Register at $(grep NEXTAUTH_URL "$ENV_FILE" | cut -d= -f2 | tr -d '"')/register" - warn "Then run: docker compose exec memento-note npx tsx scripts/grant-all-admins.ts" + warn "No users found." + if [ -n "$admin_email" ]; then + info "Register at $(grep NEXTAUTH_URL "$ENV_FILE" | cut -d= -f2 | tr -d '"')/register" + info "Use email: $admin_email (will automatically get ADMIN role)" + else + info "Register at $(grep NEXTAUTH_URL "$ENV_FILE" | cut -d= -f2 | tr -d '"')/register" + warn "ADMIN_EMAIL is not set. Set it in .env.docker for automatic admin role assignment." + fi fi }