feat: 8 AI providers, rich text editor, agent notifications, UI contrast & font settings
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m25s

- Add DeepSeek, OpenRouter, Mistral, Z.AI, LM Studio as AI providers
  with editable model names via Combobox in admin settings
- Fix OpenRouter broken by normalizeProvider bug in config.ts
- Convert agent-created notes from Markdown to HTML (TipTap rich text)
- Add Notification model + in-app notifications for agent results
- Agent notification click opens the created note directly
- Add note count display on notebook and inbox headers
- Fix checklist toggle in card view (persist state via localCheckItems)
- Add checklist creation option in tabs/list view (dropdown on + button)
- Fix image description ENOENT error with HTTP fallback
- Improve UI contrast across all themes (input, border, checkbox visibility)
- Add font family setting (Inter vs System Default) in Appearance settings
- Fix CSS font-sans variable conflict (removed dead Geist references)
- Update README with new features and 8 providers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-05-01 16:14:07 +02:00
parent 1345403a31
commit dbd49d6fcb
64 changed files with 4124 additions and 1392 deletions

View File

@@ -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:*)"
]
}
}

View File

@@ -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
# =============================================================================

View File

@@ -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)

View File

@@ -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

109
GUIDE.md
View File

@@ -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

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -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

View File

@@ -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
# -----------------------------------------------------------------------------

View File

@@ -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 ./

View File

@@ -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 <Suspense> here intentionally: combining a Suspense boundary with <Link>
@@ -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 (

View File

@@ -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

View File

@@ -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 (
<div className="space-y-6">
<div>
@@ -116,6 +132,23 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
/>
</SettingsSection>
<SettingsSection
title={t('appearance.fontFamilyLabel') || 'Font Family'}
icon={<span className="text-2xl">🔤</span>}
description={t('appearance.fontFamilyDescription') || 'Choose the font used throughout the app'}
>
<SettingSelect
label={t('appearance.fontFamilyLabel') || 'Font Family'}
description={t('appearance.selectFontFamily') || 'Inter is optimized for readability, System uses your OS native font'}
value={fontFamily}
options={[
{ value: 'inter', label: 'Inter' },
{ value: 'system', label: t('appearance.fontSystem') || 'System Default' },
]}
onChange={handleFontFamilyChange}
/>
</SettingsSection>
<SettingsSection
title={t('appearance.notesViewLabel')}
icon={<span className="text-2xl">📋</span>}

View File

@@ -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'}
/>
)
}

View File

@@ -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<string, string>) {
await prisma.$transaction(operations)
// Invalidate cache after update
updateTag('system-config')
return { success: true }
} catch (error) {
console.error('Failed to update settings:', error)

View File

@@ -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,
}
}

View File

@@ -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<SupportedLanguage> {
return await detectUserLanguage()
try {
const headersList = await headers()
const browserLang = parseAcceptLanguage(headersList.get('accept-language'))
return await detectUserLanguage(browserLang)
} catch {
return await detectUserLanguage()
}
}

View File

