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
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:
@@ -9,7 +9,22 @@
|
|||||||
"Bash(npm ls *)",
|
"Bash(npm ls *)",
|
||||||
"mcp__zread__get_repo_structure",
|
"mcp__zread__get_repo_structure",
|
||||||
"Bash(npm install *)",
|
"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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,14 @@ NEXTAUTH_URL="http://localhost:3000"
|
|||||||
NEXTAUTH_SECRET="changethisinproduction"
|
NEXTAUTH_SECRET="changethisinproduction"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# REGISTRATION
|
# REGISTRATION & ADMIN
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Set to "false" to disable public registration (default: true)
|
# Set to "false" to disable public registration (default: true)
|
||||||
# ALLOW_REGISTRATION=true
|
# ALLOW_REGISTRATION=true
|
||||||
|
|
||||||
|
# Admin email - The first user registering with this email gets ADMIN role (REQUIRED)
|
||||||
|
# ADMIN_EMAIL="admin@yourdomain.com"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# POSTGRESQL CONFIGURATION
|
# POSTGRESQL CONFIGURATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
16
DEPLOY.md
16
DEPLOY.md
@@ -63,13 +63,14 @@ git config credential.helper store
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/memento
|
cd /opt/memento
|
||||||
bash scripts/deploy.sh --full
|
bash scripts/deploy-docker.sh --full
|
||||||
```
|
```
|
||||||
|
|
||||||
Le script demande interactivement :
|
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`)
|
- URL (mettre `http://192.168.1.190` ou `http://notes.parsanet.org`)
|
||||||
- Provider AI + cle API
|
- Provider AI + cle API
|
||||||
- Email (optionnel)
|
- MCP server, email, recherche web (optionnels)
|
||||||
|
|
||||||
Il genere automatiquement NEXTAUTH_SECRET et POSTGRES_PASSWORD.
|
Il genere automatiquement NEXTAUTH_SECRET et POSTGRES_PASSWORD.
|
||||||
|
|
||||||
@@ -77,16 +78,17 @@ Il genere automatiquement NEXTAUTH_SECRET et POSTGRES_PASSWORD.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/memento
|
cd /opt/memento
|
||||||
bash scripts/deploy.sh --build
|
bash scripts/deploy-docker.sh --build
|
||||||
docker compose ps
|
docker compose ps
|
||||||
# Les 3 conteneurs doivent etre "healthy"
|
# Les 3 conteneurs doivent etre "healthy"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Creer le premier admin
|
### 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
|
```bash
|
||||||
# S'inscrire sur http://192.168.1.190/register
|
|
||||||
# Puis promouvoir en admin :
|
|
||||||
docker compose exec -T postgres psql -U memento -d memento \
|
docker compose exec -T postgres psql -U memento -d memento \
|
||||||
-c "UPDATE \"User\" SET role='ADMIN' WHERE email='VOTRE_EMAIL';"
|
-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
|
- [ ] Creer `memento-deploy` sur 192.168.1.190
|
||||||
- [ ] Cloner le repo dans `/opt/memento`
|
- [ ] Cloner le repo dans `/opt/memento`
|
||||||
- [ ] `bash scripts/deploy.sh --full`
|
- [ ] `bash scripts/deploy-docker.sh --full`
|
||||||
- [ ] 3 conteneurs healthy
|
- [ ] 3 conteneurs healthy
|
||||||
- [ ] S'inscrire, puis promouvoir en ADMIN via SQL
|
- [ ] S'inscrire avec l'email ADMIN_EMAIL defini dans .env.docker
|
||||||
- [ ] Configurer Nginx
|
- [ ] Configurer Nginx
|
||||||
|
|
||||||
### CI/CD (une seule fois)
|
### CI/CD (une seule fois)
|
||||||
|
|||||||
109
GUIDE.en.md
109
GUIDE.en.md
@@ -144,7 +144,26 @@ Browser -> Next.js App Router
|
|||||||
- PostgreSQL 16
|
- PostgreSQL 16
|
||||||
- npm
|
- 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
|
```bash
|
||||||
# 1. Clone the repository
|
# 1. Clone the repository
|
||||||
@@ -156,7 +175,7 @@ npm install --legacy-peer-deps
|
|||||||
|
|
||||||
# 3. Configure the environment
|
# 3. Configure the environment
|
||||||
cp .env.example .env
|
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
|
# 4. Create the database and apply migrations
|
||||||
npx prisma migrate dev
|
npx prisma migrate dev
|
||||||
@@ -170,15 +189,45 @@ npm run dev
|
|||||||
|
|
||||||
### First Launch
|
### First Launch
|
||||||
|
|
||||||
1. Create an account via the registration page
|
1. Register an account using the email you set in `ADMIN_EMAIL`
|
||||||
2. The first registered user automatically becomes admin
|
2. That account automatically gets the **ADMIN** role
|
||||||
3. Access the admin panel: `http://localhost:3000/admin/settings`
|
3. Access the admin panel: `http://localhost:3000/admin/settings`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docker Deployment
|
## 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
|
```bash
|
||||||
# 1. Clone the repository
|
# 1. Clone the repository
|
||||||
@@ -188,7 +237,7 @@ cd Momento
|
|||||||
# 2. Configure the environment
|
# 2. Configure the environment
|
||||||
cp .env.docker.example .env.docker
|
cp .env.docker.example .env.docker
|
||||||
nano .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
|
# 3. Start the containers
|
||||||
docker compose up -d
|
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_URL` | Public URL of the app (e.g., `http://192.168.1.190:3000`) |
|
||||||
| `NEXTAUTH_SECRET` | JWT secret - generate with `openssl rand -base64 32` |
|
| `NEXTAUTH_SECRET` | JWT secret - generate with `openssl rand -base64 32` |
|
||||||
|
| `ADMIN_EMAIL` | Email that automatically gets ADMIN role on registration |
|
||||||
|
|
||||||
### Ports Used
|
### Ports Used
|
||||||
|
|
||||||
@@ -628,7 +678,9 @@ The `SMTP_SECURE` and `SMTP_IGNORE_CERT` settings can be configured from the adm
|
|||||||
|
|
||||||
### Admin Panel
|
### 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`
|
**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 |
|
| `DATABASE_URL` | - | PostgreSQL connection string |
|
||||||
| `NEXTAUTH_SECRET` | - | JWT secret (`openssl rand -base64 32`) |
|
| `NEXTAUTH_SECRET` | - | JWT secret (`openssl rand -base64 32`) |
|
||||||
| `NEXTAUTH_URL` | `http://localhost:3000` | Public URL of the app |
|
| `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)
|
#### PostgreSQL (Docker)
|
||||||
|
|
||||||
@@ -765,6 +825,37 @@ Accessible only to the first registered user (or users with the admin role).
|
|||||||
|
|
||||||
## Useful Commands
|
## 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
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -828,7 +919,9 @@ npm start
|
|||||||
npm run lint
|
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
|
```bash
|
||||||
cd memento-note
|
cd memento-note
|
||||||
|
|||||||
109
GUIDE.md
109
GUIDE.md
@@ -145,7 +145,26 @@ Navigateur -> Next.js App Router
|
|||||||
- PostgreSQL 16
|
- PostgreSQL 16
|
||||||
- npm
|
- 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
|
```bash
|
||||||
# 1. Cloner le depot
|
# 1. Cloner le depot
|
||||||
@@ -157,7 +176,7 @@ npm install --legacy-peer-deps
|
|||||||
|
|
||||||
# 3. Configurer l'environnement
|
# 3. Configurer l'environnement
|
||||||
cp .env.example .env
|
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
|
# 4. Creer la base de donnees et appliquer les migrations
|
||||||
npx prisma migrate dev
|
npx prisma migrate dev
|
||||||
@@ -171,15 +190,45 @@ npm run dev
|
|||||||
|
|
||||||
### Premier lancement
|
### Premier lancement
|
||||||
|
|
||||||
1. Creez un compte via la page d'inscription
|
1. Inscrivez-vous avec l'email defini dans `ADMIN_EMAIL`
|
||||||
2. Le premier utilisateur inscrit devient automatiquement admin
|
2. Ce compte obtient automatiquement le role **ADMIN**
|
||||||
3. Accedez au panneau admin : `http://localhost:3000/admin/settings`
|
3. Accedez au panneau admin : `http://localhost:3000/admin/settings`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deploiement Docker
|
## 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
|
```bash
|
||||||
# 1. Cloner le depot
|
# 1. Cloner le depot
|
||||||
@@ -189,7 +238,7 @@ cd Momento
|
|||||||
# 2. Configurer l'environnement
|
# 2. Configurer l'environnement
|
||||||
cp .env.docker.example .env.docker
|
cp .env.docker.example .env.docker
|
||||||
nano .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
|
# 3. Lancer les conteneurs
|
||||||
docker compose up -d
|
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_URL` | URL publique de l'app (ex: `http://192.168.1.190:3000`) |
|
||||||
| `NEXTAUTH_SECRET` | Secret JWT - generer avec `openssl rand -base64 32` |
|
| `NEXTAUTH_SECRET` | Secret JWT - generer avec `openssl rand -base64 32` |
|
||||||
|
| `ADMIN_EMAIL` | Email qui obtient automatiquement le role ADMIN a l'inscription |
|
||||||
|
|
||||||
### Ports utilises
|
### Ports utilises
|
||||||
|
|
||||||
@@ -629,7 +679,9 @@ Les parametres `SMTP_SECURE` et `SMTP_IGNORE_CERT` sont configurables depuis le
|
|||||||
|
|
||||||
### Panneau admin
|
### 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`
|
**URL** : `/admin/settings`
|
||||||
|
|
||||||
@@ -679,6 +731,14 @@ Accessibles uniquement par le premier utilisateur inscrit (ou les utilisateurs a
|
|||||||
| `DATABASE_URL` | - | Connection string PostgreSQL |
|
| `DATABASE_URL` | - | Connection string PostgreSQL |
|
||||||
| `NEXTAUTH_SECRET` | - | Secret JWT (`openssl rand -base64 32`) |
|
| `NEXTAUTH_SECRET` | - | Secret JWT (`openssl rand -base64 32`) |
|
||||||
| `NEXTAUTH_URL` | `http://localhost:3000` | URL publique de l'app |
|
| `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)
|
#### PostgreSQL (Docker)
|
||||||
|
|
||||||
@@ -766,6 +826,37 @@ Accessibles uniquement par le premier utilisateur inscrit (ou les utilisateurs a
|
|||||||
|
|
||||||
## Commandes utiles
|
## 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
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -829,7 +920,9 @@ npm start
|
|||||||
npm run lint
|
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
|
```bash
|
||||||
cd memento-note
|
cd memento-note
|
||||||
|
|||||||
64
README.fr.md
64
README.fr.md
@@ -9,21 +9,24 @@ Une application de prise de notes intelligente et powered by IA. Comme Google Ke
|
|||||||
## Fonctionnalites
|
## Fonctionnalites
|
||||||
|
|
||||||
**Notes et organisation**
|
**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
|
- 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
|
- Upload d'images, partage de notes, archive et corbeille
|
||||||
- 10 themes pastel + mode sombre
|
- 10 themes pastel + mode sombre
|
||||||
|
- Historique des notes avec restauration de versions
|
||||||
|
- Reorganisation des cartes par drag-and-drop
|
||||||
|
|
||||||
**IA et automatisation**
|
**IA et automatisation**
|
||||||
- Recherche semantique par embeddings
|
- Recherche semantique par embeddings
|
||||||
- Generation automatique de tags et suggestions de titre
|
- 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
|
- Conversations chat IA persistees
|
||||||
- Memory Echo - decouverte de connexions entre notes
|
- Memory Echo - decouverte de connexions entre notes
|
||||||
- Organisation automatique par batch et labels intelligents
|
- Organisation automatique par batch et labels intelligents
|
||||||
- Resumes de notebooks generes par IA
|
- Resumes de notebooks generes par IA
|
||||||
- Workflows visuels
|
- Workflows visuels
|
||||||
|
- Notifications in-app pour les resultats des agents
|
||||||
|
|
||||||
**Integrations**
|
**Integrations**
|
||||||
- Serveur MCP avec **22 outils** - connecter Claude Desktop, N8N, ou tout client MCP
|
- 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)
|
### Docker (recommande)
|
||||||
|
|
||||||
|
Le script de deploiement interactif s'occupe de tout - configuration, build et demarrage :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/yourusername/Momento.git
|
git clone https://github.com/yourusername/Momento.git
|
||||||
cd Momento
|
cd Momento
|
||||||
|
|
||||||
cp .env.docker.example .env.docker
|
# macOS / Linux
|
||||||
|
./scripts/deploy-docker.sh
|
||||||
|
|
||||||
# Modifier ces deux valeurs obligatoires :
|
# Windows PowerShell
|
||||||
# NEXTAUTH_URL="http://VOTRE_IP_SERVEUR:3000"
|
.\scripts\deploy-docker.ps1
|
||||||
# NEXTAUTH_SECRET="generer avec : openssl rand -base64 32"
|
|
||||||
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
### Developpement local
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/yourusername/Momento.git
|
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
|
cd Momento/memento-note
|
||||||
|
|
||||||
cp .env.example .env
|
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
|
npm install --legacy-peer-deps
|
||||||
npx prisma migrate dev
|
npx prisma migrate dev
|
||||||
npm run dev
|
npm run dev
|
||||||
@@ -77,13 +98,18 @@ npm run dev
|
|||||||
|
|
||||||
## Providers IA
|
## 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 |
|
| Provider | Type | Configuration |
|
||||||
|----------|------|---------------|
|
|----------|------|---------------|
|
||||||
| **Ollama** | Local, gratuit | `docker compose --profile ollama up -d` |
|
| **Ollama** | Local, gratuit | `docker compose --profile ollama up -d` |
|
||||||
| **OpenAI** | Cloud, payant | Definir `OPENAI_API_KEY` |
|
| **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 :
|
Exemple avec Ollama :
|
||||||
```bash
|
```bash
|
||||||
@@ -129,7 +155,7 @@ Pour N8N ou clients HTTP, utiliser le mode HTTP Streamable : `http://localhost:3
|
|||||||
| Style | Tailwind CSS 4, shadcn/ui |
|
| Style | Tailwind CSS 4, shadcn/ui |
|
||||||
| Base de donnees | PostgreSQL 16, Prisma ORM 5 |
|
| Base de donnees | PostgreSQL 16, Prisma ORM 5 |
|
||||||
| Auth | NextAuth.js v5 |
|
| 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 |
|
| MCP | @modelcontextprotocol/sdk |
|
||||||
| Email | Nodemailer (SMTP) / Resend |
|
| Email | Nodemailer (SMTP) / Resend |
|
||||||
|
|
||||||
@@ -147,6 +173,11 @@ Pour le guide complet d'installation, deploiement et configuration, voir **[GUID
|
|||||||
Momento/
|
Momento/
|
||||||
├── docker-compose.yml # Orchestration multi-conteneurs
|
├── docker-compose.yml # Orchestration multi-conteneurs
|
||||||
├── .env.docker.example # Template environnement Docker
|
├── .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
|
├── memento-note/ # Application Next.js
|
||||||
│ ├── app/ # App Router (pages, actions, API)
|
│ ├── app/ # App Router (pages, actions, API)
|
||||||
│ ├── components/ # Composants React
|
│ ├── 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_URL` | Oui | URL publique de l'app |
|
||||||
| `NEXTAUTH_SECRET` | Oui | Secret JWT (`openssl rand -base64 32`) |
|
| `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`) |
|
| `POSTGRES_PASSWORD` | Rec. | Mot de passe PostgreSQL (defaut : `memento`) |
|
||||||
| `AI_PROVIDER_TAGS` | Non | Provider IA pour tags : `ollama`, `openai`, `custom` |
|
| `AI_PROVIDER_TAGS` | Non | Provider IA pour tags : `ollama`, `openai`, `custom` |
|
||||||
| `OPENAI_API_KEY` | Si OpenAI | Cle API OpenAI |
|
| `OPENAI_API_KEY` | Si OpenAI | Cle API OpenAI |
|
||||||
|
|||||||
64
README.md
64
README.md
@@ -9,21 +9,24 @@ A smart, AI-powered note-taking app. Like Google Keep, but with notebooks, seman
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
**Notes & Organization**
|
**Notes & Organization**
|
||||||
- Text, checklist, and Markdown notes (with LaTeX/KaTeX)
|
- Rich text (Notion-like), checklist, Markdown, and plain text notes
|
||||||
- Notebooks with contextual labels
|
- 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
|
- Image upload, note sharing, archive, and trash
|
||||||
- 10 pastel color themes + dark mode
|
- 10 pastel color themes + dark mode
|
||||||
|
- Note history with version restore
|
||||||
|
- Drag-and-drop card reordering
|
||||||
|
|
||||||
**AI & Automation**
|
**AI & Automation**
|
||||||
- Semantic search powered by embeddings
|
- Semantic search powered by embeddings
|
||||||
- Auto-generated tags and title suggestions
|
- Auto-generated tags and title suggestions
|
||||||
- Configurable AI agents with custom instructions
|
- Configurable AI agents with custom instructions and scheduling
|
||||||
- Persistent AI chat conversations
|
- Persistent AI chat conversations
|
||||||
- Memory Echo - discover hidden connections between notes
|
- Memory Echo - discover hidden connections between notes
|
||||||
- Batch auto-organization and smart labels
|
- Batch auto-organization and smart labels
|
||||||
- AI-generated notebook summaries
|
- AI-generated notebook summaries
|
||||||
- Visual workflow builder
|
- Visual workflow builder
|
||||||
|
- In-app notifications for agent results
|
||||||
|
|
||||||
**Integrations**
|
**Integrations**
|
||||||
- MCP Server with **22 tools** - connect Claude Desktop, N8N, or any MCP client
|
- 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)
|
### Docker (recommended)
|
||||||
|
|
||||||
|
The interactive deploy script handles everything - environment config, container build, and startup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/yourusername/Momento.git
|
git clone https://github.com/yourusername/Momento.git
|
||||||
cd Momento
|
cd Momento
|
||||||
|
|
||||||
cp .env.docker.example .env.docker
|
# macOS / Linux
|
||||||
|
./scripts/deploy-docker.sh
|
||||||
|
|
||||||
# Edit these two required values:
|
# Windows PowerShell
|
||||||
# NEXTAUTH_URL="http://YOUR_SERVER_IP:3000"
|
.\scripts\deploy-docker.ps1
|
||||||
# NEXTAUTH_SECRET="generate-with: openssl rand -base64 32"
|
|
||||||
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
### Local Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/yourusername/Momento.git
|
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
|
cd Momento/memento-note
|
||||||
|
|
||||||
cp .env.example .env
|
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
|
npm install --legacy-peer-deps
|
||||||
npx prisma migrate dev
|
npx prisma migrate dev
|
||||||
npm run dev
|
npm run dev
|
||||||
@@ -77,13 +98,18 @@ npm run dev
|
|||||||
|
|
||||||
## AI Providers
|
## 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 |
|
| Provider | Type | Setup |
|
||||||
|----------|------|-------|
|
|----------|------|-------|
|
||||||
| **Ollama** | Local, free | `docker compose --profile ollama up -d` |
|
| **Ollama** | Local, free | `docker compose --profile ollama up -d` |
|
||||||
| **OpenAI** | Cloud, paid | Set `OPENAI_API_KEY` |
|
| **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:
|
Example for Ollama:
|
||||||
```bash
|
```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 |
|
| Styling | Tailwind CSS 4, shadcn/ui |
|
||||||
| Database | PostgreSQL 16, Prisma ORM 5 |
|
| Database | PostgreSQL 16, Prisma ORM 5 |
|
||||||
| Auth | NextAuth.js v5 |
|
| 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 |
|
| MCP | @modelcontextprotocol/sdk |
|
||||||
| Email | Nodemailer (SMTP) / Resend |
|
| Email | Nodemailer (SMTP) / Resend |
|
||||||
|
|
||||||
@@ -147,6 +173,11 @@ For the complete installation, deployment, and configuration guide, see **[GUIDE
|
|||||||
Momento/
|
Momento/
|
||||||
├── docker-compose.yml # Multi-container orchestration
|
├── docker-compose.yml # Multi-container orchestration
|
||||||
├── .env.docker.example # Docker environment template
|
├── .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
|
├── memento-note/ # Next.js application
|
||||||
│ ├── app/ # App Router (pages, actions, API)
|
│ ├── app/ # App Router (pages, actions, API)
|
||||||
│ ├── components/ # React UI components
|
│ ├── 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_URL` | Yes | Public URL of the app |
|
||||||
| `NEXTAUTH_SECRET` | Yes | JWT secret (`openssl rand -base64 32`) |
|
| `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`) |
|
| `POSTGRES_PASSWORD` | Rec. | PostgreSQL password (default: `memento`) |
|
||||||
| `AI_PROVIDER_TAGS` | No | AI provider for tags: `ollama`, `openai`, `custom` |
|
| `AI_PROVIDER_TAGS` | No | AI provider for tags: `ollama`, `openai`, `custom` |
|
||||||
| `OPENAI_API_KEY` | If OpenAI | Your OpenAI API key |
|
| `OPENAI_API_KEY` | If OpenAI | Your OpenAI API key |
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ services:
|
|||||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-memento}:${POSTGRES_PASSWORD:-memento}@postgres:5432/${POSTGRES_DB:-memento}
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-memento}:${POSTGRES_PASSWORD:-memento}@postgres:5432/${POSTGRES_DB:-memento}
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- NEXT_TELEMETRY_DISABLED=1
|
- NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# ADMIN_EMAIL comes from .env.docker via env_file directive above
|
||||||
volumes:
|
volumes:
|
||||||
- uploads-data:/app/data/uploads
|
- uploads-data:/app/data/uploads
|
||||||
- backup-data:/app/data/backups
|
- backup-data:/app/data/backups
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ NEXTAUTH_SECRET="generate-with-openssl-rand-base64-32"
|
|||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Registration
|
# Registration & Admin
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Set to "false" to disable public registration (default: true)
|
# Set to "false" to disable public registration (default: true)
|
||||||
# ALLOW_REGISTRATION="true"
|
# ALLOW_REGISTRATION="true"
|
||||||
|
|
||||||
|
# Admin email - The first user registering with this email gets ADMIN role (REQUIRED)
|
||||||
|
# ADMIN_EMAIL="admin@yourdomain.com"
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# AI Providers
|
# AI Providers
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ RUN useradd --system --uid 1001 --gid nodejs nextjs
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
|
||||||
# Upload directory (outside public/ — served via API route)
|
# 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
|
# Next.js standalone output
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
import { AdminProvidersWrapper } from '@/components/admin-providers-wrapper'
|
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'
|
import { loadTranslations } from '@/lib/i18n/load-translations'
|
||||||
|
|
||||||
// No <Suspense> here intentionally: combining a Suspense boundary with <Link>
|
// No <Suspense> here intentionally: combining a Suspense boundary with <Link>
|
||||||
@@ -10,7 +11,9 @@ export default async function AdminGroupLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
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)
|
const initialTranslations = await loadTranslations(initialLanguage)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { HeaderWrapper } from "@/components/header-wrapper";
|
|||||||
import { Sidebar } from "@/components/sidebar";
|
import { Sidebar } from "@/components/sidebar";
|
||||||
import { ProvidersWrapper } from "@/components/providers-wrapper";
|
import { ProvidersWrapper } from "@/components/providers-wrapper";
|
||||||
import { auth } from "@/auth";
|
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 { loadTranslations } from "@/lib/i18n/load-translations";
|
||||||
import { getAISettings } from "@/app/actions/ai-settings";
|
import { getAISettings } from "@/app/actions/ai-settings";
|
||||||
|
|
||||||
@@ -14,10 +15,14 @@ export default async function MainLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
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
|
// Run auth + language detection + translation loading in parallel
|
||||||
const [session, initialLanguage] = await Promise.all([
|
const [session, initialLanguage] = await Promise.all([
|
||||||
auth(),
|
auth(),
|
||||||
detectUserLanguage(),
|
detectUserLanguage(browserLang),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Load initial translations server-side to prevent hydration mismatch
|
// Load initial translations server-side to prevent hydration mismatch
|
||||||
|
|||||||
@@ -12,14 +12,16 @@ interface AppearanceSettingsClientProps {
|
|||||||
initialTheme: string
|
initialTheme: string
|
||||||
initialNotesViewMode: 'masonry' | 'tabs'
|
initialNotesViewMode: 'masonry' | 'tabs'
|
||||||
initialCardSizeMode?: 'variable' | 'uniform'
|
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 { t } = useLanguage()
|
||||||
const [theme, setTheme] = useState(initialTheme || 'light')
|
const [theme, setTheme] = useState(initialTheme || 'light')
|
||||||
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
||||||
const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs'>(initialNotesViewMode)
|
const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs'>(initialNotesViewMode)
|
||||||
const [cardSizeMode, setCardSizeMode] = useState<'variable' | 'uniform'>(initialCardSizeMode)
|
const [cardSizeMode, setCardSizeMode] = useState<'variable' | 'uniform'>(initialCardSizeMode)
|
||||||
|
const [fontFamily, setFontFamily] = useState(initialFontFamily)
|
||||||
|
|
||||||
const handleThemeChange = async (value: string) => {
|
const handleThemeChange = async (value: string) => {
|
||||||
setTheme(value)
|
setTheme(value)
|
||||||
@@ -69,6 +71,20 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
|||||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -116,6 +132,23 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
|||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</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
|
<SettingsSection
|
||||||
title={t('appearance.notesViewLabel')}
|
title={t('appearance.notesViewLabel')}
|
||||||
icon={<span className="text-2xl">📋</span>}
|
icon={<span className="text-2xl">📋</span>}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default async function AppearanceSettingsPage() {
|
|||||||
initialTheme={userSettings.theme}
|
initialTheme={userSettings.theme}
|
||||||
initialNotesViewMode={aiSettings.notesViewMode === 'masonry' ? 'masonry' : 'tabs'}
|
initialNotesViewMode={aiSettings.notesViewMode === 'masonry' ? 'masonry' : 'tabs'}
|
||||||
initialCardSizeMode={userSettings.cardSizeMode}
|
initialCardSizeMode={userSettings.cardSizeMode}
|
||||||
|
initialFontFamily={aiSettings.fontFamily || 'inter'}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { sendEmail } from '@/lib/mail'
|
import { sendEmail } from '@/lib/mail'
|
||||||
import { updateTag } from 'next/cache'
|
|
||||||
|
|
||||||
async function checkAdmin() {
|
async function checkAdmin() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
@@ -62,9 +61,6 @@ export async function updateSystemConfig(data: Record<string, string>) {
|
|||||||
|
|
||||||
await prisma.$transaction(operations)
|
await prisma.$transaction(operations)
|
||||||
|
|
||||||
// Invalidate cache after update
|
|
||||||
updateTag('system-config')
|
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update settings:', error)
|
console.error('Failed to update settings:', error)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type UserAISettingsData = {
|
|||||||
autoLabeling?: boolean
|
autoLabeling?: boolean
|
||||||
noteHistory?: boolean
|
noteHistory?: boolean
|
||||||
noteHistoryMode?: 'manual' | 'auto'
|
noteHistoryMode?: 'manual' | 'auto'
|
||||||
|
fontFamily?: 'inter' | 'system'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
|
/** 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',
|
'autoLabeling',
|
||||||
'noteHistory',
|
'noteHistory',
|
||||||
'noteHistoryMode',
|
'noteHistoryMode',
|
||||||
|
'fontFamily',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
|
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
|
||||||
@@ -157,6 +159,7 @@ const getCachedAISettings = unstable_cache(
|
|||||||
autoLabeling: true,
|
autoLabeling: true,
|
||||||
noteHistory: false,
|
noteHistory: false,
|
||||||
noteHistoryMode: 'manual' as const,
|
noteHistoryMode: 'manual' as const,
|
||||||
|
fontFamily: 'inter' as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +191,7 @@ const getCachedAISettings = unstable_cache(
|
|||||||
autoLabeling: settings.autoLabeling ?? true,
|
autoLabeling: settings.autoLabeling ?? true,
|
||||||
noteHistory: settings.noteHistory ?? false,
|
noteHistory: settings.noteHistory ?? false,
|
||||||
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
|
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
|
||||||
|
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'system',
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting AI settings:', error)
|
console.error('Error getting AI settings:', error)
|
||||||
@@ -212,6 +216,7 @@ const getCachedAISettings = unstable_cache(
|
|||||||
autoLabeling: true,
|
autoLabeling: true,
|
||||||
noteHistory: false,
|
noteHistory: false,
|
||||||
noteHistoryMode: 'manual' as const,
|
noteHistoryMode: 'manual' as const,
|
||||||
|
fontFamily: 'inter' as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -249,6 +254,7 @@ export async function getAISettings(userId?: string) {
|
|||||||
autoLabeling: true,
|
autoLabeling: true,
|
||||||
noteHistory: false,
|
noteHistory: false,
|
||||||
noteHistoryMode: 'manual' as const,
|
noteHistoryMode: 'manual' as const,
|
||||||
|
fontFamily: 'inter' as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use server'
|
'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'
|
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
|
* Called on app load to set initial language
|
||||||
*/
|
*/
|
||||||
export async function getInitialLanguage(): Promise<SupportedLanguage> {
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
76
memento-note/app/actions/notifications.ts
Normal file
76
memento-note/app/actions/notifications.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,13 +10,13 @@ async function requireAdmin() {
|
|||||||
/**
|
/**
|
||||||
* GET /api/admin/models?type=ollama&url=<base_url>
|
* 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=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
|
* Route API for fetching AI model lists from providers.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
if (!(await requireAdmin())) {
|
if (!(await requireAdmin())) {
|
||||||
@@ -29,14 +29,22 @@ export async function GET(request: NextRequest) {
|
|||||||
const apiKey = searchParams.get('key') ?? undefined
|
const apiKey = searchParams.get('key') ?? undefined
|
||||||
const kind = searchParams.get('kind') ?? 'tags'
|
const kind = searchParams.get('kind') ?? 'tags'
|
||||||
|
|
||||||
if (!rawUrl) {
|
// Provider-specific base URLs (used when url param is empty)
|
||||||
return NextResponse.json({ success: false, models: [], error: 'url parameter is required' })
|
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 {
|
try {
|
||||||
|
// Ollama: uses native /api/tags endpoint
|
||||||
if (type === 'ollama') {
|
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`, {
|
const res = await fetch(`${baseUrl}/api/tags`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
@@ -49,58 +57,69 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ success: true, models })
|
return NextResponse.json({ success: true, models })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'custom') {
|
// All other providers: use OpenAI-compatible /v1/models endpoint
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
const baseUrl = rawUrl
|
||||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
? rawUrl.replace(/\/$/, '')
|
||||||
|
: (PROVIDER_URLS[type || ''] || '')
|
||||||
|
|
||||||
if (kind === 'embeddings') {
|
if (!baseUrl) {
|
||||||
// Try provider-specific embeddings endpoint first (e.g. OpenRouter)
|
return NextResponse.json({ success: false, models: [], error: 'url parameter is required' })
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
} catch (err: any) {
|
||||||
return NextResponse.json({ success: false, models: [], error: err.message ?? 'Request failed' })
|
return NextResponse.json({ success: false, models: [], error: err.message ?? 'Request failed' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,8 +63,8 @@
|
|||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-inter);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, Consolas, monospace;
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -117,11 +117,11 @@
|
|||||||
--secondary-foreground: #1e293b;
|
--secondary-foreground: #1e293b;
|
||||||
--muted: #f1f5f9;
|
--muted: #f1f5f9;
|
||||||
--muted-foreground: #64748b;
|
--muted-foreground: #64748b;
|
||||||
--accent: #f8fafc;
|
--accent: #f1f5f9;
|
||||||
--accent-foreground: #0284c7;
|
--accent-foreground: #0284c7;
|
||||||
--destructive: oklch(0.6 0.18 25); /* Rouge */
|
--destructive: oklch(0.6 0.18 25); /* Rouge */
|
||||||
--border: #e2e8f0; /* Gris-bleu très clair */
|
--border: #cbd5e1; /* Gris-bleu visible */
|
||||||
--input: #ffffff;
|
--input: #cbd5e1; /* Bordure visible pour inputs/checkbox */
|
||||||
--ring: #0284c7;
|
--ring: #0284c7;
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
@@ -151,11 +151,11 @@
|
|||||||
--secondary-foreground: oklch(0.2 0.02 230);
|
--secondary-foreground: oklch(0.2 0.02 230);
|
||||||
--muted: oklch(0.92 0.005 230);
|
--muted: oklch(0.92 0.005 230);
|
||||||
--muted-foreground: oklch(0.6 0.01 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);
|
--accent-foreground: oklch(0.2 0.02 230);
|
||||||
--destructive: oklch(0.6 0.18 25); /* Rouge */
|
--destructive: oklch(0.6 0.18 25); /* Rouge */
|
||||||
--border: oklch(0.9 0.008 230); /* Gris-bleu très clair */
|
--border: oklch(0.85 0.008 230); /* Gris-bleu visible */
|
||||||
--input: oklch(0.98 0.003 230);
|
--input: oklch(0.85 0.008 230); /* Bordure visible */
|
||||||
--ring: oklch(0.7 0.005 230);
|
--ring: oklch(0.7 0.005 230);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.2 0.02 230);
|
--popover-foreground: oklch(0.2 0.02 230);
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||||
--sidebar-accent: oklch(0.94 0.005 230);
|
--sidebar-accent: oklch(0.94 0.005 230);
|
||||||
--sidebar-accent-foreground: oklch(0.2 0.02 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);
|
--sidebar-ring: oklch(0.7 0.005 230);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,11 +182,11 @@
|
|||||||
--secondary-foreground: oklch(0.97 0.003 230);
|
--secondary-foreground: oklch(0.97 0.003 230);
|
||||||
--muted: oklch(0.22 0.006 230);
|
--muted: oklch(0.22 0.006 230);
|
||||||
--muted-foreground: oklch(0.55 0.01 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);
|
--accent-foreground: oklch(0.97 0.003 230);
|
||||||
--destructive: oklch(0.65 0.18 25);
|
--destructive: oklch(0.65 0.18 25);
|
||||||
--border: oklch(0.28 0.01 230);
|
--border: oklch(0.33 0.01 230);
|
||||||
--input: oklch(0.2 0.006 230);
|
--input: oklch(0.33 0.01 230);
|
||||||
--ring: oklch(0.6 0.01 230);
|
--ring: oklch(0.6 0.01 230);
|
||||||
--popover: oklch(0.18 0.006 230);
|
--popover: oklch(0.18 0.006 230);
|
||||||
--popover-foreground: oklch(0.97 0.003 230);
|
--popover-foreground: oklch(0.97 0.003 230);
|
||||||
@@ -213,11 +213,11 @@
|
|||||||
--secondary-foreground: oklch(0.97 0.003 230);
|
--secondary-foreground: oklch(0.97 0.003 230);
|
||||||
--muted: oklch(0.22 0.006 230);
|
--muted: oklch(0.22 0.006 230);
|
||||||
--muted-foreground: oklch(0.55 0.01 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);
|
--accent-foreground: oklch(0.97 0.003 230);
|
||||||
--destructive: oklch(0.65 0.18 25);
|
--destructive: oklch(0.65 0.18 25);
|
||||||
--border: oklch(0.28 0.01 230);
|
--border: oklch(0.33 0.01 230);
|
||||||
--input: oklch(0.2 0.006 230);
|
--input: oklch(0.33 0.01 230);
|
||||||
--ring: oklch(0.6 0.01 230);
|
--ring: oklch(0.6 0.01 230);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
@@ -248,8 +248,8 @@
|
|||||||
--accent: oklch(0.25 0.015 250);
|
--accent: oklch(0.25 0.015 250);
|
||||||
--accent-foreground: oklch(0.18 0.03 250);
|
--accent-foreground: oklch(0.18 0.03 250);
|
||||||
--destructive: oklch(0.6 0.22 25);
|
--destructive: oklch(0.6 0.22 25);
|
||||||
--border: oklch(0.85 0.015 250);
|
--border: oklch(0.82 0.015 250);
|
||||||
--input: oklch(0.25 0.01 250);
|
--input: oklch(0.82 0.015 250);
|
||||||
--ring: oklch(0.65 0.015 250);
|
--ring: oklch(0.65 0.015 250);
|
||||||
--popover: oklch(0.97 0.006 250);
|
--popover: oklch(0.97 0.006 250);
|
||||||
--popover-foreground: oklch(0.18 0.03 250);
|
--popover-foreground: oklch(0.18 0.03 250);
|
||||||
@@ -274,11 +274,11 @@
|
|||||||
--secondary-foreground: oklch(0.96 0.005 250);
|
--secondary-foreground: oklch(0.96 0.005 250);
|
||||||
--muted: oklch(0.2 0.015 250);
|
--muted: oklch(0.2 0.015 250);
|
||||||
--muted-foreground: oklch(0.5 0.02 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);
|
--accent-foreground: oklch(0.96 0.005 250);
|
||||||
--destructive: oklch(0.65 0.2 25);
|
--destructive: oklch(0.65 0.2 25);
|
||||||
--border: oklch(0.3 0.02 250);
|
--border: oklch(0.33 0.02 250);
|
||||||
--input: oklch(0.22 0.02 250);
|
--input: oklch(0.33 0.02 250);
|
||||||
--ring: oklch(0.55 0.02 250);
|
--ring: oklch(0.55 0.02 250);
|
||||||
--popover: oklch(0.15 0.015 250);
|
--popover: oklch(0.15 0.015 250);
|
||||||
--popover-foreground: oklch(0.96 0.005 250);
|
--popover-foreground: oklch(0.96 0.005 250);
|
||||||
@@ -306,8 +306,8 @@
|
|||||||
--accent: oklch(0.93 0.01 225);
|
--accent: oklch(0.93 0.01 225);
|
||||||
--accent-foreground: oklch(0.18 0.035 225);
|
--accent-foreground: oklch(0.18 0.035 225);
|
||||||
--destructive: oklch(0.6 0.2 25);
|
--destructive: oklch(0.6 0.2 25);
|
||||||
--border: oklch(0.87 0.012 225);
|
--border: oklch(0.83 0.012 225);
|
||||||
--input: oklch(0.95 0.01 225);
|
--input: oklch(0.83 0.012 225);
|
||||||
--ring: oklch(0.65 0.015 225);
|
--ring: oklch(0.65 0.015 225);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.18 0.035 225);
|
--popover-foreground: oklch(0.18 0.035 225);
|
||||||
@@ -332,11 +332,11 @@
|
|||||||
--secondary-foreground: oklch(0.97 0.006 225);
|
--secondary-foreground: oklch(0.97 0.006 225);
|
||||||
--muted: oklch(0.25 0.02 225);
|
--muted: oklch(0.25 0.02 225);
|
||||||
--muted-foreground: oklch(0.52 0.018 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);
|
--accent-foreground: oklch(0.97 0.006 225);
|
||||||
--destructive: oklch(0.65 0.22 25);
|
--destructive: oklch(0.65 0.22 25);
|
||||||
--border: oklch(0.32 0.018 225);
|
--border: oklch(0.35 0.018 225);
|
||||||
--input: oklch(0.25 0.02 225);
|
--input: oklch(0.35 0.018 225);
|
||||||
--ring: oklch(0.55 0.02 225);
|
--ring: oklch(0.55 0.02 225);
|
||||||
--popover: oklch(0.17 0.01 225);
|
--popover: oklch(0.17 0.01 225);
|
||||||
--popover-foreground: oklch(0.97 0.006 225);
|
--popover-foreground: oklch(0.97 0.006 225);
|
||||||
@@ -364,8 +364,8 @@
|
|||||||
--accent: oklch(0.93 0.01 45);
|
--accent: oklch(0.93 0.01 45);
|
||||||
--accent-foreground: oklch(0.2 0.015 45);
|
--accent-foreground: oklch(0.2 0.015 45);
|
||||||
--destructive: oklch(0.6 0.2 25);
|
--destructive: oklch(0.6 0.2 25);
|
||||||
--border: oklch(0.88 0.012 45);
|
--border: oklch(0.83 0.012 45);
|
||||||
--input: oklch(0.97 0.008 45);
|
--input: oklch(0.83 0.012 45);
|
||||||
--ring: oklch(0.68 0.01 45);
|
--ring: oklch(0.68 0.01 45);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.2 0.015 45);
|
--popover-foreground: oklch(0.2 0.015 45);
|
||||||
@@ -390,11 +390,11 @@
|
|||||||
--secondary-foreground: oklch(0.97 0.005 45);
|
--secondary-foreground: oklch(0.97 0.005 45);
|
||||||
--muted: oklch(0.23 0.02 45);
|
--muted: oklch(0.23 0.02 45);
|
||||||
--muted-foreground: oklch(0.55 0.012 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);
|
--accent-foreground: oklch(0.97 0.005 45);
|
||||||
--destructive: oklch(0.65 0.2 25);
|
--destructive: oklch(0.65 0.2 25);
|
||||||
--border: oklch(0.3 0.018 45);
|
--border: oklch(0.33 0.018 45);
|
||||||
--input: oklch(0.27 0.02 45);
|
--input: oklch(0.33 0.018 45);
|
||||||
--ring: oklch(0.58 0.02 45);
|
--ring: oklch(0.58 0.02 45);
|
||||||
--popover: oklch(0.19 0.01 45);
|
--popover: oklch(0.19 0.01 45);
|
||||||
--popover-foreground: oklch(0.97 0.005 45);
|
--popover-foreground: oklch(0.97 0.005 45);
|
||||||
@@ -408,6 +408,16 @@
|
|||||||
--sidebar-ring: oklch(0.58 0.02 45);
|
--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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default async function RootLayout({
|
|||||||
<SessionProviderWrapper>
|
<SessionProviderWrapper>
|
||||||
<ErrorReporter />
|
<ErrorReporter />
|
||||||
<DirectionInitializer />
|
<DirectionInitializer />
|
||||||
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} />
|
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} />
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</SessionProviderWrapper>
|
</SessionProviderWrapper>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
|
|||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { Note } from '@/lib/types'
|
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 { NoteInput } from '@/components/note-input'
|
||||||
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
|
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
|
||||||
import { NotesViewToggle } from '@/components/notes-view-toggle'
|
import { NotesViewToggle } from '@/components/notes-view-toggle'
|
||||||
@@ -165,6 +165,33 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
|
|
||||||
useReminderCheck(notes)
|
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
|
// Listen for global label deletion and immediately update local state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
@@ -321,6 +348,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
|
<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>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
||||||
@@ -351,6 +379,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
<FileText className="w-8 h-8 text-primary" />
|
<FileText className="w-8 h-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
|
<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>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
||||||
|
|||||||
@@ -218,6 +218,13 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
|
|
||||||
// Local color state so color persists after transition ends
|
// Local color state so color persists after transition ends
|
||||||
const [localColor, setLocalColor] = useState(note.color)
|
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
|
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) => {
|
const handleCheckItem = async (checkItemId: string) => {
|
||||||
if (note.type === 'checklist' && Array.isArray(note.checkItems)) {
|
if (note.type === 'checklist') {
|
||||||
const updatedItems = note.checkItems.map(item =>
|
const currentItems = localCheckItems || note.checkItems || []
|
||||||
|
const updatedItems = currentItems.map(item =>
|
||||||
item.id === checkItemId ? { ...item, checked: !item.checked } : item
|
item.id === checkItemId ? { ...item, checked: !item.checked } : item
|
||||||
)
|
)
|
||||||
|
setLocalCheckItems(updatedItems) // instant visual update, survives transition
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
addOptimisticNote({ checkItems: updatedItems })
|
addOptimisticNote({ checkItems: updatedItems })
|
||||||
await updateNote(note.id, { 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 */}
|
{/* Content */}
|
||||||
{optimisticNote.type === 'checklist' ? (
|
{optimisticNote.type === 'checklist' ? (
|
||||||
<NoteChecklist
|
<NoteChecklist
|
||||||
items={optimisticNote.checkItems || []}
|
items={localCheckItems || optimisticNote.checkItems || []}
|
||||||
onToggleItem={handleCheckItem}
|
onToggleItem={handleCheckItem}
|
||||||
/>
|
/>
|
||||||
) : optimisticNote.type === 'richtext' ? (
|
) : optimisticNote.type === 'richtext' ? (
|
||||||
|
|||||||
@@ -497,8 +497,14 @@ export function NoteInlineEditor({
|
|||||||
value={noteType}
|
value={noteType}
|
||||||
onChange={(newType) => {
|
onChange={(newType) => {
|
||||||
setNoteType(newType)
|
setNoteType(newType)
|
||||||
if (newType !== 'markdown') setShowMarkdownPreview(false)
|
if (newType === 'markdown') setShowMarkdownPreview(true)
|
||||||
onChange?.(note.id, { isMarkdown: newType === 'markdown' })
|
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
|
compact
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
useSortable,
|
useSortable,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
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 { cn } from '@/lib/utils'
|
||||||
import { NoteInlineEditor } from '@/components/note-inline-editor'
|
import { NoteInlineEditor } from '@/components/note-inline-editor'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
@@ -737,11 +738,13 @@ export function NotesTabsView({
|
|||||||
const selected = items.find((n) => n.id === selectedId) ?? null
|
const selected = items.find((n) => n.id === selectedId) ?? null
|
||||||
const colorKey = selected ? getColorKey(selected) : 'default'
|
const colorKey = selected ? getColorKey(selected) : 'default'
|
||||||
|
|
||||||
const handleCreateNote = () => {
|
const handleCreateNote = (noteType: NoteType = 'richtext') => {
|
||||||
startCreating(async () => {
|
startCreating(async () => {
|
||||||
try {
|
try {
|
||||||
const newNote = await createNote({
|
const newNote = await createNote({
|
||||||
content: '',
|
content: noteType === 'checklist' ? '' : '',
|
||||||
|
type: noteType,
|
||||||
|
checkItems: noteType === 'checklist' ? [{ id: Date.now().toString(), text: '', checked: false }] : undefined,
|
||||||
title: undefined,
|
title: undefined,
|
||||||
notebookId: currentNotebookId || undefined,
|
notebookId: currentNotebookId || undefined,
|
||||||
skipRevalidation: true
|
skipRevalidation: true
|
||||||
@@ -843,19 +846,32 @@ export function NotesTabsView({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* New note button */}
|
{/* New note button — dropdown to choose type */}
|
||||||
<Button
|
<DropdownMenu>
|
||||||
variant="ghost"
|
<DropdownMenuTrigger asChild>
|
||||||
size="sm"
|
<Button
|
||||||
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
|
variant="ghost"
|
||||||
onClick={handleCreateNote}
|
size="sm"
|
||||||
disabled={isCreating}
|
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
|
||||||
title={t('notes.newNote')}
|
disabled={isCreating}
|
||||||
>
|
title={t('notes.newNote')}
|
||||||
{isCreating
|
>
|
||||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
{isCreating
|
||||||
: <Plus className="h-4 w-4" />}
|
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
</Button>
|
: <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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover'
|
} from '@/components/ui/popover'
|
||||||
import { getPendingShareRequests, respondToShareRequest, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
|
import { getPendingShareRequests, respondToShareRequest, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
|
||||||
|
import { getUnreadNotifications, markNotificationRead, markAllNotificationsRead, type AppNotification } from '@/app/actions/notifications'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
|
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -47,19 +49,23 @@ interface ReminderNote {
|
|||||||
export function NotificationPanel() {
|
export function NotificationPanel() {
|
||||||
const { triggerRefresh } = useNoteRefreshOptional()
|
const { triggerRefresh } = useNoteRefreshOptional()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const router = useRouter()
|
||||||
const [requests, setRequests] = useState<ShareRequest[]>([])
|
const [requests, setRequests] = useState<ShareRequest[]>([])
|
||||||
const [reminders, setReminders] = useState<ReminderNote[]>([])
|
const [reminders, setReminders] = useState<ReminderNote[]>([])
|
||||||
|
const [appNotifications, setAppNotifications] = useState<AppNotification[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [shareData, reminderData] = await Promise.all([
|
const [shareData, reminderData, notifData] = await Promise.all([
|
||||||
getPendingShareRequests(),
|
getPendingShareRequests(),
|
||||||
getNotesWithReminders(),
|
getNotesWithReminders(),
|
||||||
|
getUnreadNotifications(),
|
||||||
])
|
])
|
||||||
setRequests(shareData as any)
|
setRequests(shareData as any)
|
||||||
setReminders((reminderData as any) || [])
|
setReminders((reminderData as any) || [])
|
||||||
|
setAppNotifications(notifData || [])
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to load notifications:', error)
|
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 overdueReminders = activeReminders.filter(r => new Date(r.reminder!) < now)
|
||||||
const upcomingReminders = 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) => {
|
const handleAccept = async (shareId: string) => {
|
||||||
try {
|
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 (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
@@ -149,11 +165,22 @@ export function NotificationPanel() {
|
|||||||
<Bell className="h-4 w-4 text-primary dark:text-primary-foreground" />
|
<Bell className="h-4 w-4 text-primary dark:text-primary-foreground" />
|
||||||
<span className="font-semibold text-sm">{t('notification.notifications')}</span>
|
<span className="font-semibold text-sm">{t('notification.notifications')}</span>
|
||||||
</div>
|
</div>
|
||||||
{pendingCount > 0 && (
|
<div className="flex items-center gap-2">
|
||||||
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
|
{appNotifications.length > 0 && (
|
||||||
{pendingCount}
|
<button
|
||||||
</Badge>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -168,6 +195,65 @@ export function NotificationPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<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 */}
|
{/* Overdue reminders */}
|
||||||
{overdueReminders.map((note) => (
|
{overdueReminders.map((note) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { useEffect } from 'react'
|
|||||||
interface ThemeInitializerProps {
|
interface ThemeInitializerProps {
|
||||||
theme?: string
|
theme?: string
|
||||||
fontSize?: string
|
fontSize?: string
|
||||||
|
fontFamily?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeInitializer({ theme, fontSize }: ThemeInitializerProps) {
|
export function ThemeInitializer({ theme, fontSize, fontFamily }: ThemeInitializerProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
// Helper to apply theme
|
// Helper to apply theme
|
||||||
@@ -77,7 +78,20 @@ export function ThemeInitializer({ theme, fontSize }: ThemeInitializerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyFontSize(fontSize)
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function Checkbox({
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ interface ComboboxProps {
|
|||||||
emptyMessage?: string
|
emptyMessage?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
/** When true, the search text can be submitted as a custom value not in the options list */
|
||||||
|
allowCustomValue?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Combobox({
|
export function Combobox({
|
||||||
@@ -34,6 +36,7 @@ export function Combobox({
|
|||||||
emptyMessage = 'No results found.',
|
emptyMessage = 'No results found.',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
|
allowCustomValue = false,
|
||||||
}: ComboboxProps) {
|
}: ComboboxProps) {
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false)
|
||||||
const [search, setSearch] = React.useState('')
|
const [search, setSearch] = React.useState('')
|
||||||
@@ -56,6 +59,14 @@ export function Combobox({
|
|||||||
setSearch('')
|
setSearch('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleConfirmCustom = () => {
|
||||||
|
if (allowCustomValue && search.trim()) {
|
||||||
|
onChange(search.trim())
|
||||||
|
setOpen(false)
|
||||||
|
setSearch('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) setSearch('') }}>
|
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) setSearch('') }}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -85,14 +96,26 @@ export function Combobox({
|
|||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleConfirmCustom() } }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-60 overflow-y-auto p-1">
|
<div className="max-h-60 overflow-y-auto p-1">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
allowCustomValue && search.trim() ? (
|
||||||
{emptyMessage}
|
<button
|
||||||
</div>
|
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) => {
|
filtered.map((option) => {
|
||||||
const isSelected = option.value === value
|
const isSelected = option.value === value
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
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]",
|
"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",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ services:
|
|||||||
# Application (IMPORTANT: Change these!)
|
# Application (IMPORTANT: Change these!)
|
||||||
- NEXTAUTH_URL=http://your-domain.com:3000
|
- NEXTAUTH_URL=http://your-domain.com:3000
|
||||||
- NEXTAUTH_SECRET=change-this-to-a-random-secret-string
|
- 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
|
# Disable Next.js telemetry
|
||||||
- NEXT_TELEMETRY_DISABLED=1
|
- NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Memento Note — Docker Entrypoint
|
# Memento Note — Docker Entrypoint
|
||||||
# Automatic DB migration with backup, retry, and cleanup.
|
# Reliable DB migration for fresh installs and upgrades.
|
||||||
# Safe for production: no data loss on image updates.
|
# ============================================================
|
||||||
|
# Strategy:
|
||||||
|
# 1. prisma migrate deploy → fresh DB, normal upgrades
|
||||||
|
# 2. prisma db push → fallback for DBs without migration history
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -26,13 +29,12 @@ case "$DATABASE_URL" in
|
|||||||
esac
|
esac
|
||||||
log "Database type: $DB_TYPE"
|
log "Database type: $DB_TYPE"
|
||||||
|
|
||||||
# --- Wait for database to be reachable ---
|
# --- Ensure data directories exist ---
|
||||||
wait_for_db() {
|
mkdir -p "$BACKUP_DIR" 2>/dev/null || true
|
||||||
if [ "$DB_TYPE" = "sqlite" ]; then
|
mkdir -p /app/data/uploads/notes 2>/dev/null || true
|
||||||
log "SQLite — no connection check needed."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
# --- Wait for database to be reachable ---
|
||||||
|
if [ "$DB_TYPE" != "sqlite" ]; then
|
||||||
log "Waiting for database connection..."
|
log "Waiting for database connection..."
|
||||||
i=0
|
i=0
|
||||||
while [ "$i" -lt "$DB_WAIT_RETRIES" ]; do
|
while [ "$i" -lt "$DB_WAIT_RETRIES" ]; do
|
||||||
@@ -45,107 +47,74 @@ wait_for_db() {
|
|||||||
s.on('error', () => process.exit(1));
|
s.on('error', () => process.exit(1));
|
||||||
" 2>/dev/null; then
|
" 2>/dev/null; then
|
||||||
log "Database is reachable."
|
log "Database is reachable."
|
||||||
return 0
|
break
|
||||||
fi
|
fi
|
||||||
i=$((i + 1))
|
i=$((i + 1))
|
||||||
log " Attempt $i/$DB_WAIT_RETRIES — retrying in ${DB_WAIT_INTERVAL}s..."
|
log " Attempt $i/$DB_WAIT_RETRIES — retrying in ${DB_WAIT_INTERVAL}s..."
|
||||||
sleep "$DB_WAIT_INTERVAL"
|
sleep "$DB_WAIT_INTERVAL"
|
||||||
done
|
done
|
||||||
|
|
||||||
err "Database unreachable after $((DB_WAIT_RETRIES * DB_WAIT_INTERVAL)) seconds. Aborting."
|
if [ "$i" -ge "$DB_WAIT_RETRIES" ]; then
|
||||||
exit 1
|
err "Database unreachable after $((DB_WAIT_RETRIES * DB_WAIT_INTERVAL)) seconds."
|
||||||
}
|
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
|
|
||||||
fi
|
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
|
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
|
sleep 60
|
||||||
while true; do
|
while true; do
|
||||||
@@ -163,6 +132,6 @@ fi
|
|||||||
done
|
done
|
||||||
) &
|
) &
|
||||||
|
|
||||||
# Step 5: Start server
|
# --- Start server ---
|
||||||
log "Starting server..."
|
log "Starting server..."
|
||||||
exec node server.js
|
exec node server.js
|
||||||
|
|||||||
@@ -3,7 +3,36 @@ import { OllamaProvider } from './providers/ollama';
|
|||||||
import { CustomOpenAIProvider } from './providers/custom-openai';
|
import { CustomOpenAIProvider } from './providers/custom-openai';
|
||||||
import { AIProvider } from './types';
|
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 {
|
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
|
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
|
// Ensure baseUrl doesn't end with /api, we'll add it in OllamaProvider
|
||||||
if (baseUrl.endsWith('/api')) {
|
if (baseUrl.endsWith('/api')) {
|
||||||
baseUrl = baseUrl.slice(0, -4); // Remove /api
|
baseUrl = baseUrl.slice(0, -4);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new OllamaProvider(baseUrl, modelName, embeddingModelName);
|
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 {
|
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');
|
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 {
|
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');
|
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 {
|
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);
|
return createDeepSeekProvider(config, modelName, embeddingModelName);
|
||||||
case 'openrouter':
|
case 'openrouter':
|
||||||
return createOpenRouterProvider(config, modelName, embeddingModelName);
|
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:
|
default:
|
||||||
return createOllamaProvider(config, modelName, embeddingModelName, ollamaBaseUrl);
|
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 {
|
export function getTagsProvider(config?: Record<string, string>): AIProvider {
|
||||||
// Check database config first, then environment variables
|
|
||||||
const providerType = (
|
const providerType = (
|
||||||
config?.AI_PROVIDER_TAGS ||
|
config?.AI_PROVIDER_TAGS ||
|
||||||
config?.AI_PROVIDER_EMBEDDING ||
|
config?.AI_PROVIDER_EMBEDDING ||
|
||||||
config?.AI_PROVIDER ||
|
config?.AI_PROVIDER ||
|
||||||
process.env.AI_PROVIDER_TAGS ||
|
process.env.AI_PROVIDER_TAGS ||
|
||||||
process.env.AI_PROVIDER_EMBEDDING ||
|
process.env.AI_PROVIDER_EMBEDDING ||
|
||||||
process.env.AI_PROVIDER
|
process.env.AI_PROVIDER
|
||||||
);
|
);
|
||||||
|
|
||||||
// If no provider is configured, throw a clear error
|
|
||||||
if (!providerType) {
|
if (!providerType) {
|
||||||
console.error('[getTagsProvider] FATAL: No provider configured. Config received:', config);
|
console.error('[getTagsProvider] FATAL: No provider configured. Config received:', config);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AI_PROVIDER_TAGS is not configured. Please set it in the admin settings or environment variables. ' +
|
'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 {
|
export function getEmbeddingsProvider(config?: Record<string, string>): AIProvider {
|
||||||
// Check database config first, then environment variables
|
|
||||||
const providerType = (
|
const providerType = (
|
||||||
config?.AI_PROVIDER_EMBEDDING ||
|
config?.AI_PROVIDER_EMBEDDING ||
|
||||||
config?.AI_PROVIDER_TAGS ||
|
config?.AI_PROVIDER_TAGS ||
|
||||||
config?.AI_PROVIDER ||
|
config?.AI_PROVIDER ||
|
||||||
process.env.AI_PROVIDER_EMBEDDING ||
|
process.env.AI_PROVIDER_EMBEDDING ||
|
||||||
process.env.AI_PROVIDER_TAGS ||
|
process.env.AI_PROVIDER_TAGS ||
|
||||||
process.env.AI_PROVIDER
|
process.env.AI_PROVIDER
|
||||||
);
|
);
|
||||||
|
|
||||||
// If no provider is configured, throw a clear error
|
|
||||||
if (!providerType) {
|
if (!providerType) {
|
||||||
console.error('[getEmbeddingsProvider] FATAL: No provider configured. Config received:', config);
|
console.error('[getEmbeddingsProvider] FATAL: No provider configured. Config received:', config);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AI_PROVIDER_EMBEDDING is not configured. Please set it in the admin settings or environment variables. ' +
|
'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 {
|
export function getChatProvider(config?: Record<string, string>): AIProvider {
|
||||||
// Check database config first, then environment variables
|
|
||||||
// Fallback cascade: chat -> tags -> embeddings
|
|
||||||
const providerType = (
|
const providerType = (
|
||||||
config?.AI_PROVIDER_CHAT ||
|
config?.AI_PROVIDER_CHAT ||
|
||||||
config?.AI_PROVIDER_TAGS ||
|
config?.AI_PROVIDER_TAGS ||
|
||||||
@@ -153,12 +221,11 @@ export function getChatProvider(config?: Record<string, string>): AIProvider {
|
|||||||
process.env.AI_PROVIDER
|
process.env.AI_PROVIDER
|
||||||
);
|
);
|
||||||
|
|
||||||
// If no provider is configured, throw a clear error
|
|
||||||
if (!providerType) {
|
if (!providerType) {
|
||||||
console.error('[getChatProvider] FATAL: No provider configured. Config received:', config);
|
console.error('[getChatProvider] FATAL: No provider configured. Config received:', config);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AI_PROVIDER_CHAT is not configured. Please set it in the admin settings or environment variables. ' +
|
'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);
|
return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export for use by admin settings form and deploy scripts
|
||||||
|
export { PROVIDER_DEFAULTS, getProviderConfigKeys };
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { sendEmail } from '@/lib/mail'
|
|||||||
import { getAgentEmailTemplate } from '@/lib/agent-email-template'
|
import { getAgentEmailTemplate } from '@/lib/agent-email-template'
|
||||||
import { extractAndDownloadImages, extractImageUrlsFromHtml, downloadImage } from '../tools/extract-images'
|
import { extractAndDownloadImages, extractImageUrlsFromHtml, downloadImage } from '../tools/extract-images'
|
||||||
import { calculateNextRun } from '@/lib/agents/schedule'
|
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 for side-effect registration
|
||||||
import '../tools'
|
import '../tools'
|
||||||
@@ -30,6 +32,32 @@ export interface AgentExecutionResult {
|
|||||||
error?: string
|
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 ---
|
// --- Language Helper ---
|
||||||
|
|
||||||
type Lang = 'fr' | 'en'
|
type Lang = 'fr' | 'en'
|
||||||
@@ -324,15 +352,11 @@ async function executeScraperAgent(
|
|||||||
|
|
||||||
const title = await generateTitle(fullContent, agent.name, lang)
|
const title = await generateTitle(fullContent, agent.name, lang)
|
||||||
|
|
||||||
const note = await prisma.note.create({
|
const note = await createAgentNote({
|
||||||
data: {
|
title,
|
||||||
title,
|
content: fullContent,
|
||||||
content: fullContent,
|
userId: agent.userId,
|
||||||
isMarkdown: true,
|
notebookId: agent.targetNotebookId,
|
||||||
autoGenerated: true,
|
|
||||||
userId: agent.userId,
|
|
||||||
notebookId: agent.targetNotebookId,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const logMsg = lang === 'fr'
|
const logMsg = lang === 'fr'
|
||||||
@@ -424,15 +448,11 @@ async function executeResearcherAgent(
|
|||||||
|
|
||||||
const title = await generateTitle(fullContent, agent.name, lang)
|
const title = await generateTitle(fullContent, agent.name, lang)
|
||||||
|
|
||||||
const note = await prisma.note.create({
|
const note = await createAgentNote({
|
||||||
data: {
|
title,
|
||||||
title,
|
content: fullContent,
|
||||||
content: fullContent,
|
userId: agent.userId,
|
||||||
isMarkdown: true,
|
notebookId: agent.targetNotebookId,
|
||||||
autoGenerated: true,
|
|
||||||
userId: agent.userId,
|
|
||||||
notebookId: agent.targetNotebookId,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const logMsg = lang === 'fr'
|
const logMsg = lang === 'fr'
|
||||||
@@ -526,15 +546,11 @@ async function executeMonitorAgent(
|
|||||||
|
|
||||||
const title = await generateTitle(fullContent, agent.name, lang)
|
const title = await generateTitle(fullContent, agent.name, lang)
|
||||||
|
|
||||||
const note = await prisma.note.create({
|
const note = await createAgentNote({
|
||||||
data: {
|
title,
|
||||||
title,
|
content: fullContent,
|
||||||
content: fullContent,
|
userId: agent.userId,
|
||||||
isMarkdown: true,
|
notebookId: agent.targetNotebookId,
|
||||||
autoGenerated: true,
|
|
||||||
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}`
|
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 title = await generateTitle(fullContent, agent.name, lang)
|
||||||
|
|
||||||
const note = await prisma.note.create({
|
const note = await createAgentNote({
|
||||||
data: {
|
title,
|
||||||
title,
|
content: fullContent,
|
||||||
content: fullContent,
|
userId: agent.userId,
|
||||||
isMarkdown: true,
|
notebookId: agent.targetNotebookId,
|
||||||
autoGenerated: true,
|
|
||||||
userId: agent.userId,
|
|
||||||
notebookId: agent.targetNotebookId,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const toolLogData = JSON.stringify([{
|
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 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 title = await generateTitle(fullContent, agent.name, lang)
|
||||||
|
|
||||||
const note = await prisma.note.create({
|
const note = await createAgentNote({
|
||||||
data: {
|
title,
|
||||||
title,
|
content: fullContent,
|
||||||
content: fullContent,
|
userId: agent.userId,
|
||||||
isMarkdown: true,
|
notebookId: agent.targetNotebookId || null,
|
||||||
autoGenerated: true,
|
|
||||||
userId: agent.userId,
|
|
||||||
notebookId: agent.targetNotebookId || null,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
noteId = note.id
|
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
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown 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 }
|
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 }
|
return { success: false, actionId: action.id, error: message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,23 +19,43 @@ export interface ImageDescriptionResult {
|
|||||||
|
|
||||||
const UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads')
|
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\/(.+)/)
|
const localMatch = imageUrl.match(/\/uploads\/(.+)/)
|
||||||
if (localMatch) {
|
if (localMatch) {
|
||||||
const filePath = path.join(UPLOAD_DIR, localMatch[1])
|
// Try reading from filesystem first
|
||||||
const buffer = await readFile(filePath)
|
try {
|
||||||
const ext = path.extname(imageUrl).toLowerCase()
|
const filePath = path.join(UPLOAD_DIR, localMatch[1])
|
||||||
const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'
|
const buffer = await readFile(filePath)
|
||||||
return `data:${mime};base64,${buffer.toString('base64')}`
|
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
|
// Remote URL — fetch and convert
|
||||||
const res = await fetch(imageUrl)
|
try {
|
||||||
if (!res.ok) throw new Error(`Failed to fetch image: ${imageUrl}`)
|
const res = await fetch(imageUrl)
|
||||||
const contentType = res.headers.get('content-type') || 'image/jpeg'
|
if (!res.ok) return null
|
||||||
const arrayBuffer = await res.arrayBuffer()
|
const contentType = res.headers.get('content-type') || 'image/jpeg'
|
||||||
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
const arrayBuffer = await res.arrayBuffer()
|
||||||
return `data:${contentType};base64,${base64}`
|
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
||||||
|
return `data:${contentType};base64,${base64}`
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function describeImages(
|
export async function describeImages(
|
||||||
@@ -55,8 +75,9 @@ export async function describeImages(
|
|||||||
}
|
}
|
||||||
const langName = langMap[language] || 'English'
|
const langName = langMap[language] || 'English'
|
||||||
|
|
||||||
// Resolve all images as base64 data URLs (same approach as the chat route)
|
// Resolve all images as base64 data URLs — skip any that can't be found
|
||||||
const imageDataUrls = await Promise.all(imageUrls.map(url => resolveImageAsBase64(url)))
|
const resolved = await Promise.all(imageUrls.map(url => resolveImageAsBase64(url)))
|
||||||
|
const imageDataUrls = resolved.filter((d): d is string => d !== null)
|
||||||
|
|
||||||
if (isTitleMode) {
|
if (isTitleMode) {
|
||||||
const prompt = imageUrls.length === 1
|
const prompt = imageUrls.length === 1
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { tool } from 'ai'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { toolRegistry } from './registry'
|
import { toolRegistry } from './registry'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { markdownToHtml } from '@/lib/markdown-to-html'
|
||||||
|
|
||||||
// --- note_read ---
|
// --- note_read ---
|
||||||
toolRegistry.register({
|
toolRegistry.register({
|
||||||
@@ -50,11 +51,13 @@ toolRegistry.register({
|
|||||||
}),
|
}),
|
||||||
execute: async ({ title, content, notebookId, images }) => {
|
execute: async ({ title, content, notebookId, images }) => {
|
||||||
try {
|
try {
|
||||||
|
const htmlContent = markdownToHtml(content)
|
||||||
const note = await prisma.note.create({
|
const note = await prisma.note.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
content,
|
content: htmlContent,
|
||||||
isMarkdown: true,
|
type: 'richtext',
|
||||||
|
isMarkdown: false,
|
||||||
autoGenerated: true,
|
autoGenerated: true,
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
notebookId: notebookId || null,
|
notebookId: notebookId || null,
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
import prisma from './prisma'
|
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
|
// Environment variable fallbacks for system config keys
|
||||||
const ENV_FALLBACKS: Record<string, string> = {
|
const ENV_FALLBACKS: Record<string, string> = {
|
||||||
// AI providers (openrouter → custom)
|
// AI providers
|
||||||
AI_PROVIDER_TAGS: normalizeProvider(process.env.AI_PROVIDER_TAGS),
|
AI_PROVIDER_TAGS: process.env.AI_PROVIDER_TAGS || '',
|
||||||
AI_MODEL_TAGS: process.env.AI_MODEL_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_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 || '',
|
AI_MODEL_CHAT: process.env.AI_MODEL_CHAT || '',
|
||||||
|
// Ollama
|
||||||
OLLAMA_BASE_URL: process.env.OLLAMA_BASE_URL || '',
|
OLLAMA_BASE_URL: process.env.OLLAMA_BASE_URL || '',
|
||||||
|
// OpenAI
|
||||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
|
||||||
|
// Custom OpenAI
|
||||||
CUSTOM_OPENAI_API_KEY: process.env.CUSTOM_OPENAI_API_KEY || '',
|
CUSTOM_OPENAI_API_KEY: process.env.CUSTOM_OPENAI_API_KEY || '',
|
||||||
CUSTOM_OPENAI_BASE_URL: process.env.CUSTOM_OPENAI_BASE_URL || '',
|
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
|
||||||
EMAIL_PROVIDER: process.env.EMAIL_PROVIDER || (process.env.RESEND_API_KEY ? 'resend' : 'smtp'),
|
EMAIL_PROVIDER: process.env.EMAIL_PROVIDER || (process.env.RESEND_API_KEY ? 'resend' : 'smtp'),
|
||||||
RESEND_API_KEY: process.env.RESEND_API_KEY || '',
|
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)
|
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
|
// Merge: DB values take precedence, env vars as fallback
|
||||||
const merged = { ...ENV_FALLBACKS, ...dbConfig }
|
const merged = { ...ENV_FALLBACKS, ...dbConfig }
|
||||||
return merged
|
return merged
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Detect user's preferred language from their existing notes
|
* Detect user's preferred language.
|
||||||
* Uses a single DB-level GROUP BY query — no note content is loaded
|
* 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'
|
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'])
|
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(
|
const getCachedUserLanguage = unstable_cache(
|
||||||
async (userId: string): Promise<SupportedLanguage> => {
|
async (userId: string): Promise<SupportedLanguage | null> => {
|
||||||
try {
|
try {
|
||||||
// Single aggregated query — no notes are fetched, only language counts
|
|
||||||
const result = await prisma.note.groupBy({
|
const result = await prisma.note.groupBy({
|
||||||
by: ['language'],
|
by: ['language'],
|
||||||
where: {
|
where: {
|
||||||
@@ -33,22 +63,39 @@ const getCachedUserLanguage = unstable_cache(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'en'
|
return null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error detecting user language:', error)
|
console.error('Error detecting user language from notes:', error)
|
||||||
return 'en'
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
['user-language'],
|
['user-language'],
|
||||||
{ tags: ['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()
|
const session = await auth()
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
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'
|
||||||
}
|
}
|
||||||
|
|||||||
225
memento-note/lib/markdown-to-html.ts
Normal file
225
memento-note/lib/markdown-to-html.ts
Normal 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, '&')
|
||||||
|
html = html.replace(/</g, '<')
|
||||||
|
html = html.replace(/>/g, '>')
|
||||||
|
|
||||||
|
// Code blocks (``` ... ```) — protect from further processing
|
||||||
|
const codeBlocks: string[] = []
|
||||||
|
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
|
||||||
|
const idx = codeBlocks.length
|
||||||
|
codeBlocks.push(`<pre><code class="language-${lang || 'plaintext'}">${code.trim()}</code></pre>`)
|
||||||
|
return `%%CODEBLOCK_${idx}%%`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inline code (`...`)
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
|
||||||
|
// Images ()
|
||||||
|
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(/^>\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')
|
||||||
|
}
|
||||||
@@ -837,6 +837,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
||||||
|
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||||
|
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||||
|
"providerMistralOption": "🌀 Mistral AI",
|
||||||
|
"providerZAIOption": "✨ Z.AI",
|
||||||
|
"providerLMStudioOption": "🖥️ LM Studio (محلي)",
|
||||||
"bestValue": "أفضل قيمة",
|
"bestValue": "أفضل قيمة",
|
||||||
"bestQuality": "أفضل جودة",
|
"bestQuality": "أفضل جودة",
|
||||||
"saved": "(تم الحفظ)",
|
"saved": "(تم الحفظ)",
|
||||||
@@ -1175,7 +1180,11 @@
|
|||||||
"notesViewLabel": "عرض الملاحظات",
|
"notesViewLabel": "عرض الملاحظات",
|
||||||
"notesViewTabs": "علامات تبويب (نمط OneNote)",
|
"notesViewTabs": "علامات تبويب (نمط OneNote)",
|
||||||
"notesViewMasonry": "بطاقات (شبكة)",
|
"notesViewMasonry": "بطاقات (شبكة)",
|
||||||
"selectTheme": "Select theme"
|
"selectTheme": "Select theme",
|
||||||
|
"fontFamilyLabel": "عائلة الخطوط",
|
||||||
|
"fontFamilyDescription": "اختر الخط المستخدم في جميع أنحاء التطبيق",
|
||||||
|
"selectFontFamily": "Inter مُحسّن لسهولة القراءة، النظام يستخدم الخط الأصلي لنظام التشغيل",
|
||||||
|
"fontSystem": "الخط الافتراضي للنظام"
|
||||||
},
|
},
|
||||||
"generalSettings": {
|
"generalSettings": {
|
||||||
"title": "الإعدادات العامة",
|
"title": "الإعدادات العامة",
|
||||||
|
|||||||
@@ -837,6 +837,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
||||||
|
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||||
|
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||||
|
"providerMistralOption": "🌀 Mistral AI",
|
||||||
|
"providerZAIOption": "✨ Z.AI",
|
||||||
|
"providerLMStudioOption": "🖥️ LM Studio (Lokal)",
|
||||||
"bestValue": "Bestes Preis-Leistungs-Verhältnis",
|
"bestValue": "Bestes Preis-Leistungs-Verhältnis",
|
||||||
"bestQuality": "Beste Qualität",
|
"bestQuality": "Beste Qualität",
|
||||||
"saved": "(Gespeichert)",
|
"saved": "(Gespeichert)",
|
||||||
@@ -1175,7 +1180,11 @@
|
|||||||
"notesViewLabel": "Notizen-Ansicht",
|
"notesViewLabel": "Notizen-Ansicht",
|
||||||
"notesViewTabs": "Tabs (OneNote-Stil)",
|
"notesViewTabs": "Tabs (OneNote-Stil)",
|
||||||
"notesViewMasonry": "Karten (Raster)",
|
"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": {
|
"generalSettings": {
|
||||||
"title": "Allgemeine Einstellungen",
|
"title": "Allgemeine Einstellungen",
|
||||||
|
|||||||
@@ -841,6 +841,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
||||||
|
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||||
|
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||||
|
"providerMistralOption": "🌀 Mistral AI",
|
||||||
|
"providerZAIOption": "✨ Z.AI",
|
||||||
|
"providerLMStudioOption": "🖥️ LM Studio (Local)",
|
||||||
"bestValue": "Best value",
|
"bestValue": "Best value",
|
||||||
"bestQuality": "Best quality",
|
"bestQuality": "Best quality",
|
||||||
"saved": "(Saved)",
|
"saved": "(Saved)",
|
||||||
@@ -1179,7 +1184,11 @@
|
|||||||
"notesViewLabel": "Notes layout",
|
"notesViewLabel": "Notes layout",
|
||||||
"notesViewTabs": "Tabs (OneNote-style)",
|
"notesViewTabs": "Tabs (OneNote-style)",
|
||||||
"notesViewMasonry": "Cards (grid)",
|
"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": {
|
"generalSettings": {
|
||||||
"title": "General Settings",
|
"title": "General Settings",
|
||||||
|
|||||||
@@ -837,6 +837,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
||||||
|
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||||
|
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||||
|
"providerMistralOption": "🌀 Mistral AI",
|
||||||
|
"providerZAIOption": "✨ Z.AI",
|
||||||
|
"providerLMStudioOption": "🖥️ LM Studio (Local)",
|
||||||
"bestValue": "Mejor relación calidad/precio",
|
"bestValue": "Mejor relación calidad/precio",
|
||||||
"bestQuality": "Mejor calidad",
|
"bestQuality": "Mejor calidad",
|
||||||
"saved": "(Guardado)",
|
"saved": "(Guardado)",
|
||||||
@@ -1175,7 +1180,11 @@
|
|||||||
"notesViewLabel": "Vista de notas",
|
"notesViewLabel": "Vista de notas",
|
||||||
"notesViewTabs": "Pestañas (estilo OneNote)",
|
"notesViewTabs": "Pestañas (estilo OneNote)",
|
||||||
"notesViewMasonry": "Tarjetas (cuadrícula)",
|
"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": {
|
"generalSettings": {
|
||||||
"title": "Configuración general",
|
"title": "Configuración general",
|
||||||
|
|||||||
@@ -837,6 +837,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (محلی و رایگان)",
|
"providerOllamaOption": "🦙 Ollama (محلی و رایگان)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 سفارشی سازگار با OpenAI",
|
"providerCustomOption": "🔧 سفارشی سازگار با OpenAI",
|
||||||
|
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||||
|
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||||
|
"providerMistralOption": "🌀 Mistral AI",
|
||||||
|
"providerZAIOption": "✨ Z.AI",
|
||||||
|
"providerLMStudioOption": "🖥️ LM Studio (محلی)",
|
||||||
"bestValue": "بهترین ارزش",
|
"bestValue": "بهترین ارزش",
|
||||||
"bestQuality": "بهترین کیفیت",
|
"bestQuality": "بهترین کیفیت",
|
||||||
"saved": "(ذخیره شد)",
|
"saved": "(ذخیره شد)",
|
||||||
@@ -1175,7 +1180,11 @@
|
|||||||
"notesViewLabel": "چیدمان یادداشتها",
|
"notesViewLabel": "چیدمان یادداشتها",
|
||||||
"notesViewTabs": "زبانهها (سبک OneNote)",
|
"notesViewTabs": "زبانهها (سبک OneNote)",
|
||||||
"notesViewMasonry": "کارتها (شبکهای)",
|
"notesViewMasonry": "کارتها (شبکهای)",
|
||||||
"selectTheme": "Select theme"
|
"selectTheme": "Select theme",
|
||||||
|
"fontFamilyLabel": "خانواده فونت",
|
||||||
|
"fontFamilyDescription": "فونت استفاده شده در سراسر برنامه را انتخاب کنید",
|
||||||
|
"selectFontFamily": "Inter برای خوانایی بهینه شده است، سیستم از فونت بومی سیستمعامل شما استفاده میکند",
|
||||||
|
"fontSystem": "فونت پیشفرض سیستم"
|
||||||
},
|
},
|
||||||
"generalSettings": {
|
"generalSettings": {
|
||||||
"title": "تنظیمات عمومی",
|
"title": "تنظیمات عمومی",
|
||||||
|
|||||||
@@ -841,6 +841,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (Local & Gratuit)",
|
"providerOllamaOption": "🦙 Ollama (Local & Gratuit)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 Custom Compatible OpenAI",
|
"providerCustomOption": "🔧 Custom Compatible OpenAI",
|
||||||
|
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||||
|
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||||
|
"providerMistralOption": "🌀 Mistral AI",
|
||||||
|
"providerZAIOption": "✨ Z.AI",
|
||||||
|
"providerLMStudioOption": "🖥️ LM Studio (Local)",
|
||||||
"bestValue": "Meilleur rapport qualité/prix",
|
"bestValue": "Meilleur rapport qualité/prix",
|
||||||
"bestQuality": "Meilleure qualité",
|
"bestQuality": "Meilleure qualité",
|
||||||
"saved": "(Enregistré)",
|
"saved": "(Enregistré)",
|
||||||
@@ -1179,7 +1184,11 @@
|
|||||||
"notesViewLabel": "Affichage des notes",
|
"notesViewLabel": "Affichage des notes",
|
||||||
"notesViewTabs": "Onglets (type OneNote)",
|
"notesViewTabs": "Onglets (type OneNote)",
|
||||||
"notesViewMasonry": "Cartes (grille)",
|
"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": {
|
"generalSettings": {
|
||||||
"title": "Paramètres généraux",
|
"title": "Paramètres généraux",
|
||||||
|
|||||||
@@ -837,6 +837,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
||||||
|
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||||
|
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||||
|
"providerMistralOption": "🌀 Mistral AI",
|
||||||
|
"providerZAIOption": "✨ Z.AI",
|
||||||
|
"providerLMStudioOption": "🖥️ LM Studio (स्थानीय)",
|
||||||
"bestValue": "सर्वोत्तम मूल्य",
|
"bestValue": "सर्वोत्तम मूल्य",
|
||||||
"bestQuality": "सर्वोत्तम गुणवत्ता",
|
"bestQuality": "सर्वोत्तम गुणवत्ता",
|
||||||
"saved": "(सहेजा गया)",
|
"saved": "(सहेजा गया)",
|
||||||
@@ -1175,7 +1180,11 @@
|
|||||||
"notesViewLabel": "नोट्स दृश्य",
|
"notesViewLabel": "नोट्स दृश्य",
|
||||||
"notesViewTabs": "टैब (OneNote-शैली)",
|
"notesViewTabs": "टैब (OneNote-शैली)",
|
||||||
"notesViewMasonry": "कार्ड (ग्रिड)",
|
"notesViewMasonry": "कार्ड (ग्रिड)",
|
||||||
"selectTheme": "Select theme"
|
"selectTheme": "Select theme",
|
||||||
|
"fontFamilyLabel": "फ़ॉन्ट परिवार",
|
||||||
|
"fontFamilyDescription": "पूरे ऐप में उपयोग किए जाने वाले फ़ॉन्ट का चयन करें",
|
||||||
|
"selectFontFamily": "Inter पठनीयता के लिए अनुकूलित है, सिस्टम आपके OS के मूल फ़ॉन्ट का उपयोग करता है",
|
||||||
|
"fontSystem": "सिस्टम डिफ़ॉल्ट"
|
||||||
},
|
},
|
||||||
"generalSettings": {
|
"generalSettings": {
|
||||||
"title": "सामान्य सेटिंग्स",
|
"title": "सामान्य सेटिंग्स",
|
||||||
|
|||||||
@@ -837,6 +837,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
||||||
|
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||||
|
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||||
|
"providerMistralOption": "🌀 Mistral AI",
|
||||||
|
"providerZAIOption": "✨ Z.AI",
|
||||||
|
"providerLMStudioOption": "🖥️ LM Studio (Locale)",
|
||||||
"bestValue": "Miglior rapporto qualità/prezzo",
|
"bestValue": "Miglior rapporto qualità/prezzo",
|
||||||
"bestQuality": "Miglior qualità",
|
"bestQuality": "Miglior qualità",
|
||||||
"saved": "(Salvato)",
|
"saved": "(Salvato)",
|
||||||
@@ -1175,7 +1180,11 @@
|
|||||||
"notesViewLabel": "Vista note",
|
"notesViewLabel": "Vista note",
|
||||||
"notesViewTabs": "Schede (stile OneNote)",
|
"notesViewTabs": "Schede (stile OneNote)",
|
||||||
"notesViewMasonry": "Schede (griglia)",
|
"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": {
|
"generalSettings": {
|
||||||
"title": "Impostazioni generali",
|
"title": "Impostazioni generali",
|
||||||
|
|||||||
@@ -837,6 +837,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
||||||
|
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||||
|
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||||
|
"providerMistralOption": "🌀 Mistral AI",
|
||||||
|
"providerZAIOption": "✨ Z.AI",
|
||||||
|
"providerLMStudioOption": "🖥️ LM Studio (ローカル)",
|
||||||
"bestValue": "最もコスパが良い",
|
"bestValue": "最もコスパが良い",
|
||||||
"bestQuality": "最高品質",
|
"bestQuality": "最高品質",
|
||||||
"saved": "(保存済み)",
|
"saved": "(保存済み)",
|
||||||
@@ -1175,7 +1180,11 @@
|
|||||||
"notesViewLabel": "ノートのレイアウト",
|
"notesViewLabel": "ノートのレイアウト",
|
||||||
"notesViewTabs": "タブ(OneNote風)",
|
"notesViewTabs": "タブ(OneNote風)",
|
||||||
"notesViewMasonry": "カード(グリッド)",
|
"notesViewMasonry": "カード(グリッド)",
|
||||||
"selectTheme": "Select theme"
|
"selectTheme": "Select theme",
|
||||||
|
"fontFamilyLabel": "フォントファミリー",
|
||||||
|
"fontFamilyDescription": "アプリ全体で使用するフォントを選択してください",
|
||||||
|
"selectFontFamily": "Inter は読みやすさに最適化されています。システムはOSのネイティブフォントを使用します",
|
||||||
|
"fontSystem": "システムデフォルト"
|
||||||
},
|
},
|
||||||
"generalSettings": {
|
"generalSettings": {
|
||||||
"title": "一般設定",
|
"title": "一般設定",
|
||||||
|
|||||||
@@ -837,6 +837,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (로컬 및 무료)",
|
"providerOllamaOption": "🦙 Ollama (로컬 및 무료)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 사용자 정의 OpenAI 호환",
|
"providerCustomOption": "🔧 사용자 정의 OpenAI 호환",
|
||||||
|
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||||
|
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||||
|
"providerMistralOption": "🌀 Mistral AI",
|
||||||
|
"providerZAIOption": "✨ Z.AI",
|
||||||
|
"providerLMStudioOption": "🖥️ LM Studio (로컬)",
|
||||||
"bestValue": "최고 가성비",
|
"bestValue": "최고 가성비",
|
||||||
"bestQuality": "최고 품질",
|
"bestQuality": "최고 품질",
|
||||||
"saved": "(저장됨)",
|
"saved": "(저장됨)",
|
||||||
@@ -1175,7 +1180,11 @@
|
|||||||
"notesViewLabel": "메모 레이아웃",
|
"notesViewLabel": "메모 레이아웃",
|
||||||
"notesViewTabs": "탭 (OneNote 스타일)",
|
"notesViewTabs": "탭 (OneNote 스타일)",
|
||||||
"notesViewMasonry": "카드 (그리드)",
|
"notesViewMasonry": "카드 (그리드)",
|
||||||
"selectTheme": "Select theme"
|
"selectTheme": "Select theme",
|
||||||
|
"fontFamilyLabel": "글꼴 패밀리",
|
||||||
|
"fontFamilyDescription": "앱 전체에서 사용할 글꼴을 선택하세요",
|
||||||
|
"selectFontFamily": "Inter는 가독성에 최적화되어 있으며, 시스템은 OS 기본 글꼴을 사용합니다",
|
||||||
|
"fontSystem": "시스템 기본 글꼴"
|
||||||
},
|
},
|
||||||
"generalSettings": {
|
"generalSettings": {
|
||||||
"title": "일반 설정",
|
"title": "일반 설정",
|
||||||
|
|||||||
@@ -837,6 +837,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
||||||
|
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||||
|
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||||
|
"providerMistralOption": "🌀 Mistral AI",
|
||||||
|
"providerZAIOption": "✨ Z.AI",
|
||||||
|
"providerLMStudioOption": "🖥️ LM Studio (Lokaal)",
|
||||||
"bestValue": "Beste prijs-kwaliteit",
|
"bestValue": "Beste prijs-kwaliteit",
|
||||||
"bestQuality": "Beste kwaliteit",
|
"bestQuality": "Beste kwaliteit",
|
||||||
"saved": "(Opgeslagen)",
|
"saved": "(Opgeslagen)",
|
||||||
@@ -1175,7 +1180,11 @@
|
|||||||
"notesViewLabel": "Notities weergave",
|
"notesViewLabel": "Notities weergave",
|
||||||
"notesViewTabs": "Tabbladen (OneNote-stijl)",
|
"notesViewTabs": "Tabbladen (OneNote-stijl)",
|
||||||
"notesViewMasonry": "Kaarten (raster)",
|
"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": {
|
"generalSettings": {
|
||||||
"title": "Algemene instellingen",
|
"title": "Algemene instellingen",
|
||||||
|
|||||||
@@ -837,6 +837,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (lokalny i darmowy)",
|
"providerOllamaOption": "🦙 Ollama (lokalny i darmowy)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 Niestandardowy (kompatybilny z OpenAI)",
|
"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",
|
"bestValue": "Najlepszy stosunek jakości do ceny",
|
||||||
"bestQuality": "Najwyższa jakość",
|
"bestQuality": "Najwyższa jakość",
|
||||||
"saved": "(Zapisano)",
|
"saved": "(Zapisano)",
|
||||||
@@ -1175,7 +1180,11 @@
|
|||||||
"notesViewLabel": "Układ notatek",
|
"notesViewLabel": "Układ notatek",
|
||||||
"notesViewTabs": "Karty (styl OneNote)",
|
"notesViewTabs": "Karty (styl OneNote)",
|
||||||
"notesViewMasonry": "Karty (siatka)",
|
"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": {
|
"generalSettings": {
|
||||||
"title": "Ustawienia ogólne",
|
"title": "Ustawienia ogólne",
|
||||||
|
|||||||
@@ -837,6 +837,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (Local e Gratuito)",
|
"providerOllamaOption": "🦙 Ollama (Local e Gratuito)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 Compatível com OpenAI (Personalizado)",
|
"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",
|
"bestValue": "Melhor custo-benefício",
|
||||||
"bestQuality": "Melhor qualidade",
|
"bestQuality": "Melhor qualidade",
|
||||||
"saved": "(Salvo)",
|
"saved": "(Salvo)",
|
||||||
@@ -1175,7 +1180,11 @@
|
|||||||
"notesViewLabel": "Layout das notas",
|
"notesViewLabel": "Layout das notas",
|
||||||
"notesViewTabs": "Abas (estilo OneNote)",
|
"notesViewTabs": "Abas (estilo OneNote)",
|
||||||
"notesViewMasonry": "Cartões (grade)",
|
"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": {
|
"generalSettings": {
|
||||||
"title": "Configurações gerais",
|
"title": "Configurações gerais",
|
||||||
|
|||||||
@@ -837,6 +837,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (Локальный и бесплатный)",
|
"providerOllamaOption": "🦙 Ollama (Локальный и бесплатный)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 Пользовательский (совместимый с OpenAI)",
|
"providerCustomOption": "🔧 Пользовательский (совместимый с OpenAI)",
|
||||||
|
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||||
|
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||||
|
"providerMistralOption": "🌀 Mistral AI",
|
||||||
|
"providerZAIOption": "✨ Z.AI",
|
||||||
|
"providerLMStudioOption": "🖥️ LM Studio (Локальный)",
|
||||||
"bestValue": "Лучшее соотношение цена/качество",
|
"bestValue": "Лучшее соотношение цена/качество",
|
||||||
"bestQuality": "Лучшее качество",
|
"bestQuality": "Лучшее качество",
|
||||||
"saved": "(Сохранено)",
|
"saved": "(Сохранено)",
|
||||||
@@ -1175,7 +1180,11 @@
|
|||||||
"notesViewLabel": "Макет заметок",
|
"notesViewLabel": "Макет заметок",
|
||||||
"notesViewTabs": "Вкладки (в стиле OneNote)",
|
"notesViewTabs": "Вкладки (в стиле OneNote)",
|
||||||
"notesViewMasonry": "Карточки (сетка)",
|
"notesViewMasonry": "Карточки (сетка)",
|
||||||
"selectTheme": "Select theme"
|
"selectTheme": "Select theme",
|
||||||
|
"fontFamilyLabel": "Семейство шрифтов",
|
||||||
|
"fontFamilyDescription": "Выберите шрифт, используемый во всём приложении",
|
||||||
|
"selectFontFamily": "Inter оптимизирован для читаемости, Системный использует нативный шрифт вашей ОС",
|
||||||
|
"fontSystem": "Системный шрифт по умолчанию"
|
||||||
},
|
},
|
||||||
"generalSettings": {
|
"generalSettings": {
|
||||||
"title": "Общие настройки",
|
"title": "Общие настройки",
|
||||||
|
|||||||
@@ -837,6 +837,11 @@
|
|||||||
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
"providerOllamaOption": "🦙 Ollama (Local & Free)",
|
||||||
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
"providerOpenAIOption": "🤖 OpenAI (GPT-5, GPT-4)",
|
||||||
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
"providerCustomOption": "🔧 Custom OpenAI-Compatible",
|
||||||
|
"providerDeepSeekOption": "🔍 DeepSeek",
|
||||||
|
"providerOpenRouterOption": "🌐 OpenRouter",
|
||||||
|
"providerMistralOption": "🌀 Mistral AI",
|
||||||
|
"providerZAIOption": "✨ Z.AI",
|
||||||
|
"providerLMStudioOption": "🖥️ LM Studio (本地)",
|
||||||
"bestValue": "最佳性价比",
|
"bestValue": "最佳性价比",
|
||||||
"bestQuality": "最佳质量",
|
"bestQuality": "最佳质量",
|
||||||
"saved": "(已保存)",
|
"saved": "(已保存)",
|
||||||
@@ -1175,7 +1180,11 @@
|
|||||||
"notesViewLabel": "笔记布局",
|
"notesViewLabel": "笔记布局",
|
||||||
"notesViewTabs": "标签页(OneNote 风格)",
|
"notesViewTabs": "标签页(OneNote 风格)",
|
||||||
"notesViewMasonry": "卡片(网格)",
|
"notesViewMasonry": "卡片(网格)",
|
||||||
"selectTheme": "Select theme"
|
"selectTheme": "Select theme",
|
||||||
|
"fontFamilyLabel": "字体系列",
|
||||||
|
"fontFamilyDescription": "选择应用程序中使用的字体",
|
||||||
|
"selectFontFamily": "Inter 针对可读性进行了优化,系统使用您操作系统的原生字体",
|
||||||
|
"fontSystem": "系统默认字体"
|
||||||
},
|
},
|
||||||
"generalSettings": {
|
"generalSettings": {
|
||||||
"title": "常规设置",
|
"title": "常规设置",
|
||||||
|
|||||||
435
memento-note/package-lock.json
generated
435
memento-note/package-lock.json
generated
@@ -501,7 +501,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
@@ -548,7 +547,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
@@ -576,7 +574,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -640,6 +637,28 @@
|
|||||||
"integrity": "sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==",
|
"integrity": "sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.7.5",
|
"@floating-ui/core": "^1.7.5",
|
||||||
"@floating-ui/utils": "^0.2.11"
|
"@floating-ui/utils": "^0.2.11"
|
||||||
@@ -2357,337 +2375,12 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"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": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.59.1",
|
"version": "1.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.59.1"
|
"playwright": "1.59.1"
|
||||||
},
|
},
|
||||||
@@ -2714,7 +2407,6 @@
|
|||||||
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
|
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.13"
|
"node": ">=16.13"
|
||||||
},
|
},
|
||||||
@@ -6215,7 +5907,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
|
||||||
"integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==",
|
"integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -6477,7 +6168,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz",
|
||||||
"integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==",
|
"integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"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",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.22.5.tgz",
|
||||||
"integrity": "sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==",
|
"integrity": "sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -6690,7 +6379,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz",
|
||||||
"integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==",
|
"integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -6705,7 +6393,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz",
|
||||||
"integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==",
|
"integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-changeset": "^2.3.0",
|
"prosemirror-changeset": "^2.3.0",
|
||||||
"prosemirror-commands": "^1.6.2",
|
"prosemirror-commands": "^1.6.2",
|
||||||
@@ -7215,7 +6902,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -7225,7 +6911,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -7286,7 +6971,6 @@
|
|||||||
"integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==",
|
"integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bcoe/v8-coverage": "^1.0.2",
|
"@bcoe/v8-coverage": "^1.0.2",
|
||||||
"@vitest/utils": "4.1.4",
|
"@vitest/utils": "4.1.4",
|
||||||
@@ -7632,7 +7316,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -7804,7 +7487,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
|
||||||
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
|
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chevrotain/cst-dts-gen": "11.0.3",
|
"@chevrotain/cst-dts-gen": "11.0.3",
|
||||||
"@chevrotain/gast": "11.0.3",
|
"@chevrotain/gast": "11.0.3",
|
||||||
@@ -8065,7 +7747,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz",
|
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz",
|
||||||
"integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==",
|
"integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
@@ -8475,7 +8156,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@@ -9570,7 +9250,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz",
|
||||||
"integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==",
|
"integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.20.0"
|
"node": ">=12.20.0"
|
||||||
},
|
},
|
||||||
@@ -10546,7 +10225,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
|
||||||
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
|
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chevrotain/cst-dts-gen": "12.0.0",
|
"@chevrotain/cst-dts-gen": "12.0.0",
|
||||||
"@chevrotain/gast": "12.0.0",
|
"@chevrotain/gast": "12.0.0",
|
||||||
@@ -11348,14 +11026,6 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.37",
|
"version": "2.0.37",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
||||||
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
@@ -11426,7 +11095,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
|
||||||
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
|
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"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",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz",
|
||||||
"integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==",
|
"integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"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",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
||||||
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
|
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -11883,7 +11549,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
|
||||||
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
|
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-changeset": "^2.3.0",
|
"prosemirror-changeset": "^2.3.0",
|
||||||
"prosemirror-collab": "^1.3.1",
|
"prosemirror-collab": "^1.3.1",
|
||||||
@@ -12357,7 +12022,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -12392,7 +12056,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||||
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
|
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
@@ -12414,7 +12077,6 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/engines": "5.22.0"
|
"@prisma/engines": "5.22.0"
|
||||||
},
|
},
|
||||||
@@ -12432,7 +12094,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -12565,7 +12226,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"orderedmap": "^2.0.0"
|
"orderedmap": "^2.0.0"
|
||||||
}
|
}
|
||||||
@@ -12595,7 +12255,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.0.0",
|
"prosemirror-model": "^1.0.0",
|
||||||
"prosemirror-transform": "^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",
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
||||||
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.20.0",
|
"prosemirror-model": "^1.20.0",
|
||||||
"prosemirror-state": "^1.0.0",
|
"prosemirror-state": "^1.0.0",
|
||||||
@@ -12857,7 +12515,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -12877,7 +12534,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -13595,8 +13251,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.3.tgz",
|
||||||
"integrity": "sha512-fA/NX5gMf0ooCLISgB0wScaWgaj6rjTN2SVAwleURjiya7ITNkV+VMmoHtKkldP6CIZoYCZyxb8zP/e2TWoEtQ==",
|
"integrity": "sha512-fA/NX5gMf0ooCLISgB0wScaWgaj6rjTN2SVAwleURjiya7ITNkV+VMmoHtKkldP6CIZoYCZyxb8zP/e2TWoEtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
@@ -13681,7 +13336,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -14185,7 +13839,6 @@
|
|||||||
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
|
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.1.4",
|
"@vitest/expect": "4.1.4",
|
||||||
"@vitest/mocker": "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": {
|
"node_modules/vitest/node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"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": "^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": {
|
"node_modules/vitest/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
@@ -14350,28 +13978,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/vitest/node_modules/vite": {
|
||||||
"version": "8.0.9",
|
"version": "8.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
|
||||||
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
|
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
@@ -14646,7 +14258,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "UserAISettings" ADD COLUMN "fontFamily" TEXT NOT NULL DEFAULT 'inter';
|
||||||
@@ -37,6 +37,7 @@ model User {
|
|||||||
sessions Session[]
|
sessions Session[]
|
||||||
aiSettings UserAISettings?
|
aiSettings UserAISettings?
|
||||||
workflows Workflow[]
|
workflows Workflow[]
|
||||||
|
notifications Notification[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
@@ -278,6 +279,7 @@ model UserAISettings {
|
|||||||
noteHistory Boolean @default(false)
|
noteHistory Boolean @default(false)
|
||||||
noteHistoryMode String @default("manual")
|
noteHistoryMode String @default("manual")
|
||||||
languageDetection Boolean @default(true)
|
languageDetection Boolean @default(true)
|
||||||
|
fontFamily String @default("inter")
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([memoryEcho])
|
@@index([memoryEcho])
|
||||||
@@ -409,3 +411,19 @@ model WorkflowRun {
|
|||||||
@@index([workflowId])
|
@@index([workflowId])
|
||||||
@@index([status])
|
@@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
474
scripts/deploy-docker.ps1
Normal 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
592
scripts/deploy-docker.sh
Executable 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
510
scripts/deploy-local.ps1
Normal 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
582
scripts/deploy-local.sh
Executable 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
|
||||||
@@ -251,9 +251,8 @@ deploy() {
|
|||||||
done
|
done
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
info "Initializing database..."
|
info "Database migrations are handled by the container entrypoint on every start."
|
||||||
docker compose exec memento-note npx prisma db push --skip-generate 2>/dev/null || \
|
info "The entrypoint handles fresh installs, updates, and P3005 baseline recovery automatically."
|
||||||
warn "DB push failed (may already be synced)"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
@@ -266,10 +265,18 @@ deploy() {
|
|||||||
# Show admin setup hint if first time
|
# Show admin setup hint if first time
|
||||||
local user_count
|
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")
|
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
|
if [ "$user_count" = "0" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
warn "No users found. Register at $(grep NEXTAUTH_URL "$ENV_FILE" | cut -d= -f2 | tr -d '"')/register"
|
warn "No users found."
|
||||||
warn "Then run: docker compose exec memento-note npx tsx scripts/grant-all-admins.ts"
|
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
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user