@@ -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<AppNotification[]> {
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<void> {
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<void> {
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<void> {
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)
}
}

View File

@@ -10,13 +10,13 @@ async function requireAdmin() {
/**
* GET /api/admin/models?type=ollama&url=<base_url>
* GET /api/admin/models?type=custom&url=<base_url>&key=<api_key>&kind=tags|embeddings
* GET /api/admin/models?type=deepseek&key=<api_key>&kind=tags|embeddings
* GET /api/admin/models?type=openrouter&key=<api_key>&kind=tags|embeddings
* GET /api/admin/models?type=mistral&key=<api_key>&kind=tags|embeddings
* GET /api/admin/models?type=zai&key=<api_key>&kind=tags|embeddings
* GET /api/admin/models?type=lmstudio&url=<base_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<string, string> = {
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<string, string> = { '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<string, string> = { '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' })
}

View File

@@ -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;

View File

@@ -89,7 +89,7 @@ export default async function RootLayout({
<SessionProviderWrapper>
<ErrorReporter />
<DirectionInitializer />
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} />
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} />
{children}
<Toaster />
</SessionProviderWrapper>

View File

@@ -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) {
})()}
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
<span className="text-sm font-medium text-muted-foreground mt-2">({notes.length})</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
@@ -351,6 +379,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
<FileText className="w-8 h-8 text-primary" />
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
<span className="text-sm font-medium text-muted-foreground mt-2">{notes.length} {notes.length === 1 ? (t('notes.note') || 'note') : (t('notes.notes') || 'notes')}</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />

View File

@@ -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' ? (
<NoteChecklist
items={optimisticNote.checkItems || []}
items={localCheckItems || optimisticNote.checkItems || []}
onToggleItem={handleCheckItem}
/>
) : optimisticNote.type === 'richtext' ? (

View File

@@ -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
/>

View File

@@ -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({
</DropdownMenuContent>
</DropdownMenu>
{/* New note button */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
onClick={handleCreateNote}
disabled={isCreating}
title={t('notes.newNote')}
>
{isCreating
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-4 w-4" />}
</Button>
{/* New note button — dropdown to choose type */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
disabled={isCreating}
title={t('notes.newNote')}
>
{isCreating
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem onClick={() => handleCreateNote('richtext')}>
<FileText className="h-4 w-4 mr-2" />
{t('notes.newNote') || 'Note'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCreateNote('checklist')}>
<ListChecks className="h-4 w-4 mr-2" />
{t('notes.newChecklist') || 'Checklist'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>

View File

@@ -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<ShareRequest[]>([])
const [reminders, setReminders] = useState<ReminderNote[]>([])
const [appNotifications, setAppNotifications] = useState<AppNotification[]>([])
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 (
<Popover open={open} onOpenChange={setOpen}>
@@ -149,11 +165,22 @@ export function NotificationPanel() {
<Bell className="h-4 w-4 text-primary dark:text-primary-foreground" />
<span className="font-semibold text-sm">{t('notification.notifications')}</span>
</div>
{pendingCount > 0 && (
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
{pendingCount}
</Badge>
)}
<div className="flex items-center gap-2">
{appNotifications.length > 0 && (
<button
onClick={handleMarkAllRead}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
title={t('notification.markAllRead') || 'Mark all read'}
>
<Check className="h-3.5 w-3.5" />
</button>
)}
{pendingCount > 0 && (
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
{pendingCount}
</Badge>
)}
</div>
</div>
</div>
@@ -168,6 +195,65 @@ export function NotificationPanel() {
</div>
) : (
<div className="max-h-96 overflow-y-auto">
{/* App notifications (agents, system) */}
{appNotifications.map((notif) => (
<div
key={notif.id}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150 cursor-pointer"
onClick={() => {
if (notif.actionUrl) {
handleMarkNotifRead(notif.id)
setOpen(false)
router.push(notif.actionUrl)
}
}}
>
<div className="flex items-start gap-3">
<div className={cn(
"mt-0.5 flex-none rounded-full p-1",
notif.type === 'agent_success' && 'bg-green-100 dark:bg-green-900/30 text-green-600',
notif.type === 'agent_failure' && 'bg-red-100 dark:bg-red-900/30 text-red-600',
notif.type === 'system' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
)}>
{notif.type.startsWith('agent') ? (
<Bot className="w-3.5 h-3.5" />
) : (
<AlertCircle className="w-3.5 h-3.5" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className={cn(
"text-[10px] font-semibold uppercase tracking-wider",
notif.type === 'agent_success' && 'text-green-600 dark:text-green-400',
notif.type === 'agent_failure' && 'text-red-600 dark:text-red-400',
notif.type === 'system' && 'text-blue-600 dark:text-blue-400',
)}>
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent completed')}
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent failed')}
{notif.type === 'system' && 'System'}
</span>
</div>
<p className="text-sm font-medium truncate">{notif.title}</p>
{notif.message && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{notif.message}</p>
)}
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
className="mt-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
title={t('notification.dismiss') || 'Dismiss'}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
{/* Overdue reminders */}
{overdueReminders.map((note) => (
<div

View File

@@ -5,9 +5,10 @@ import { useEffect } from 'react'
interface ThemeInitializerProps {
theme?: string
fontSize?: string
fontFamily?: string
}
export function ThemeInitializer({ theme, fontSize }: ThemeInitializerProps) {
export function ThemeInitializer({ theme, fontSize, fontFamily }: ThemeInitializerProps) {
useEffect(() => {
// 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
}

View File

@@ -14,7 +14,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer border-input dark:bg-input/50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}

View File

@@ -23,6 +23,8 @@ interface ComboboxProps {
emptyMessage?: string
disabled?: boolean
className?: string
/** When true, the search text can be submitted as a custom value not in the options list */
allowCustomValue?: boolean
}
export function Combobox({
@@ -34,6 +36,7 @@ export function Combobox({
emptyMessage = 'No results found.',
disabled = false,
className,
allowCustomValue = false,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const [search, setSearch] = React.useState('')
@@ -56,6 +59,14 @@ export function Combobox({
setSearch('')
}
const handleConfirmCustom = () => {
if (allowCustomValue && search.trim()) {
onChange(search.trim())
setOpen(false)
setSearch('')
}
}
return (
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) setSearch('') }}>
<PopoverTrigger asChild>
@@ -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
/>
</div>
<div className="max-h-60 overflow-y-auto p-1">
{filtered.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
allowCustomValue && search.trim() ? (
<button
type="button"
onClick={handleConfirmCustom}
className="flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
>
<span className="text-muted-foreground mr-2">+</span>
<span>Use "<strong>{search.trim()}</strong>"</span>
</button>
) : (
<div className="py-6 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
)
) : (
filtered.map((option) => {
const isSelected = option.value === value

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<string, { baseUrl: string; model: string; embeddingModel: string }> = {
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<string, string>, 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<string, string>, 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<string, string>, modelName: s
}
function createDeepSeekProvider(config: Record<string, string>, modelName: string, embeddingModelName: string): CustomOpenAIProvider {
const apiKey = config?.DEEPSEEK_API_KEY || config?.CUSTOM_OPENAI_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.CUSTOM_OPENAI_API_KEY || '';
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<string, string>, modelName: string, embeddingModelName: string): CustomOpenAIProvider {
const apiKey = config?.OPENROUTER_API_KEY || config?.CUSTOM_OPENAI_API_KEY || process.env.OPENROUTER_API_KEY || process.env.CUSTOM_OPENAI_API_KEY || '';
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<string, string>, 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<string, string>, 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<string, string>, 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<string, string>, modelName: string, embeddingModelName: string, ollamaBaseUrl?: string): AIProvider {
@@ -74,28 +127,47 @@ function getProviderInstance(providerType: ProviderType, config: Record<string,
return createDeepSeekProvider(config, modelName, embeddingModelName);
case 'openrouter':
return createOpenRouterProvider(config, modelName, embeddingModelName);
case 'mistral':
return createMistralProvider(config, modelName, embeddingModelName);
case 'zai':
return createZAIProvider(config, modelName, embeddingModelName);
case 'lmstudio':
return createLMStudioProvider(config, modelName, embeddingModelName);
default:
return createOllamaProvider(config, modelName, embeddingModelName, ollamaBaseUrl);
}
}
// Resolve the effective provider type and config keys for a given provider
// Returns { providerType, apiKeyConfigKey, baseUrlConfigKey }
function getProviderConfigKeys(providerType: string): { apiKeyConfigKey: string; baseUrlConfigKey: string } {
switch (providerType) {
case 'deepseek': return { apiKeyConfigKey: 'DEEPSEEK_API_KEY', baseUrlConfigKey: '' };
case 'openrouter': return { apiKeyConfigKey: 'OPENROUTER_API_KEY', baseUrlConfigKey: '' };
case 'mistral': return { apiKeyConfigKey: 'MISTRAL_API_KEY', baseUrlConfigKey: '' };
case 'zai': return { apiKeyConfigKey: 'ZAI_API_KEY', baseUrlConfigKey: '' };
case 'lmstudio': return { apiKeyConfigKey: 'LMSTUDIO_API_KEY', baseUrlConfigKey: 'LMSTUDIO_BASE_URL' };
case 'openai': return { apiKeyConfigKey: 'OPENAI_API_KEY', baseUrlConfigKey: '' };
case 'custom': return { apiKeyConfigKey: 'CUSTOM_OPENAI_API_KEY', baseUrlConfigKey: 'CUSTOM_OPENAI_BASE_URL' };
default: return { apiKeyConfigKey: '', baseUrlConfigKey: 'OLLAMA_BASE_URL' };
}
}
export function getTagsProvider(config?: Record<string, string>): 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<string, string>): AIProvider {
}
export function getEmbeddingsProvider(config?: Record<string, string>): 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<string, string>): AIProvider {
}
export function getChatProvider(config?: Record<string, string>): 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<string, string>): 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<string, string>): AIProvider {
return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
}
// Export for use by admin settings form and deploy scripts
export { PROVIDER_DEFAULTS, getProviderConfigKeys };

View File

@@ -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 }
}
}

View File

@@ -19,23 +19,43 @@ export interface ImageDescriptionResult {
const UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads')
async function resolveImageAsBase64(imageUrl: string): Promise<string> {
async function resolveImageAsBase64(imageUrl: string): Promise<string | null> {
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

View File

@@ -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,

View File

@@ -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<string, string> = {
// 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

View File

@@ -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<SupportedLanguage> => {
async (userId: string): Promise<SupportedLanguage | null> => {
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<SupportedLanguage> {
/**
* 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<SupportedLanguage> {
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'
}

View File

@@ -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, '&amp;')
html = html.replace(/</g, '&lt;')
html = html.replace(/>/g, '&gt;')
// 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(`<pre><code class="language-${lang || 'plaintext'}">${code.trim()}</code></pre>`)
return `%%CODEBLOCK_${idx}%%`
})
// Inline code (`...`)
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
// Images (![alt](url))
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
// Links ([text](url))
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
// Headings (h1-h6)
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>')
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>')
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>')
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>')
// Bold (**text** or __text__)
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>')
// Italic (*text* or _text_)
html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
html = html.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>')
// Strikethrough (~~text~~)
html = html.replace(/~~([^~]+)~~/g, '<s>$1</s>')
// Horizontal rules (---, ***, ___)
html = html.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '<hr />')
// Tables
html = convertTables(html)
// Blockquotes (> text)
html = html.replace(/^&gt;\s+(.+)$/gm, '<blockquote><p>$1</p></blockquote>')
// Merge consecutive blockquotes
html = html.replace(/<\/blockquote>\n<blockquote>/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 <p> tags
html = wrapParagraphs(html)
// Clean up empty paragraphs
html = html.replace(/<p>\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 = '<table>'
// Header row
const headers = parseTableRow(rows[0])
if (headers.length > 0) {
table += '<thead><tr>'
headers.forEach(h => { table += `<th>${h}</th>` })
table += '</tr></thead>'
}
// Body rows (skip separator)
const bodyRows = rows.slice(2)
if (bodyRows.length > 0) {
table += '<tbody>'
bodyRows.forEach(row => {
const cells = parseTableRow(row)
table += '<tr>'
cells.forEach(c => { table += `<td>${c}</td>` })
table += '</tr>'
})
table += '</tbody>'
}
table += '</table>'
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('<ul>')
inList = true
}
result.push(`<li>${listMatch[2]}</li>`)
} else {
if (inList) {
result.push('</ul>')
inList = false
}
result.push(line)
}
}
if (inList) result.push('</ul>')
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('<ol>')
inList = true
}
result.push(`<li>${listMatch[2]}</li>`)
} else {
if (inList) {
result.push('</ol>')
inList = false
}
result.push(line)
}
}
if (inList) result.push('</ol>')
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(`<p>${text}</p>`)
}
}
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')
}

View File

@@ -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": "الإعدادات العامة",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "تنظیمات عمومی",

View File

@@ -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",

View File

@@ -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": "सामान्य सेटिंग्स",

View File

@@ -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",

View File

@@ -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": "一般設定",

View File

@@ -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": "일반 설정",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "Общие настройки",

View File

@@ -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": "常规设置",

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "UserAISettings" ADD COLUMN "fontFamily" TEXT NOT NULL DEFAULT 'inter';

View File

@@ -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])
}

474
scripts/deploy-docker.ps1 Normal file
View File

@@ -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
}

592
scripts/deploy-docker.sh Executable file
View File

@@ -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

510
scripts/deploy-local.ps1 Normal file
View File

@@ -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
}

582
scripts/deploy-local.sh Executable file
View File

@@ -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

View File

@@ -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
}