Compare commits
11 Commits
77c4cfc777
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec4d030c0a | ||
|
|
c362219a40 | ||
|
|
e684154938 | ||
|
|
e705f240d4 | ||
|
|
a3f9cef883 | ||
|
|
bbd6e6d007 | ||
|
|
84316e9c53 | ||
|
|
24ca1521b4 | ||
|
|
3e77b54d71 | ||
|
|
0b6886774e | ||
|
|
87d4acc2e2 |
444
README.md
444
README.md
@@ -1,6 +1,6 @@
|
||||
# Indie — Independent Website Platform
|
||||
|
||||
A platform that lets anyone create, own, and publish their own static website without social media gatekeeping. Built with a drag-and-drop builder and LLM prompt-to-site generation. Every site is backed by a git repository.
|
||||
A platform that lets anyone create, own, and publish their own static website through AI prompts. Describe what you want, and the AI generates it. Every site is backed by a git repository.
|
||||
|
||||
---
|
||||
|
||||
@@ -10,9 +10,8 @@ A platform that lets anyone create, own, and publish their own static website wi
|
||||
|
||||
- Java 17+
|
||||
- Node.js 18+ and npm
|
||||
- Docker & Docker Compose (for PostgreSQL, MongoDB, MinIO)
|
||||
- Angular CLI 17 (`npm install -g @angular/cli@17`)
|
||||
- Gradle wrapper (included)
|
||||
- Docker & Docker Compose (for PostgreSQL, MongoDB)
|
||||
- npm packages: `@angular/cli@17` (`npm install -g @angular/cli@17`)
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -22,6 +21,8 @@ A platform that lets anyone create, own, and publish their own static website wi
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts PostgreSQL (port 5432) and MongoDB (port 27017).
|
||||
|
||||
2. **Start the backend:**
|
||||
|
||||
```bash
|
||||
@@ -43,22 +44,54 @@ A platform that lets anyone create, own, and publish their own static website wi
|
||||
The app will be available at `http://localhost:4200`.
|
||||
API calls are proxied to `http://localhost:8080`.
|
||||
|
||||
### Environment Variables
|
||||
---
|
||||
|
||||
## Infrastructure (Docker Compose)
|
||||
|
||||
| Service | Image | Ports | Credentials | Volume |
|
||||
|------------|--------------------|--------------|--------------------------------|---------------------|
|
||||
| PostgreSQL | `postgres:16-alpine` | host:5432 | `indie` / `indie_dev` | `pgdata` |
|
||||
| MongoDB | `mongo:7` | host:27017 | (no auth) | `mongodata` |
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| **Database** | | |
|
||||
| `SPRING_DATASOURCE_URL` | PostgreSQL JDBC URL | `jdbc:postgresql://localhost:5432/indie` |
|
||||
| `SPRING_DATASOURCE_USERNAME` | DB username | `indie` |
|
||||
| `SPRING_DATASOURCE_PASSWORD` | DB password | `indie_dev` |
|
||||
| `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_ID` | Google OAuth 2.0 client ID (omit to disable Google login) | — |
|
||||
| `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET` | Google OAuth 2.0 client secret | — |
|
||||
| `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GITHUB_CLIENT_ID` | GitHub OAuth App client ID (omit to disable GitHub login) | — |
|
||||
| `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GITHUB_CLIENT_SECRET` | GitHub OAuth App client secret | — |
|
||||
| `MONGODB_URI` | MongoDB connection URI | `mongodb://localhost:27017/indie` |
|
||||
| **JWT** | | |
|
||||
| `INDIE_JWT_ACCESS_EXPIRATION` | Access token TTL (ms) | `900000` (15 min) |
|
||||
| `INDIE_JWT_REFRESH_EXPIRATION` | Refresh token TTL (ms) | `604800000` (7 days) |
|
||||
| `INDIE_JWT_PRIVATE_KEY_PATH` | RS256 private key path | `config/jwt-private.key` |
|
||||
| `INDIE_JWT_PUBLIC_KEY_PATH` | RS256 public key path | `config/jwt-public.key` |
|
||||
| **Git** | | |
|
||||
| `INDIE_GIT_REPOS_PATH` | Git repositories root | `/var/git/sites/` |
|
||||
| `INDIE_GIT_AUTHOR_NAME` | Default git author name | `Indie Platform` |
|
||||
| `INDIE_GIT_AUTHOR_EMAIL` | Default git author email | `git@indie.local` |
|
||||
| **Encryption** | | |
|
||||
| `INDIE_ENCRYPTION_KEY_PATH` | AES-256-GCM master key path | `config/encryption.key` |
|
||||
| **App** | | |
|
||||
| `INDIE_APP_BASE_URL` | Frontend base URL (for verification emails) | `http://localhost:4200` |
|
||||
| `INDIE_VERIFICATION_TOKEN_EXPIRATION` | Email verification token TTL (minutes) | `1440` (24h) |
|
||||
| `INDIE_CORS_ORIGINS` | Allowed CORS origins (comma-separated) | `http://localhost:4200,http://localhost:3000` |
|
||||
| **LLM** | | |
|
||||
| `GEMINI_API_KEY` | Google Gemini API key | — |
|
||||
| `GEMINI_MODEL` | Gemini model name | `gemini-2.0-flash` |
|
||||
| `OPENAI_API_KEY` | OpenAI API key | — |
|
||||
| `OPENAI_MODEL` | OpenAI model name | `gpt-4o-mini` |
|
||||
| `INDIE_LLM_RATE_LIMIT` | Max LLM requests per hour per user | `10` |
|
||||
| **Email (MailHog compatible)** | | |
|
||||
| `EMAIL_HOST` | SMTP host | `localhost` |
|
||||
| `EMAIL_PORT` | SMTP port | `1025` |
|
||||
| `EMAIL_USERNAME` | SMTP username | _(empty)_ |
|
||||
| `EMAIL_PASSWORD` | SMTP password | _(empty)_ |
|
||||
| `EMAIL_SMTP_AUTH` | Enable SMTP auth | `true` |
|
||||
| `EMAIL_SMTP_STARTTLS` | Enable STARTTLS | `true` |
|
||||
|
||||
---
|
||||
|
||||
@@ -70,81 +103,175 @@ backend/
|
||||
├── settings.gradle # Project name
|
||||
├── gradlew / gradlew.bat # Gradle wrapper
|
||||
├── gradle/wrapper/ # Wrapper JAR + properties
|
||||
└── src/main/
|
||||
├── java/com/indie/
|
||||
│ ├── IndieApplication.java # Spring Boot main class
|
||||
└── src/
|
||||
├── main/java/com/krrishg/
|
||||
│ ├── IndieApplication.java # Spring Boot main class (@EnableScheduling)
|
||||
│ ├── config/
|
||||
│ │ ├── SecurityConfig.java # HTTP security, JWT filter, OAuth2 login, CORS
|
||||
│ │ ├── SecurityConfig.java # HTTP security, JWT filter, CORS
|
||||
│ │ ├── JwtConfig.java # RS256 token generation and validation
|
||||
│ │ └── GitConfig.java # Git repository settings
|
||||
│ │ ├── TokenService.java # Interface for token generation/validation
|
||||
│ │ ├── JwtAuthenticationToken.java # Custom AbstractAuthenticationToken
|
||||
│ │ ├── UserPrincipal.java # UserDetails record
|
||||
│ │ ├── GitConfig.java # Git repository settings
|
||||
|
||||
│ │ ├── EncryptionService.java # AES-256/GCM for LLM API key encryption
|
||||
│ │ ├── RateLimitConfig.java # Bucket4j token-bucket rate limiter
|
||||
│ │ ├── SchemaMigration.java # Startup DDL migrations
|
||||
│ │ ├── MapToJsonConverter.java # JPA converter for JSON text columns
|
||||
│ │ └── GlobalExceptionHandler.java # @ControllerAdvice error handling
|
||||
│ ├── model/
|
||||
│ │ ├── User.java # User entity with local + OAuth support
|
||||
│ │ ├── User.java # User entity (email, password, emailVerified)
|
||||
│ │ ├── VerificationToken.java # Email verification / set-password tokens
|
||||
│ │ ├── Site.java # Site entity per user
|
||||
│ │ ├── Page.java # Page within a site
|
||||
│ │ ├── Component.java # Component on a page (heading, text, image, etc.)
|
||||
│ │ ├── JsonbConverter.java # JPA converter for JSONB columns
|
||||
│ │ └── *.java # Enums (AuthProvider, SiteStatus, ComponentType)
|
||||
│ │ ├── SiteStatus.java # Enum: DRAFT, PUBLISHED
|
||||
│ │ ├── AuthProvider.java # Enum: LOCAL only (OAuth planned)
|
||||
│ │ ├── GitRemote.java # Connected git remote (GitHub/GitLab)
|
||||
│ │ ├── SelfHostedConfig.java # SSH/deploy configuration per site
|
||||
│ │ └── GenerationVersion.java # MongoDB: LLM generation history per site
|
||||
│ ├── repository/
|
||||
│ │ ├── UserRepository.java
|
||||
│ │ ├── SiteRepository.java
|
||||
│ │ ├── PageRepository.java
|
||||
│ │ └── ComponentRepository.java
|
||||
│ │ ├── GitRemoteRepository.java
|
||||
│ │ ├── SelfHostedConfigRepository.java
|
||||
│ │ └── VerificationTokenRepository.java
|
||||
│ ├── dto/
|
||||
│ │ ├── RegisterRequest.java
|
||||
│ │ ├── RegistrationResponse.java
|
||||
│ │ ├── LoginRequest.java
|
||||
│ │ ├── RefreshTokenRequest.java
|
||||
│ │ ├── AuthResponse.java # JWT tokens + user info
|
||||
│ │ ├── SiteRequest.java
|
||||
│ │ ├── VerifyEmailRequest.java
|
||||
│ │ ├── VerifyEmailResponse.java
|
||||
│ │ ├── ResendVerificationRequest.java
|
||||
│ │ ├── SetPasswordRequest.java
|
||||
│ │ ├── SiteRequest.java # Includes deploymentTarget field
|
||||
│ │ ├── SiteResponse.java
|
||||
│ │ ├── PageRequest.java
|
||||
│ │ └── PageResponse.java
|
||||
│ │ ├── SiteStructure.java # Top-level DTO: pages + theme
|
||||
│ │ ├── Reference.java # URL/IMAGE reference for LLM context
|
||||
│ │ ├── LLMOptions.java # LLM generation options
|
||||
│ │ ├── LLMResult.java # LLM generation result
|
||||
│ │ ├── LLMConfigRequest.java # User LLM provider config
|
||||
│ │ ├── SelfHostedConfigRequest.java
|
||||
│ │ └── SelfHostedConfigResponse.java
|
||||
│ ├── service/
|
||||
│ │ ├── AuthService.java # Register, login, token refresh
|
||||
│ │ ├── OAuth2UserService.java # Maps Google/GitHub user info to User entity
|
||||
│ │ └── OAuth2SuccessHandler.java # Redirects with JWT after OAuth login
|
||||
│ │ ├── AuthService.java # Register, login, token refresh, verify email
|
||||
│ │ ├── EmailService.java # Verification email sender
|
||||
│ │ ├── CleanupService.java # Scheduled cleanup of stale unverified accounts
|
||||
│ │ ├── LLMService.java # Orchestrates LLM calls, stores/restores versions
|
||||
│ │ ├── LLMResponseParser.java # Parses LLM JSON into SiteStructure
|
||||
│ │ ├── ReferenceService.java # Fetches web page content for LLM context
|
||||
│ │ ├── RenderService.java # Renders SiteStructure into HTML + CSS + JS
|
||||
│ │ ├── DeploymentService.java # Orchestrates publish/unpublish
|
||||
│ │ ├── Deployer.java # Interface for self-hosted deployment
|
||||
│ │ ├── SshRsyncDeployer.java # SSH + rsync + nginx + Let's Encrypt
|
||||
│ │ ├── PagesProvider.java # Interface for git provider Pages
|
||||
│ │ ├── GitHubPagesProvider.java # GitHub Pages implementation
|
||||
│ │ ├── GitLabPagesProvider.java # GitLab Pages implementation
|
||||
│ │ ├── GitService.java # JGit: init, commit, push, log, rollback
|
||||
│ │ ├── KeyManager.java # SSH key-pair generation per site
|
||||
|
||||
│ │ ├── ExportService.java # Exports site structure to ZIP
|
||||
│ │ └── DomainService.java # Custom domain resolution skeleton
|
||||
│ ├── service/llm/
|
||||
│ │ ├── LLMProvider.java # Interface for LLM providers
|
||||
│ │ ├── GeminiProvider.java # Google Gemini implementation
|
||||
│ │ ├── OpenAIProvider.java # OpenAI implementation
|
||||
│ │ └── ProviderFactory.java # Factory creating providers by name + key
|
||||
│ ├── engine/
|
||||
│ │ ├── JsBundle.java # Generates static JS (nav, smooth scroll, lightbox, contact form)
|
||||
│ │ └── StyleCompiler.java # Generates base CSS with custom property fallbacks
|
||||
│ └── controller/
|
||||
│ ├── AuthController.java # POST /api/auth/register, /login, /refresh, /logout
|
||||
│ ├── AuthController.java # POST /api/auth/*
|
||||
│ ├── SiteController.java # CRUD /api/sites
|
||||
│ └── PageController.java # CRUD /api/sites/{siteId}/pages
|
||||
└── resources/
|
||||
└── application.yml # All configuration
|
||||
│ ├── LLMController.java # POST /api/llm/generate, /refine, /versions/*
|
||||
│ ├── GitController.java # POST /api/sites/{id}/git/*
|
||||
│ ├── DeploymentController.java # POST /api/sites/{id}/publish, /unpublish
|
||||
│ ├── ExportController.java # GET /api/sites/{id}/export
|
||||
│ ├── UserSettingsController.java # GET/PUT/DELETE /api/user/llm-config
|
||||
│ └── SelfHostedController.java # CRUD /api/sites/{id}/self-hosted
|
||||
├── main/resources/
|
||||
│ ├── application.yml # All configuration
|
||||
│ └── prompts/
|
||||
│ ├── site-generator.txt # LLM system prompt for site generation
|
||||
│ └── site-refiner.txt # LLM system prompt for refinement
|
||||
└── test/java/com/krrishg/
|
||||
├── config/ # 7 config tests
|
||||
├── controller/ # 9 controller tests
|
||||
├── dto/ # 7 DTO tests
|
||||
├── engine/ # 2 engine tests
|
||||
├── model/ # 7 model tests
|
||||
├── repository/ # 4 repository tests
|
||||
├── service/ # 16 service tests + fakes
|
||||
├── service/llm/ # 3 LLM provider tests
|
||||
└── support/ # 13 test fakes, stubs, and configs
|
||||
|
||||
frontend/
|
||||
├── Dockerfile # Multi-stage production build
|
||||
├── nginx.conf # Nginx config (SPA fallback + /api/ proxy)
|
||||
├── proxy.conf.json # Dev proxy to backend
|
||||
├── angular.json # Angular CLI configuration
|
||||
├── tailwind.config.js # Tailwind CSS configuration
|
||||
├── postcss.config.js # PostCSS (tailwindcss + autoprefixer)
|
||||
├── package.json
|
||||
└── src/
|
||||
├── index.html
|
||||
├── styles.css # Global styles + Tailwind imports
|
||||
└── app/
|
||||
├── app.component.ts # Root component
|
||||
├── app.config.ts # App providers
|
||||
├── app.routes.ts # Lazy-loaded routes
|
||||
├── core/
|
||||
│ ├── models/
|
||||
│ │ ├── auth-response.ts # AuthResponse & UserInfo interfaces
|
||||
│ │ ├── site.ts # SiteResponse interface
|
||||
│ │ └── page.ts # PageResponse interface
|
||||
│ ├── services/
|
||||
│ │ ├── auth.service.ts # Auth HTTP + token storage
|
||||
│ │ ├── site.service.ts # Site CRUD HTTP
|
||||
│ │ └── page.service.ts # Page CRUD HTTP
|
||||
│ ├── guards/
|
||||
│ │ └── auth.guard.ts # Route guard (redirects to /login)
|
||||
│ └── interceptors/
|
||||
│ └── jwt.interceptor.ts # Attaches Bearer token
|
||||
├── auth/
|
||||
│ ├── login/
|
||||
│ │ └── login.component.ts # Email/password login form
|
||||
│ └── register/
|
||||
│ └── register.component.ts # Name/email/password register form
|
||||
└── dashboard/
|
||||
├── dashboard.component.ts # Site cards grid + toolbar
|
||||
├── create-site-dialog/
|
||||
│ └── create-site-dialog.ts # Create site dialog
|
||||
└── delete-site-dialog/
|
||||
└── delete-site-dialog.ts # Confirm delete dialog
|
||||
├── tsconfig.json / tsconfig.app.json / tsconfig.spec.json
|
||||
├── src/
|
||||
│ ├── index.html
|
||||
│ ├── main.ts # Angular bootstrap
|
||||
│ ├── styles.css # Global styles + Tailwind imports
|
||||
│ ├── favicon.ico
|
||||
│ ├── assets/
|
||||
│ └── app/
|
||||
│ ├── app.component.ts / .html / .css
|
||||
│ ├── app.config.ts # App providers
|
||||
│ ├── app.routes.ts # Lazy-loaded routes
|
||||
│ ├── core/
|
||||
│ │ ├── models/
|
||||
│ │ │ ├── auth-response.ts # AuthResponse & UserInfo
|
||||
│ │ │ ├── site.ts # SiteResponse
|
||||
│ │ │ ├── page.ts # PageResponse
|
||||
│ │ │ ├── llm.ts # LLM request/response types, GenerationVersion
|
||||
│ │ │ └── deployment.ts # Git/self-hosted config types
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── auth.service.ts # Auth HTTP + token storage
|
||||
│ │ │ ├── site.service.ts # Site CRUD HTTP
|
||||
│ │ │ ├── page.service.ts # Page CRUD HTTP
|
||||
│ │ │ ├── deployment.service.ts # Export, git, publish HTTP
|
||||
│ │ │ ├── llm-api.service.ts # LLM generate/refine/version HTTP
|
||||
│ │ │ └── llm-config.service.ts # User LLM key storage HTTP
|
||||
│ │ ├── guards/
|
||||
│ │ │ ├── auth.guard.ts # Redirects unauthenticated to /login
|
||||
│ │ │ └── login.guard.ts # Redirects authenticated to /dashboard
|
||||
│ │ └── interceptors/
|
||||
│ │ └── jwt.interceptor.ts # Attaches Bearer token
|
||||
│ ├── auth/
|
||||
│ │ ├── login/
|
||||
│ │ │ └── login.component.ts
|
||||
│ │ ├── register/
|
||||
│ │ │ └── register.component.ts
|
||||
│ │ └── verify-email/
|
||||
│ │ └── verify-email.component.ts # Email verification + set-password
|
||||
│ ├── dashboard/
|
||||
│ │ ├── dashboard.component.ts # Site cards grid + toolbar
|
||||
│ │ ├── create-site-dialog/
|
||||
│ │ │ └── create-site-dialog.ts
|
||||
│ │ └── delete-site-dialog/
|
||||
│ │ └── delete-site-dialog.ts
|
||||
│ └── llm-workspace/
|
||||
│ ├── llm-workspace.component.ts # Main workspace (prompt, preview, timeline)
|
||||
│ ├── llm-session.service.ts # LLM generation session state
|
||||
│ ├── settings/
|
||||
│ │ └── settings.component.ts # LLM provider key/dialog settings
|
||||
│ ├── git-settings/
|
||||
│ │ └── git-settings.component.ts # Git remote connection settings
|
||||
│ ├── preview-dialog/
|
||||
│ │ └── preview-dialog.component.ts # Preview generated site
|
||||
│ └── delete-version-dialog/
|
||||
│ └── delete-version-dialog.ts # Confirm delete version
|
||||
|
||||
config/ # Auto-generated on first boot
|
||||
├── jwt-private.key # RS256 private key
|
||||
├── jwt-public.key # RS256 public key
|
||||
└── encryption.key # AES-256-GCM master key (base64)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -155,30 +282,79 @@ frontend/
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/auth/register` | Register with email + password |
|
||||
| POST | `/api/auth/register` | Register with name + email + password (returns verification pending) |
|
||||
| POST | `/api/auth/login` | Login with email + password |
|
||||
| POST | `/api/auth/refresh` | Refresh access token |
|
||||
| POST | `/api/auth/logout` | Logout |
|
||||
| POST | `/api/auth/verify-email` | Verify email via token, returns setup token |
|
||||
| POST | `/api/auth/resend-verification` | Resend verification email |
|
||||
| POST | `/api/auth/set-password` | Set password after email verification |
|
||||
|
||||
### Sites (`/api/sites`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/sites` | List user's sites |
|
||||
| POST | `/api/sites` | Create a new site |
|
||||
| POST | `/api/sites` | Create a new site (with `deploymentTarget`: `GIT_PAGES`, `SELF_HOSTED`, or `NONE`) |
|
||||
| GET | `/api/sites/{id}` | Get site details |
|
||||
| PUT | `/api/sites/{id}` | Update site |
|
||||
| DELETE | `/api/sites/{id}` | Delete site |
|
||||
|
||||
### Pages (`/api/sites/{siteId}/pages`)
|
||||
### LLM Generation (`/api/llm`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/sites/{siteId}/pages` | List pages for a site |
|
||||
| POST | `/api/sites/{siteId}/pages` | Create a new page |
|
||||
| GET | `/api/sites/{siteId}/pages/{pageId}` | Get page details |
|
||||
| PUT | `/api/sites/{siteId}/pages/{pageId}` | Update page |
|
||||
| DELETE | `/api/sites/{siteId}/pages/{pageId}` | Delete page |
|
||||
| POST | `/api/llm/generate` | Generate site from prompt (rate-limited to 10/hour/user) |
|
||||
| POST | `/api/llm/refine` | Refine existing site structure with follow-up prompt |
|
||||
| GET | `/api/llm/versions/{siteId}` | List generation versions for a site |
|
||||
| POST | `/api/llm/versions/{id}/restore` | Restore a specific generation version |
|
||||
| DELETE | `/api/llm/versions/{id}` | Delete a generation version |
|
||||
|
||||
### Git Remotes (`/api/sites/{id}/git`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/sites/{id}/git/connect` | Connect git remote (GitHub/GitLab) |
|
||||
| POST | `/api/sites/{id}/git/disconnect` | Disconnect git remote |
|
||||
| GET | `/api/sites/{id}/git/status` | Get connection status, SSH keys, commit log |
|
||||
|
||||
### Deployment (`/api/sites/{id}`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/sites/{id}/publish` | Publish site (git push) |
|
||||
| POST | `/api/sites/{id}/unpublish` | Unpublish site |
|
||||
| GET | `/api/sites/{id}/export` | Download site as `.zip` archive |
|
||||
|
||||
### Self-Hosted Deployment (`/api/sites/{id}/self-hosted`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/sites/{id}/self-hosted` | Get SSH/deploy configuration |
|
||||
| PUT | `/api/sites/{id}/self-hosted` | Save SSH/deploy configuration |
|
||||
| DELETE | `/api/sites/{id}/self-hosted` | Remove configuration |
|
||||
|
||||
### User Settings (`/api/user`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/user/llm-config` | Get saved LLM provider configs (keys redacted) |
|
||||
| PUT | `/api/user/llm-config` | Save API key / base URL / model for a provider |
|
||||
| DELETE | `/api/user/llm-config/{provider}` | Delete saved config for a provider |
|
||||
|
||||
---
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
| Path | Component | Auth |
|
||||
|------|-----------|------|
|
||||
| `/login` | LoginComponent | Redirects to /dashboard if already logged in |
|
||||
| `/register` | RegisterComponent | Redirects to /dashboard if already logged in |
|
||||
| `/verify-email` | VerifyEmailComponent | Public (email verification flow) |
|
||||
| `/dashboard` | DashboardComponent | Requires auth |
|
||||
| `/llm/:siteId` | LlmWorkspaceComponent | Requires auth |
|
||||
| `/llm/:siteId/settings` | SettingsComponent | Requires auth |
|
||||
| `/llm/:siteId/git` | GitSettingsComponent | Requires auth |
|
||||
|
||||
---
|
||||
|
||||
@@ -186,44 +362,142 @@ frontend/
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Backend | Java 17, Spring Boot 3.2, Gradle |
|
||||
| Frontend | Angular 17, Angular Material, Tailwind CSS |
|
||||
| Backend | Java 17, Spring Boot 3.2, Gradle (Groovy) |
|
||||
| Frontend | Angular 17, Angular Material, Tailwind CSS, Leaflet |
|
||||
| Relational DB | PostgreSQL 16 |
|
||||
| Document DB | MongoDB 7 |
|
||||
| Object Storage | MinIO (dev) / Cloudflare R2 (prod) |
|
||||
| Auth | Spring Security + OAuth2 Client + JWT (RS256) |
|
||||
| Git | JGit |
|
||||
| Document DB | MongoDB 7 (generation versions, LLM sessions) |
|
||||
|
||||
| Auth | Spring Security + JWT (RS256), email/password |
|
||||
| LLM | Gemini (primary), OpenAI (fallback), provider-abstraction |
|
||||
| Git | JGit — local repo per site, GitHub/GitLab remote push |
|
||||
| Email | Spring Boot Mail (MailHog compatible) |
|
||||
| Rate Limiting | Bucket4j |
|
||||
| API Docs | Springdoc OpenAPI (Swagger UI) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ Spring Boot API │
|
||||
│ (VPS / Docker) │
|
||||
└────────┬─────────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
┌────────▼───┐ ┌──────▼──────┐ ┌───▼────────┐
|
||||
│ PostgreSQL │ │ MongoDB │ │ Git Repos │
|
||||
│ (users, │ │ (versions, │ │ /var/git/ │
|
||||
│ sites) │ │ sessions) │ └────────────┘
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
./gradlew build # Build
|
||||
./gradlew test # Run tests
|
||||
./gradlew test # Run tests (unit + slice + integration)
|
||||
./gradlew bootRun # Run locally (requires docker compose up -d first)
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm install # Install dependencies
|
||||
ng serve # Dev server at localhost:4200
|
||||
ng build # Production build
|
||||
ng build # Production build (outputs to dist/)
|
||||
ng test # Run unit tests (Jasmine + Karma)
|
||||
```
|
||||
|
||||
### Production Build (Docker)
|
||||
|
||||
```bash
|
||||
docker build -t indie-frontend frontend/
|
||||
docker run -p 80:80 indie-frontend
|
||||
```
|
||||
|
||||
The frontend Docker image serves the app via nginx (port 80) and proxies `/api/` requests to `http://backend:8080`.
|
||||
|
||||
---
|
||||
|
||||
## Build Status
|
||||
## Roadmap
|
||||
|
||||
### v0.1 — Auth & Foundation ✅
|
||||
- [x] User registration (email/password)
|
||||
- [x] User registration (email/password + email verification)
|
||||
- [x] User login (email/password + JWT)
|
||||
- [x] OAuth2 login (Google, GitHub) with JWT redirect
|
||||
- [x] Token refresh with 7-day refresh token
|
||||
- [x] Site CRUD (create, list, get, update, delete)
|
||||
- [x] Page CRUD within a site
|
||||
- [x] Frontend: Login, Register, OAuth callback pages
|
||||
- [x] Frontend: Dashboard with site cards, create/delete dialogs
|
||||
- [x] Frontend: JWT interceptor, auth guard, lazy routes
|
||||
- [x] Frontend: Login, Register, Verify Email, Dashboard
|
||||
- [x] Frontend: JWT interceptor, auth guard, login guard, lazy routes
|
||||
- [x] Scheduled cleanup of unverified accounts
|
||||
|
||||
### v0.2 — LLM Prompt Workspace ✅
|
||||
- [x] Prompt-to-site generation via Gemini/OpenAI
|
||||
- [x] Version timeline with MongoDB storage
|
||||
- [x] Live preview (sandboxed iframe)
|
||||
- [x] Content editor (grouped by page)
|
||||
- [x] Refine via follow-up prompts
|
||||
- [x] Rate limiting (10 generations/hour/user)
|
||||
- [x] Reference URL support for context
|
||||
|
||||
### v0.3 — Export, Git & Hosting ✅
|
||||
- [x] Render engine (HTML + CSS + JS generation)
|
||||
- [x] Git init, commit, push via JGit
|
||||
- [x] SSH key-pair management per site
|
||||
- [x] GitHub Pages / GitLab Pages providers
|
||||
- [x] Export as `.zip` archive
|
||||
- [x] Publish/unpublish flow
|
||||
- [x] Custom domain skeleton
|
||||
- [x] Self-hosted deployment (SSH + rsync + nginx)
|
||||
|
||||
---
|
||||
|
||||
## LLM Provider Abstraction
|
||||
|
||||
```java
|
||||
public interface LLMProvider {
|
||||
LLMResult generate(String systemPrompt, String userPrompt,
|
||||
List<Reference> references, LLMOptions options);
|
||||
String getName();
|
||||
}
|
||||
```
|
||||
|
||||
Adding a new provider = implement `LLMProvider` interface + add config block in `application.yml`.
|
||||
|
||||
Provider API keys can be configured globally via env vars (`GEMINI_API_KEY`, `OPENAI_API_KEY`) or per-user through the `/api/user/llm-config` endpoint (encrypted at rest via AES-256-GCM).
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Pure prompt-based** — No drag-and-drop builder. Every site starts from a prompt. Iteration happens through follow-up prompts and inline text edits.
|
||||
|
||||
2. **Static output only** — Generated sites are pure HTML/CSS/JS. No runtime framework dependency. Users own the output forever.
|
||||
|
||||
3. **Git as source of truth** — Every site is a git repository. Publish = commit. Rollback = checkout. Export includes full `.git/`.
|
||||
|
||||
4. **Version timeline** — Every generation creates a version in MongoDB. Users can browse, preview, and restore any previous version.
|
||||
|
||||
5. **No billing in MVP** — Monetization is post-MVP.
|
||||
|
||||
---
|
||||
|
||||
## config/ Directory
|
||||
|
||||
The following files live in `config/` and are auto-generated on first boot if they don't exist.
|
||||
In production, generate and mount them manually:
|
||||
|
||||
```bash
|
||||
# RS256 key pair for JWT
|
||||
openssl genpkey -algorithm RSA -out config/jwt-private.key -pkeyopt rsa_keygen_bits:2048
|
||||
openssl rsa -pubout -in config/jwt-private.key -out config/jwt-public.key
|
||||
|
||||
# AES-256-GCM master key for LLM API key encryption
|
||||
openssl rand -base64 32 > config/encryption.key
|
||||
```
|
||||
|
||||
Set the `INDIE_JWT_PRIVATE_KEY_PATH`, `INDIE_JWT_PUBLIC_KEY_PATH`, and
|
||||
`INDIE_ENCRYPTION_KEY_PATH` env vars to point to these files.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import org.apache.catalina.connector.ClientAbortException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -35,6 +36,13 @@ public class GlobalExceptionHandler {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ClientAbortException.class)
|
||||
public void handleClientAbort(ClientAbortException ex) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Client disconnected: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ProblemDetail handleGeneral(Exception ex) {
|
||||
log.error("Unhandled exception", ex);
|
||||
|
||||
@@ -5,7 +5,6 @@ import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
@@ -13,7 +12,6 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
@@ -33,9 +31,6 @@ public class SecurityConfig {
|
||||
|
||||
private final JwtConfig jwtConfig;
|
||||
|
||||
@Value("${indie.cors.allowed-origins:http://localhost:4200}")
|
||||
private String[] allowedOrigins;
|
||||
|
||||
public SecurityConfig(JwtConfig jwtConfig) {
|
||||
this.jwtConfig = jwtConfig;
|
||||
}
|
||||
@@ -72,7 +67,7 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(List.of(allowedOrigins));
|
||||
config.setAllowedOriginPatterns(List.of("*"));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import io.github.bucket4j.Refill;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
public class SuggestRateLimitConfig {
|
||||
|
||||
private final Map<UUID, Bucket> cache = new ConcurrentHashMap<>();
|
||||
private final int maxRequestsPerHour;
|
||||
|
||||
public SuggestRateLimitConfig(
|
||||
@Value("${indie.llm.suggest-rate-limit.max-requests-per-hour:30}") int maxRequestsPerHour) {
|
||||
this.maxRequestsPerHour = maxRequestsPerHour;
|
||||
}
|
||||
|
||||
public boolean tryConsume(UUID userId) {
|
||||
Bucket bucket = cache.computeIfAbsent(userId, this::createBucket);
|
||||
return bucket.tryConsume(1);
|
||||
}
|
||||
|
||||
public int getAvailableTokens(UUID userId) {
|
||||
Bucket bucket = cache.computeIfAbsent(userId, this::createBucket);
|
||||
return (int) bucket.getAvailableTokens();
|
||||
}
|
||||
|
||||
private Bucket createBucket(UUID userId) {
|
||||
Bandwidth limit = Bandwidth.classic(maxRequestsPerHour,
|
||||
Refill.greedy(maxRequestsPerHour, Duration.ofHours(1)));
|
||||
return Bucket.builder().addLimit(limit).build();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.RateLimitConfig;
|
||||
import com.krrishg.config.SuggestRateLimitConfig;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.dto.SuggestSectionsResponse;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.service.LLMService;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
@@ -23,11 +25,14 @@ public class LLMController {
|
||||
|
||||
private final LLMService llmService;
|
||||
private final RateLimitConfig rateLimitConfig;
|
||||
private final SuggestRateLimitConfig suggestRateLimitConfig;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public LLMController(LLMService llmService, RateLimitConfig rateLimitConfig, ObjectMapper objectMapper) {
|
||||
public LLMController(LLMService llmService, RateLimitConfig rateLimitConfig,
|
||||
SuggestRateLimitConfig suggestRateLimitConfig, ObjectMapper objectMapper) {
|
||||
this.llmService = llmService;
|
||||
this.rateLimitConfig = rateLimitConfig;
|
||||
this.suggestRateLimitConfig = suggestRateLimitConfig;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@@ -72,7 +77,41 @@ public class LLMController {
|
||||
SiteStructure structure = llmService.generateSite(
|
||||
principal.id(), siteId, prompt, references, provider, apiKey, baseUrl, model);
|
||||
return ResponseEntity.ok(structure);
|
||||
} catch (IllegalStateException e) {
|
||||
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/suggest-sections")
|
||||
public ResponseEntity<?> suggestSections(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestBody Map<String, Object> request) {
|
||||
if (!suggestRateLimitConfig.tryConsume(principal.id())) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(Map.of("error", "Suggest rate limit exceeded. Max " +
|
||||
suggestRateLimitConfig.getAvailableTokens(principal.id()) +
|
||||
" requests per hour."));
|
||||
}
|
||||
|
||||
String briefDescription = (String) request.get("briefDescription");
|
||||
if (briefDescription == null || briefDescription.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "briefDescription is required"));
|
||||
}
|
||||
|
||||
String provider = (String) request.get("provider");
|
||||
if (provider == null || provider.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "provider is required"));
|
||||
}
|
||||
|
||||
String apiKey = (String) request.get("apiKey");
|
||||
String baseUrl = (String) request.get("baseUrl");
|
||||
String model = (String) request.get("model");
|
||||
|
||||
try {
|
||||
SuggestSectionsResponse response = llmService.suggestSections(
|
||||
principal.id(), briefDescription, provider, apiKey, baseUrl, model);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -130,7 +169,7 @@ public class LLMController {
|
||||
SiteStructure structure = llmService.refineSite(principal.id(), siteId,
|
||||
currentStructure, prompt, references, provider, apiKey, baseUrl, model);
|
||||
return ResponseEntity.ok(structure);
|
||||
} catch (IllegalStateException e) {
|
||||
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -162,4 +201,17 @@ public class LLMController {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/versions/{siteId}/prune")
|
||||
public ResponseEntity<?> pruneVersions(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID siteId,
|
||||
@RequestBody(required = false) Map<String, Object> request) {
|
||||
int keep = 10;
|
||||
if (request != null && request.containsKey("keep")) {
|
||||
keep = ((Number) request.get("keep")).intValue();
|
||||
}
|
||||
int deleted = llmService.pruneVersions(siteId, keep);
|
||||
return ResponseEntity.ok(Map.of("deleted", deleted));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ public class LLMOptions {
|
||||
private double temperature = 0.9;
|
||||
|
||||
@Builder.Default
|
||||
private int maxTokens = 4096;
|
||||
private int maxTokens = 16384;
|
||||
|
||||
@Builder.Default
|
||||
private int timeoutSeconds = 60;
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.krrishg.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@@ -11,11 +10,4 @@ public class RegisterRequest {
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email(message = "Invalid email format")
|
||||
private String email;
|
||||
|
||||
@NotBlank(message = "Password is required")
|
||||
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
|
||||
private String password;
|
||||
|
||||
@NotBlank(message = "Name is required")
|
||||
private String name;
|
||||
}
|
||||
|
||||
@@ -16,4 +16,7 @@ public class SetPasswordRequest {
|
||||
@NotBlank
|
||||
@Size(min = 8, max = 100)
|
||||
private String password;
|
||||
|
||||
@NotBlank(message = "Name is required")
|
||||
private String name;
|
||||
}
|
||||
|
||||
@@ -35,5 +35,6 @@ public class SiteStructure {
|
||||
private String seoDescription;
|
||||
private int orderIndex;
|
||||
private String rawHtml;
|
||||
private boolean deleted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
public record SuggestSectionsRequest(
|
||||
String briefDescription,
|
||||
String provider,
|
||||
String apiKey,
|
||||
String baseUrl,
|
||||
String model
|
||||
) {}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record SuggestSectionsResponse(
|
||||
List<SectionSuggestion> sections
|
||||
) {
|
||||
public record SectionSuggestion(
|
||||
String title,
|
||||
String description,
|
||||
String slug,
|
||||
List<PatternSuggestion> patterns
|
||||
) {}
|
||||
|
||||
public record PatternSuggestion(
|
||||
String title,
|
||||
String description
|
||||
) {}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ public class User {
|
||||
@Column(name = "password")
|
||||
private String password;
|
||||
|
||||
@Column(name = "name", nullable = false)
|
||||
@Column(name = "name")
|
||||
private String name;
|
||||
|
||||
@Column(name = "avatar_url")
|
||||
|
||||
@@ -69,8 +69,6 @@ public class AuthService {
|
||||
|
||||
User user = User.builder()
|
||||
.email(email)
|
||||
.password(passwordEncoder.encode(request.getPassword()))
|
||||
.name(request.getName())
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
|
||||
@@ -88,12 +86,12 @@ public class AuthService {
|
||||
User user = userRepository.findByEmail(email)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid email or password"));
|
||||
|
||||
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
|
||||
throw new IllegalArgumentException("Invalid email or password");
|
||||
if (!user.isEmailVerified() || user.getPassword() == null) {
|
||||
throw new IllegalArgumentException("Please verify your email address before logging in");
|
||||
}
|
||||
|
||||
if (!user.isEmailVerified()) {
|
||||
throw new IllegalArgumentException("Please verify your email address before logging in");
|
||||
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
|
||||
throw new IllegalArgumentException("Invalid email or password");
|
||||
}
|
||||
|
||||
return generateAuthResponse(user);
|
||||
@@ -174,6 +172,7 @@ public class AuthService {
|
||||
User user = userRepository.findById(token.getUserId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
user.setName(request.getName());
|
||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||
userRepository.save(user);
|
||||
|
||||
|
||||
@@ -16,15 +16,19 @@ public class EmailService {
|
||||
|
||||
private final JavaMailSender mailSender;
|
||||
private final String baseUrl;
|
||||
private final String from;
|
||||
|
||||
public EmailService(JavaMailSender mailSender,
|
||||
@Value("${indie.app.base-url}") String baseUrl) {
|
||||
@Value("${indie.app.base-url}") String baseUrl,
|
||||
@Value("${spring.mail.from}") String from) {
|
||||
this.mailSender = mailSender;
|
||||
this.baseUrl = baseUrl;
|
||||
this.from = from;
|
||||
}
|
||||
|
||||
public void sendVerificationEmail(String to, String name, String token) {
|
||||
String link = baseUrl + "/verify-email?token=" + token;
|
||||
String displayName = name != null ? name : to.substring(0, to.indexOf('@'));
|
||||
String subject = "Verify your Indie account";
|
||||
String html = """
|
||||
<!DOCTYPE html>
|
||||
@@ -58,7 +62,7 @@ public class EmailService {
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
""".formatted(name, link);
|
||||
""".formatted(displayName, link);
|
||||
|
||||
sendHtml(to, subject, html);
|
||||
}
|
||||
@@ -67,6 +71,7 @@ public class EmailService {
|
||||
try {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
|
||||
helper.setFrom(from);
|
||||
helper.setTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(html, true);
|
||||
|
||||
@@ -31,7 +31,17 @@ public class LLMResponseParser {
|
||||
String json = extractJson(llmOutput);
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
return parseSiteStructure(root);
|
||||
return parseSiteStructure(root, true);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("Failed to parse LLM output as JSON: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public SiteStructure parseLenient(String llmOutput) {
|
||||
String json = extractJson(llmOutput);
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
return parseSiteStructure(root, false);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("Failed to parse LLM output as JSON: " + e.getMessage());
|
||||
}
|
||||
@@ -69,7 +79,7 @@ public class LLMResponseParser {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private SiteStructure parseSiteStructure(JsonNode root) {
|
||||
private SiteStructure parseSiteStructure(JsonNode root, boolean requirePages) {
|
||||
SiteStructure.SiteStructureBuilder builder = SiteStructure.builder();
|
||||
|
||||
if (root.has("theme")) {
|
||||
@@ -79,13 +89,13 @@ public class LLMResponseParser {
|
||||
List<SiteStructure.PageStructure> pages = new ArrayList<>();
|
||||
if (root.has("pages") && root.get("pages").isArray()) {
|
||||
for (JsonNode pageNode : root.get("pages")) {
|
||||
pages.add(parsePage(pageNode));
|
||||
pages.add(parsePage(pageNode, !requirePages));
|
||||
}
|
||||
}
|
||||
builder.pages(pages);
|
||||
|
||||
SiteStructure structure = builder.build();
|
||||
if (structure.getPages() == null || structure.getPages().isEmpty()) {
|
||||
if (requirePages && (structure.getPages() == null || structure.getPages().isEmpty())) {
|
||||
throw new IllegalArgumentException("LLM output must contain at least one page");
|
||||
}
|
||||
|
||||
@@ -118,7 +128,7 @@ public class LLMResponseParser {
|
||||
return map;
|
||||
}
|
||||
|
||||
private SiteStructure.PageStructure parsePage(JsonNode pageNode) {
|
||||
private SiteStructure.PageStructure parsePage(JsonNode pageNode, boolean lenient) {
|
||||
SiteStructure.PageStructure.PageStructureBuilder builder = SiteStructure.PageStructure.builder();
|
||||
|
||||
if (pageNode.has("id")) {
|
||||
@@ -145,9 +155,12 @@ public class LLMResponseParser {
|
||||
if (pageNode.has("rawHtml")) {
|
||||
builder.rawHtml(pageNode.get("rawHtml").asText());
|
||||
}
|
||||
if (pageNode.has("deleted")) {
|
||||
builder.deleted(pageNode.get("deleted").asBoolean());
|
||||
}
|
||||
|
||||
SiteStructure.PageStructure page = builder.build();
|
||||
if (page.getTitle() == null || page.getSlug() == null) {
|
||||
if (!lenient && !page.isDeleted() && (page.getTitle() == null || page.getSlug() == null)) {
|
||||
throw new IllegalArgumentException("Page must have title and slug");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,16 @@ import com.krrishg.dto.LLMOptions;
|
||||
import com.krrishg.dto.LLMResult;
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.dto.SuggestSectionsResponse;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
import com.krrishg.service.llm.LLMProvider;
|
||||
import com.krrishg.service.llm.ProviderFactory;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -28,8 +32,12 @@ import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -37,7 +45,6 @@ import java.util.stream.Collectors;
|
||||
public class LLMService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LLMService.class);
|
||||
private static final int MAX_VERSIONS_PER_SITE = 100;
|
||||
|
||||
private final ProviderFactory providerFactory;
|
||||
private final LLMResponseParser responseParser;
|
||||
@@ -47,6 +54,10 @@ public class LLMService {
|
||||
private final EncryptionService encryptionService;
|
||||
private final String systemPrompt;
|
||||
private final String refinerPrompt;
|
||||
private final String suggestSectionsPrompt;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final int maxVersionsPerSite;
|
||||
private final double refineTemperature;
|
||||
|
||||
public LLMService(ProviderFactory providerFactory,
|
||||
LLMResponseParser responseParser,
|
||||
@@ -56,7 +67,10 @@ public class LLMService {
|
||||
EncryptionService encryptionService,
|
||||
ResourceLoader resourceLoader,
|
||||
@Value("${indie.llm.system-prompt-path:classpath:prompts/site-generator.txt}") String promptPath,
|
||||
@Value("${indie.llm.refiner-prompt-path:classpath:prompts/site-refiner.txt}") String refinerPath) {
|
||||
@Value("${indie.llm.refiner-prompt-path:classpath:prompts/site-refiner.txt}") String refinerPath,
|
||||
@Value("${indie.llm.suggest-sections-prompt-path:classpath:prompts/suggest-sections.txt}") String suggestSectionsPath,
|
||||
@Value("${indie.llm.max-versions-per-site:10}") int maxVersionsPerSite,
|
||||
@Value("${indie.llm.refine.temperature:0.3}") double refineTemperature) {
|
||||
this.providerFactory = providerFactory;
|
||||
this.responseParser = responseParser;
|
||||
this.referenceService = referenceService;
|
||||
@@ -65,6 +79,10 @@ public class LLMService {
|
||||
this.encryptionService = encryptionService;
|
||||
this.systemPrompt = loadSystemPrompt(resourceLoader, promptPath);
|
||||
this.refinerPrompt = loadSystemPrompt(resourceLoader, refinerPath);
|
||||
this.suggestSectionsPrompt = loadSystemPrompt(resourceLoader, suggestSectionsPath);
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.maxVersionsPerSite = maxVersionsPerSite;
|
||||
this.refineTemperature = refineTemperature;
|
||||
}
|
||||
|
||||
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
|
||||
@@ -97,6 +115,35 @@ public class LLMService {
|
||||
return structure;
|
||||
}
|
||||
|
||||
public SuggestSectionsResponse suggestSections(UUID userId, String briefDescription,
|
||||
String provider, String apiKey) {
|
||||
return suggestSections(userId, briefDescription, provider, apiKey, null, null);
|
||||
}
|
||||
|
||||
public SuggestSectionsResponse suggestSections(UUID userId, String briefDescription,
|
||||
String provider, String apiKey, String baseUrl) {
|
||||
return suggestSections(userId, briefDescription, provider, apiKey, baseUrl, null);
|
||||
}
|
||||
|
||||
public SuggestSectionsResponse suggestSections(UUID userId, String briefDescription,
|
||||
String provider, String apiKey, String baseUrl, String model) {
|
||||
String resolvedKey = resolveApiKey(userId, provider, apiKey);
|
||||
String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl);
|
||||
String resolvedModel = resolveModel(userId, provider, model);
|
||||
|
||||
LLMOptions options = LLMOptions.builder()
|
||||
.temperature(0.2)
|
||||
.maxTokens(2000)
|
||||
.build();
|
||||
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel,
|
||||
suggestSectionsPrompt, briefDescription, null, options);
|
||||
log.info("Section suggestions completed: model={}, latency={}ms, tokens={}+{}",
|
||||
result.getModel(), result.getLatencyMs(),
|
||||
result.getPromptTokens(), result.getCompletionTokens());
|
||||
|
||||
return parseSectionSuggestions(result.getContent());
|
||||
}
|
||||
|
||||
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
|
||||
String refinementPrompt, List<Reference> references,
|
||||
String provider, String apiKey) {
|
||||
@@ -120,29 +167,54 @@ public class LLMService {
|
||||
String combinedPrompt = "Current site structure:\n" + currentJson
|
||||
+ "\n\nRefinement request: " + refinementPrompt
|
||||
+ referenceContext
|
||||
+ "\n\nOutput the complete updated JSON structure with the requested changes applied.";
|
||||
+ "\n\nOutput only the fields that changed. Fields you omit will remain unchanged."
|
||||
+ " Include the \"id\" field for any page you modify or add."
|
||||
+ " To delete a page, include it with \"id\" and \"deleted\": true."
|
||||
+ " Pages not included in your output will stay as-is.";
|
||||
|
||||
LLMOptions options = LLMOptions.builder()
|
||||
.temperature(0.5)
|
||||
.temperature(refineTemperature)
|
||||
.build();
|
||||
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, refinerPrompt, combinedPrompt, references, options);
|
||||
log.info("LLM refinement completed: model={}, latency={}ms, tokens={}+{}",
|
||||
result.getModel(), result.getLatencyMs(),
|
||||
result.getPromptTokens(), result.getCompletionTokens());
|
||||
|
||||
SiteStructure structure = responseParser.parse(result.getContent());
|
||||
saveVersion(userId, siteId, refinementPrompt, references, result.getContent());
|
||||
SiteStructure patch = responseParser.parseLenient(result.getContent());
|
||||
SiteStructure merged = mergePatches(currentStructure, patch);
|
||||
saveVersion(userId, siteId, refinementPrompt, references, serializeStructure(merged));
|
||||
|
||||
return structure;
|
||||
return merged;
|
||||
}
|
||||
|
||||
public List<GenerationVersion> getVersions(UUID siteId) {
|
||||
Query query = new Query(Criteria.where("siteId").is(siteId))
|
||||
.with(Sort.by(Sort.Direction.DESC, "versionNumber"))
|
||||
.limit(MAX_VERSIONS_PER_SITE);
|
||||
.limit(maxVersionsPerSite);
|
||||
return mongoTemplate.find(query, GenerationVersion.class);
|
||||
}
|
||||
|
||||
public int pruneVersions(UUID siteId) {
|
||||
return pruneVersions(siteId, maxVersionsPerSite);
|
||||
}
|
||||
|
||||
public int pruneVersions(UUID siteId, int keep) {
|
||||
Query countQuery = new Query(Criteria.where("siteId").is(siteId));
|
||||
long total = mongoTemplate.count(countQuery, GenerationVersion.class);
|
||||
if (total <= keep) {
|
||||
return 0;
|
||||
}
|
||||
long toDelete = total - keep;
|
||||
Query findQuery = new Query(Criteria.where("siteId").is(siteId))
|
||||
.with(Sort.by(Sort.Direction.ASC, "versionNumber"))
|
||||
.limit((int) toDelete);
|
||||
List<GenerationVersion> oldVersions = mongoTemplate.find(findQuery, GenerationVersion.class);
|
||||
for (GenerationVersion v : oldVersions) {
|
||||
mongoTemplate.remove(v);
|
||||
}
|
||||
return oldVersions.size();
|
||||
}
|
||||
|
||||
public SiteStructure restoreVersion(String versionId) {
|
||||
GenerationVersion version = mongoTemplate.findById(versionId, GenerationVersion.class);
|
||||
if (version == null) {
|
||||
@@ -159,6 +231,110 @@ public class LLMService {
|
||||
mongoTemplate.remove(version);
|
||||
}
|
||||
|
||||
private SiteStructure mergePatches(SiteStructure current, SiteStructure patch) {
|
||||
SiteStructure.SiteStructureBuilder builder = SiteStructure.builder();
|
||||
|
||||
if (patch.getTheme() != null) {
|
||||
builder.theme(mergeTheme(current.getTheme(), patch.getTheme()));
|
||||
} else {
|
||||
builder.theme(current.getTheme());
|
||||
}
|
||||
|
||||
if (patch.getPages() != null && !patch.getPages().isEmpty()) {
|
||||
Map<String, SiteStructure.PageStructure> currentPageMap = new HashMap<>();
|
||||
for (SiteStructure.PageStructure page : current.getPages()) {
|
||||
if (page.getId() != null) {
|
||||
currentPageMap.put(page.getId(), page);
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> deletedIds = new HashSet<>();
|
||||
for (SiteStructure.PageStructure patchPage : patch.getPages()) {
|
||||
if (patchPage.isDeleted() && patchPage.getId() != null) {
|
||||
deletedIds.add(patchPage.getId());
|
||||
}
|
||||
}
|
||||
|
||||
List<SiteStructure.PageStructure> mergedPages = new ArrayList<>();
|
||||
for (SiteStructure.PageStructure currentPage : current.getPages()) {
|
||||
if (currentPage.getId() != null && deletedIds.contains(currentPage.getId())) {
|
||||
continue;
|
||||
}
|
||||
SiteStructure.PageStructure matchingPatch = null;
|
||||
if (currentPage.getId() != null) {
|
||||
for (SiteStructure.PageStructure pp : patch.getPages()) {
|
||||
if (currentPage.getId().equals(pp.getId())) {
|
||||
matchingPatch = pp;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matchingPatch != null && !matchingPatch.isDeleted()) {
|
||||
mergedPages.add(mergePage(currentPage, matchingPatch));
|
||||
} else {
|
||||
mergedPages.add(currentPage);
|
||||
}
|
||||
}
|
||||
for (SiteStructure.PageStructure patchPage : patch.getPages()) {
|
||||
if (patchPage.isDeleted()) continue;
|
||||
String pid = patchPage.getId();
|
||||
if (pid == null || !currentPageMap.containsKey(pid)) {
|
||||
mergedPages.add(patchPage);
|
||||
}
|
||||
}
|
||||
builder.pages(mergedPages);
|
||||
} else {
|
||||
builder.pages(current.getPages());
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private SiteStructure.ThemeConfig mergeTheme(SiteStructure.ThemeConfig current, SiteStructure.ThemeConfig patch) {
|
||||
SiteStructure.ThemeConfig.ThemeConfigBuilder builder = SiteStructure.ThemeConfig.builder();
|
||||
|
||||
if (patch.getColors() != null) {
|
||||
Map<String, String> mergedColors = new HashMap<>(current.getColors() != null ? current.getColors() : Map.of());
|
||||
mergedColors.putAll(patch.getColors());
|
||||
builder.colors(mergedColors);
|
||||
} else {
|
||||
builder.colors(current.getColors());
|
||||
}
|
||||
|
||||
if (patch.getFonts() != null) {
|
||||
Map<String, String> mergedFonts = new HashMap<>(current.getFonts() != null ? current.getFonts() : Map.of());
|
||||
mergedFonts.putAll(patch.getFonts());
|
||||
builder.fonts(mergedFonts);
|
||||
} else {
|
||||
builder.fonts(current.getFonts());
|
||||
}
|
||||
|
||||
if (patch.getSpacing() != null) {
|
||||
Map<String, String> mergedSpacing = new HashMap<>(current.getSpacing() != null ? current.getSpacing() : Map.of());
|
||||
mergedSpacing.putAll(patch.getSpacing());
|
||||
builder.spacing(mergedSpacing);
|
||||
} else {
|
||||
builder.spacing(current.getSpacing());
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private SiteStructure.PageStructure mergePage(SiteStructure.PageStructure current, SiteStructure.PageStructure patch) {
|
||||
SiteStructure.PageStructure.PageStructureBuilder builder = SiteStructure.PageStructure.builder();
|
||||
|
||||
builder.id(patch.getId() != null ? patch.getId() : current.getId());
|
||||
builder.title(patch.getTitle() != null ? patch.getTitle() : current.getTitle());
|
||||
builder.slug(patch.getSlug() != null ? patch.getSlug() : current.getSlug());
|
||||
builder.seoTitle(patch.getSeoTitle() != null ? patch.getSeoTitle() : current.getSeoTitle());
|
||||
builder.seoDescription(patch.getSeoDescription() != null ? patch.getSeoDescription() : current.getSeoDescription());
|
||||
builder.orderIndex(patch.getOrderIndex() != 0 ? patch.getOrderIndex() : current.getOrderIndex());
|
||||
builder.rawHtml(patch.getRawHtml() != null ? patch.getRawHtml() : current.getRawHtml());
|
||||
builder.deleted(patch.isDeleted());
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private String resolveApiKey(UUID userId, String provider, String apiKey) {
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
return apiKey;
|
||||
@@ -204,6 +380,9 @@ public class LLMService {
|
||||
LLMProvider p = providerFactory.createProvider(provider, apiKey, baseUrl, model);
|
||||
try {
|
||||
return p.generate(systemPrompt, userPrompt, references, options);
|
||||
} catch (ResourceAccessException e) {
|
||||
throw new IllegalStateException(
|
||||
provider + " timed out. Please try again or use a simpler prompt.");
|
||||
} catch (HttpClientErrorException e) {
|
||||
String detail = e.getResponseBodyAsString();
|
||||
String msg = switch (e.getStatusCode().value()) {
|
||||
@@ -243,6 +422,7 @@ public class LLMService {
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
mongoTemplate.save(version);
|
||||
pruneVersions(siteId);
|
||||
}
|
||||
|
||||
private int getNextVersionNumber(UUID siteId) {
|
||||
@@ -278,4 +458,65 @@ public class LLMService {
|
||||
throw new RuntimeException("Failed to serialize site structure", e);
|
||||
}
|
||||
}
|
||||
|
||||
private SuggestSectionsResponse parseSectionSuggestions(String llmOutput) {
|
||||
String json = extractJsonArray(llmOutput);
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
List<SuggestSectionsResponse.SectionSuggestion> sections = new ArrayList<>();
|
||||
if (root.isArray()) {
|
||||
for (JsonNode node : root) {
|
||||
String title = node.has("title") ? node.get("title").asText() : null;
|
||||
String description = node.has("description") ? node.get("description").asText() : null;
|
||||
String slug = node.has("slug") ? node.get("slug").asText() : null;
|
||||
if (title != null && slug != null) {
|
||||
List<SuggestSectionsResponse.PatternSuggestion> patterns = new ArrayList<>();
|
||||
if (node.has("patterns") && node.get("patterns").isArray()) {
|
||||
for (JsonNode pn : node.get("patterns")) {
|
||||
String pt = pn.has("title") ? pn.get("title").asText() : null;
|
||||
String pd = pn.has("description") ? pn.get("description").asText() : null;
|
||||
if (pt != null) {
|
||||
patterns.add(new SuggestSectionsResponse.PatternSuggestion(pt, pd));
|
||||
}
|
||||
}
|
||||
}
|
||||
sections.add(new SuggestSectionsResponse.SectionSuggestion(title, description, slug, patterns));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sections.isEmpty()) {
|
||||
throw new IllegalArgumentException("LLM output must contain at least one section suggestion");
|
||||
}
|
||||
return new SuggestSectionsResponse(sections);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("Failed to parse section suggestions as JSON: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String extractJsonArray(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalArgumentException("LLM output is empty");
|
||||
}
|
||||
String trimmed = raw.trim();
|
||||
if (trimmed.startsWith("```")) {
|
||||
int start = trimmed.indexOf('\n');
|
||||
int end = trimmed.lastIndexOf("```");
|
||||
if (start > 0 && end > start) {
|
||||
trimmed = trimmed.substring(start, end).trim();
|
||||
}
|
||||
}
|
||||
if (!trimmed.startsWith("[")) {
|
||||
int bracket = trimmed.indexOf('[');
|
||||
if (bracket >= 0) {
|
||||
trimmed = trimmed.substring(bracket);
|
||||
}
|
||||
}
|
||||
if (!trimmed.endsWith("]")) {
|
||||
int bracket = trimmed.lastIndexOf(']');
|
||||
if (bracket >= 0) {
|
||||
trimmed = trimmed.substring(0, bracket + 1);
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,17 @@ public class ProviderFactory {
|
||||
|
||||
private final RestTemplateBuilder restTemplateBuilder;
|
||||
private final Map<String, ProviderConfig> providerConfigs;
|
||||
private final Duration readTimeout;
|
||||
|
||||
public ProviderFactory(
|
||||
@Value("${indie.llm.providers.gemini.model:gemini-3.1-flash-lite}") String geminiModel,
|
||||
@Value("${indie.llm.providers.gemini.url:https://generativelanguage.googleapis.com/v1beta/models}") String geminiUrl,
|
||||
@Value("${indie.llm.providers.openai.model:gpt-4o-mini}") String openaiModel,
|
||||
@Value("${indie.llm.providers.openai.url:https://api.openai.com/v1/chat/completions}") String openaiUrl,
|
||||
@Value("${indie.llm.read-timeout:180s}") Duration readTimeout,
|
||||
RestTemplateBuilder restTemplateBuilder) {
|
||||
this.restTemplateBuilder = restTemplateBuilder;
|
||||
this.readTimeout = readTimeout;
|
||||
this.providerConfigs = Map.of(
|
||||
"gemini", new ProviderConfig(geminiModel, geminiUrl),
|
||||
"openai", new ProviderConfig(openaiModel, openaiUrl)
|
||||
@@ -52,7 +55,7 @@ public class ProviderFactory {
|
||||
}
|
||||
RestTemplate rt = restTemplateBuilder
|
||||
.setConnectTimeout(Duration.ofSeconds(30))
|
||||
.setReadTimeout(Duration.ofSeconds(60))
|
||||
.setReadTimeout(readTimeout)
|
||||
.build();
|
||||
return switch (name) {
|
||||
case "gemini" -> new GeminiProvider(apiKey, resolvedModel, url, rt);
|
||||
|
||||
@@ -31,6 +31,7 @@ spring:
|
||||
port: ${EMAIL_PORT:1025}
|
||||
username: ${EMAIL_USERNAME:}
|
||||
password: ${EMAIL_PASSWORD:}
|
||||
from: ${EMAIL_FROM:mailme@krrishg.com}
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
@@ -55,9 +56,6 @@ indie:
|
||||
repos-path: ${INDIE_GIT_REPOS_PATH:/home/krrish/git/sites/}
|
||||
author-name: ${INDIE_GIT_AUTHOR_NAME:Indie Platform}
|
||||
author-email: ${INDIE_GIT_AUTHOR_EMAIL:git@indie.local}
|
||||
cors:
|
||||
allowed-origins: ${INDIE_CORS_ORIGINS:http://localhost:4200,http://localhost:3000}
|
||||
|
||||
storage:
|
||||
endpoint: ${INDIE_STORAGE_ENDPOINT:http://localhost:9000}
|
||||
region: ${INDIE_STORAGE_REGION:auto}
|
||||
@@ -70,10 +68,15 @@ indie:
|
||||
master-key-path: ${INDIE_ENCRYPTION_KEY_PATH:config/encryption.key}
|
||||
|
||||
app:
|
||||
base-url: ${INDIE_APP_BASE_URL:http://localhost:4200}
|
||||
base-url: ${INDIE_APP_BASE_URL:https://indie.krrishg.com}
|
||||
verification-token-expiration-minutes: ${INDIE_VERIFICATION_TOKEN_EXPIRATION:1440}
|
||||
|
||||
llm:
|
||||
read-timeout: ${INDIE_LLM_READ_TIMEOUT:180s}
|
||||
max-versions-per-site: ${INDIE_LLM_MAX_VERSIONS:10}
|
||||
refine:
|
||||
temperature: ${INDIE_LLM_REFINE_TEMPERATURE:0.3}
|
||||
read-timeout: ${INDIE_LLM_REFINE_READ_TIMEOUT:300s}
|
||||
rate-limit:
|
||||
max-requests-per-hour: ${INDIE_LLM_RATE_LIMIT:10}
|
||||
providers:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import org.apache.catalina.connector.ClientAbortException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
@@ -13,6 +15,19 @@ class GlobalExceptionHandlerTest {
|
||||
|
||||
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
|
||||
|
||||
@Test
|
||||
void jwtExceptionReturns401() {
|
||||
ProblemDetail detail = handler.handleJwtException(new JwtException("token expired"));
|
||||
|
||||
assertEquals(HttpStatus.UNAUTHORIZED.value(), detail.getStatus());
|
||||
assertEquals("token expired", detail.getDetail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientAbortDoesNotThrow() {
|
||||
handler.handleClientAbort(new ClientAbortException("client disconnected"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void illegalArgumentReturns400() {
|
||||
ProblemDetail detail = handler.handleBadRequest(new IllegalArgumentException("bad input"));
|
||||
|
||||
193
backend/src/test/java/com/krrishg/config/JwtConfigTest.java
Normal file
193
backend/src/test/java/com/krrishg/config/JwtConfigTest.java
Normal file
@@ -0,0 +1,193 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import com.krrishg.model.User;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPair;
|
||||
import java.util.UUID;
|
||||
|
||||
import static io.jsonwebtoken.Jwts.SIG.RS256;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JwtConfigTest {
|
||||
|
||||
@Test
|
||||
void generateAndValidateAccessToken() {
|
||||
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
|
||||
jwtConfig.init();
|
||||
|
||||
User user = User.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.email("test@example.com")
|
||||
.name("Test User")
|
||||
.build();
|
||||
|
||||
String token = jwtConfig.generateAccessToken(user);
|
||||
Claims claims = jwtConfig.validateToken(token);
|
||||
|
||||
assertEquals(user.getId().toString(), claims.getSubject());
|
||||
assertEquals("test@example.com", claims.get("email", String.class));
|
||||
assertEquals("Test User", claims.get("name", String.class));
|
||||
assertNotNull(claims.getIssuedAt());
|
||||
assertNotNull(claims.getExpiration());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateAndValidateRefreshToken() {
|
||||
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
|
||||
jwtConfig.init();
|
||||
|
||||
User user = User.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.email("refresh@example.com")
|
||||
.name("Refresh User")
|
||||
.build();
|
||||
|
||||
String token = jwtConfig.generateRefreshToken(user);
|
||||
Claims claims = jwtConfig.validateToken(token);
|
||||
|
||||
assertEquals(user.getId().toString(), claims.getSubject());
|
||||
assertEquals("refresh@example.com", claims.get("email", String.class));
|
||||
assertEquals("Refresh User", claims.get("name", String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleTokensForSameUserAreAllValid() {
|
||||
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
|
||||
jwtConfig.init();
|
||||
|
||||
User user = User.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.email("same@example.com")
|
||||
.build();
|
||||
|
||||
String token1 = jwtConfig.generateAccessToken(user);
|
||||
String token2 = jwtConfig.generateAccessToken(user);
|
||||
|
||||
assertDoesNotThrow(() -> jwtConfig.validateToken(token1));
|
||||
assertDoesNotThrow(() -> jwtConfig.validateToken(token2));
|
||||
Claims claims1 = jwtConfig.validateToken(token1);
|
||||
Claims claims2 = jwtConfig.validateToken(token2);
|
||||
assertEquals(user.getId().toString(), claims1.getSubject());
|
||||
assertEquals(user.getId().toString(), claims2.getSubject());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectTamperedToken() {
|
||||
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
|
||||
jwtConfig.init();
|
||||
|
||||
User user = User.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.email("tamper@example.com")
|
||||
.build();
|
||||
|
||||
String token = jwtConfig.generateAccessToken(user);
|
||||
String tampered = token.substring(0, token.lastIndexOf('.') + 1) + "tampered";
|
||||
|
||||
assertThrows(Exception.class, () -> jwtConfig.validateToken(tampered));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectExpiredToken() throws Exception {
|
||||
JwtConfig jwtConfig = new JwtConfig();
|
||||
setField(jwtConfig, "accessTokenExpiration", -1000L);
|
||||
jwtConfig.init();
|
||||
|
||||
User user = User.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.email("expired@example.com")
|
||||
.build();
|
||||
|
||||
String token = jwtConfig.generateAccessToken(user);
|
||||
|
||||
assertThrows(Exception.class, () -> jwtConfig.validateToken(token));
|
||||
}
|
||||
|
||||
@Test
|
||||
void initGeneratesEphemeralKeys() {
|
||||
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
|
||||
jwtConfig.init();
|
||||
|
||||
assertNotNull(jwtConfig.getPublicKey());
|
||||
User user = User.builder().id(UUID.randomUUID()).email("ephemeral@test.com").build();
|
||||
String token = jwtConfig.generateAccessToken(user);
|
||||
assertDoesNotThrow(() -> jwtConfig.validateToken(token));
|
||||
}
|
||||
|
||||
@Test
|
||||
void initLoadsKeysFromFiles(@TempDir Path tempDir) throws Exception {
|
||||
KeyPair keyPair = RS256.keyPair().build();
|
||||
Path privPath = tempDir.resolve("private.key");
|
||||
Path pubPath = tempDir.resolve("public.key");
|
||||
Files.write(privPath, keyPair.getPrivate().getEncoded());
|
||||
Files.write(pubPath, keyPair.getPublic().getEncoded());
|
||||
|
||||
JwtConfig jwtConfig = new JwtConfig();
|
||||
setField(jwtConfig, "privateKeyPath", privPath.toString());
|
||||
setField(jwtConfig, "publicKeyPath", pubPath.toString());
|
||||
setField(jwtConfig, "accessTokenExpiration", 900_000L);
|
||||
setField(jwtConfig, "refreshTokenExpiration", 604_800_000L);
|
||||
jwtConfig.init();
|
||||
|
||||
User user = User.builder().id(UUID.randomUUID()).email("file@test.com").name("File Test").build();
|
||||
String token = jwtConfig.generateAccessToken(user);
|
||||
Claims claims = jwtConfig.validateToken(token);
|
||||
assertEquals(user.getId().toString(), claims.getSubject());
|
||||
assertEquals("file@test.com", claims.get("email", String.class));
|
||||
assertEquals("File Test", claims.get("name", String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void initGeneratesAndSavesKeysWhenFilesDoNotExist(@TempDir Path tempDir) throws Exception {
|
||||
Path privPath = tempDir.resolve("private.key");
|
||||
Path pubPath = tempDir.resolve("public.key");
|
||||
|
||||
JwtConfig jwtConfig = new JwtConfig();
|
||||
setField(jwtConfig, "privateKeyPath", privPath.toString());
|
||||
setField(jwtConfig, "publicKeyPath", pubPath.toString());
|
||||
setField(jwtConfig, "accessTokenExpiration", 900_000L);
|
||||
setField(jwtConfig, "refreshTokenExpiration", 604_800_000L);
|
||||
jwtConfig.init();
|
||||
|
||||
assertTrue(Files.exists(privPath));
|
||||
assertTrue(Files.exists(pubPath));
|
||||
|
||||
User user = User.builder().id(UUID.randomUUID()).email("saved@test.com").build();
|
||||
String token = jwtConfig.generateAccessToken(user);
|
||||
assertDoesNotThrow(() -> jwtConfig.validateToken(token));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getExpirationReturnsConfiguredValues() throws Exception {
|
||||
JwtConfig jwtConfig = new JwtConfig();
|
||||
setField(jwtConfig, "accessTokenExpiration", 300_000L);
|
||||
setField(jwtConfig, "refreshTokenExpiration", 1_800_000L);
|
||||
|
||||
assertEquals(300_000L, jwtConfig.getAccessTokenExpiration());
|
||||
assertEquals(1_800_000L, jwtConfig.getRefreshTokenExpiration());
|
||||
}
|
||||
|
||||
private JwtConfig createWithExpiration(long access, long refresh) {
|
||||
JwtConfig jwtConfig = new JwtConfig();
|
||||
try {
|
||||
setField(jwtConfig, "accessTokenExpiration", access);
|
||||
setField(jwtConfig, "refreshTokenExpiration", refresh);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return jwtConfig;
|
||||
}
|
||||
|
||||
private void setField(Object target, String name, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.SQLException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
class SchemaMigrationTest {
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
@Test
|
||||
void migrateExecutesWithoutError() {
|
||||
SchemaMigration migration = new SchemaMigration(jdbcTemplate);
|
||||
assertDoesNotThrow(() -> migration.migrate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrateIsIdempotent() {
|
||||
SchemaMigration migration = new SchemaMigration(jdbcTemplate);
|
||||
migration.migrate();
|
||||
assertDoesNotThrow(() -> migration.migrate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sslColumnsExistOnSelfHostedConfigs() throws SQLException {
|
||||
SchemaMigration migration = new SchemaMigration(jdbcTemplate);
|
||||
migration.migrate();
|
||||
|
||||
assertTrue(columnExists("self_hosted_configs", "ssl_enabled"));
|
||||
assertTrue(columnExists("self_hosted_configs", "ssl_email"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptedSshKeyColumnIsDropped() throws SQLException {
|
||||
SchemaMigration migration = new SchemaMigration(jdbcTemplate);
|
||||
migration.migrate();
|
||||
|
||||
assertFalse(columnExists("self_hosted_configs", "encrypted_ssh_key"));
|
||||
}
|
||||
|
||||
private boolean columnExists(String table, String column) throws SQLException {
|
||||
try (var rs = dataSource.getConnection().getMetaData()
|
||||
.getColumns(null, null, table, column)) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
144
backend/src/test/java/com/krrishg/config/SecurityConfigTest.java
Normal file
144
backend/src/test/java/com/krrishg/config/SecurityConfigTest.java
Normal file
@@ -0,0 +1,144 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SecurityConfigTest {
|
||||
|
||||
@Mock
|
||||
private JwtConfig jwtConfig;
|
||||
|
||||
private SecurityConfig securityConfig;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
securityConfig = new SecurityConfig(jwtConfig);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void setsAuthenticationForValidToken() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
Claims claims = mock(Claims.class);
|
||||
when(claims.getSubject()).thenReturn(userId.toString());
|
||||
when(claims.get("email", String.class)).thenReturn("test@example.com");
|
||||
when(claims.get("name", String.class)).thenReturn("Test User");
|
||||
when(jwtConfig.validateToken("valid-token")).thenReturn(claims);
|
||||
|
||||
OncePerRequestFilter filter = getJwtFilter();
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("Authorization")).thenReturn("Bearer valid-token");
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
assertInstanceOf(JwtAuthenticationToken.class, auth);
|
||||
UserPrincipal principal = (UserPrincipal) auth.getPrincipal();
|
||||
assertEquals(userId, principal.id());
|
||||
assertEquals("test@example.com", principal.email());
|
||||
assertEquals("Test User", principal.name());
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearsContextOnInvalidToken() throws Exception {
|
||||
when(jwtConfig.validateToken(anyString())).thenThrow(new RuntimeException("bad token"));
|
||||
|
||||
OncePerRequestFilter filter = getJwtFilter();
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("Authorization")).thenReturn("Bearer bad-token");
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotAuthenticateWhenNoAuthHeader() throws Exception {
|
||||
OncePerRequestFilter filter = getJwtFilter();
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("Authorization")).thenReturn(null);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||
verify(chain).doFilter(request, response);
|
||||
verify(jwtConfig, never()).validateToken(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotAuthenticateForNonBearerScheme() throws Exception {
|
||||
OncePerRequestFilter filter = getJwtFilter();
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("Authorization")).thenReturn("Basic credentials");
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||
verify(chain).doFilter(request, response);
|
||||
verify(jwtConfig, never()).validateToken(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void filterCallsDoFilterOnChain() throws Exception {
|
||||
OncePerRequestFilter filter = getJwtFilter();
|
||||
var request = mock(HttpServletRequest.class);
|
||||
var response = mock(HttpServletResponse.class);
|
||||
var chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void alwaysCallsDoFilterEvenOnException() throws Exception {
|
||||
OncePerRequestFilter filter = getJwtFilter();
|
||||
var request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("Authorization")).thenReturn("Bearer token");
|
||||
when(jwtConfig.validateToken(anyString())).thenThrow(new RuntimeException("fail"));
|
||||
var response = mock(HttpServletResponse.class);
|
||||
var chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
|
||||
private OncePerRequestFilter getJwtFilter() throws Exception {
|
||||
Method method = SecurityConfig.class.getDeclaredMethod("jwtAuthenticationFilter");
|
||||
method.setAccessible(true);
|
||||
return (OncePerRequestFilter) method.invoke(securityConfig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class StorageConfigTest {
|
||||
|
||||
@Test
|
||||
void s3ClientIsCreatedWithConfiguration() throws Exception {
|
||||
StorageConfig config = new StorageConfig();
|
||||
setField(config, "endpoint", "http://localhost:9000");
|
||||
setField(config, "region", "us-east-1");
|
||||
setField(config, "accessKey", "test-access-key");
|
||||
setField(config, "secretKey", "test-secret-key");
|
||||
|
||||
S3Client client = config.s3Client();
|
||||
assertNotNull(client);
|
||||
client.close();
|
||||
}
|
||||
|
||||
private void setField(Object target, String name, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SuggestRateLimitConfigTest {
|
||||
|
||||
private SuggestRateLimitConfig config;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
config = new SuggestRateLimitConfig(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tryConsumeReturnsTrueWithinLimit() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
assertTrue(config.tryConsume(userId));
|
||||
assertTrue(config.tryConsume(userId));
|
||||
assertTrue(config.tryConsume(userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tryConsumeReturnsFalseWhenExceeded() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
assertTrue(config.tryConsume(userId));
|
||||
assertTrue(config.tryConsume(userId));
|
||||
assertTrue(config.tryConsume(userId));
|
||||
assertFalse(config.tryConsume(userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentUsersHaveIndependentLimits() {
|
||||
UUID userA = UUID.randomUUID();
|
||||
UUID userB = UUID.randomUUID();
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
assertTrue(config.tryConsume(userA));
|
||||
}
|
||||
assertFalse(config.tryConsume(userA));
|
||||
|
||||
assertTrue(config.tryConsume(userB));
|
||||
assertTrue(config.tryConsume(userB));
|
||||
assertTrue(config.tryConsume(userB));
|
||||
assertFalse(config.tryConsume(userB));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAvailableTokensReturnsRemaining() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
assertEquals(3, config.getAvailableTokens(userId));
|
||||
|
||||
config.tryConsume(userId);
|
||||
assertEquals(2, config.getAvailableTokens(userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAvailableTokensReturnsZeroWhenExhausted() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
config.tryConsume(userId);
|
||||
}
|
||||
assertEquals(0, config.getAvailableTokens(userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructorWithDefaultRate() {
|
||||
SuggestRateLimitConfig defaultConfig = new SuggestRateLimitConfig(30);
|
||||
UUID userId = UUID.randomUUID();
|
||||
assertEquals(30, defaultConfig.getAvailableTokens(userId));
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,6 @@ class AuthControllerTest {
|
||||
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("test@example.com");
|
||||
request.setPassword("password123");
|
||||
request.setName("Test");
|
||||
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -218,6 +216,7 @@ class AuthControllerTest {
|
||||
SetPasswordRequest request = new SetPasswordRequest();
|
||||
request.setSetupToken("setup-token-123");
|
||||
request.setPassword("new-password");
|
||||
request.setName("Test");
|
||||
|
||||
mockMvc.perform(post("/api/auth/set-password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -233,6 +232,7 @@ class AuthControllerTest {
|
||||
SetPasswordRequest request = new SetPasswordRequest();
|
||||
request.setSetupToken("bad-token");
|
||||
request.setPassword("new-password");
|
||||
request.setName("Test");
|
||||
|
||||
mockMvc.perform(post("/api/auth/set-password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
|
||||
@@ -3,9 +3,11 @@ package com.krrishg.controller;
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.RateLimitConfig;
|
||||
import com.krrishg.config.SuggestRateLimitConfig;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.dto.SuggestSectionsResponse;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.service.LLMService;
|
||||
import com.krrishg.support.TestSecurityConfig;
|
||||
@@ -45,6 +47,9 @@ class LLMControllerTest {
|
||||
@MockBean
|
||||
private RateLimitConfig rateLimitConfig;
|
||||
|
||||
@MockBean
|
||||
private SuggestRateLimitConfig suggestRateLimitConfig;
|
||||
|
||||
private UUID userId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
@@ -219,6 +224,22 @@ class LLMControllerTest {
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("No API key configured for gemini"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenSiteIdMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -296,6 +317,171 @@ class LLMControllerTest {
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenPromptMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"currentStructure", SiteStructure.builder().build()
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/refine")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenSiteIdMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"provider", "gemini",
|
||||
"currentStructure", SiteStructure.builder().build()
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/refine")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenCurrentStructureInvalid() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"currentStructure", "not-a-valid-structure"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/refine")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenCurrentStructureNull() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/refine")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class SuggestSections {
|
||||
|
||||
@Test
|
||||
void returns200WithSuggestions() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
SuggestSectionsResponse response = new SuggestSectionsResponse(
|
||||
List.of(
|
||||
new SuggestSectionsResponse.SectionSuggestion("Home", "Hero section", "home", List.of()),
|
||||
new SuggestSectionsResponse.SectionSuggestion("About", "Bio", "about", List.of())
|
||||
)
|
||||
);
|
||||
when(llmService.suggestSections(any(), anyString(), anyString(), any(), any(), any())).thenReturn(response);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"briefDescription", "photography portfolio",
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.sections[0].title").value("Home"))
|
||||
.andExpect(jsonPath("$.sections[1].slug").value("about"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns429WhenRateLimited() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(false);
|
||||
when(suggestRateLimitConfig.getAvailableTokens(userId)).thenReturn(0);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"briefDescription", "portfolio",
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isTooManyRequests());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenBriefDescriptionMissing() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenProviderMissing() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"briefDescription", "portfolio"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenNoKeyConfigured() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
when(llmService.suggestSections(any(), anyString(), anyString(), any(), any(), any()))
|
||||
.thenThrow(new IllegalStateException("No API key configured for gemini"));
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"briefDescription", "portfolio",
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("No API key configured for gemini"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -383,4 +569,45 @@ class LLMControllerTest {
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class PruneVersions {
|
||||
|
||||
@Test
|
||||
void returns200WithDefaultKeep() throws Exception {
|
||||
when(llmService.pruneVersions(any(UUID.class), eq(10))).thenReturn(3);
|
||||
|
||||
UUID siteId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/llm/versions/{siteId}/prune", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.deleted").value(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns200WithCustomKeep() throws Exception {
|
||||
when(llmService.pruneVersions(any(UUID.class), eq(5))).thenReturn(2);
|
||||
|
||||
UUID siteId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/llm/versions/{siteId}/prune", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("keep", 5))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.deleted").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns200WithNoRequestBody() throws Exception {
|
||||
when(llmService.pruneVersions(any(UUID.class), eq(10))).thenReturn(0);
|
||||
|
||||
UUID siteId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/llm/versions/{siteId}/prune", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.deleted").value(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ class LLMOptionsTest {
|
||||
LLMOptions options = LLMOptions.builder().build();
|
||||
|
||||
assertEquals(0.9, options.getTemperature());
|
||||
assertEquals(4096, options.getMaxTokens());
|
||||
assertEquals(16384, options.getMaxTokens());
|
||||
assertEquals(60, options.getTimeoutSeconds());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class VerificationTokenTest {
|
||||
|
||||
@Test
|
||||
void prePersistSetsIdAndCreatedAt() {
|
||||
VerificationToken token = new VerificationToken();
|
||||
token.setUserId(UUID.randomUUID());
|
||||
token.setToken("verify-token");
|
||||
token.setExpiryDate(LocalDateTime.now().plusHours(1));
|
||||
|
||||
assertNull(token.getId());
|
||||
assertNull(token.getCreatedAt());
|
||||
|
||||
token.onCreate();
|
||||
|
||||
assertNotNull(token.getId());
|
||||
assertNotNull(token.getCreatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prePersistDoesNotOverrideExistingId() {
|
||||
UUID existingId = UUID.randomUUID();
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.id(existingId)
|
||||
.userId(UUID.randomUUID())
|
||||
.token("test-token")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.build();
|
||||
|
||||
token.onCreate();
|
||||
|
||||
assertEquals(existingId, token.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderDefaults() {
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.token("test-token")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.build();
|
||||
|
||||
assertFalse(token.isUsed());
|
||||
assertFalse(token.isSetupTokenUsed());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.VerificationToken;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DataJpaTest
|
||||
class VerificationTokenRepositoryTest {
|
||||
|
||||
private final VerificationTokenRepository repository;
|
||||
|
||||
VerificationTokenRepositoryTest(@Autowired VerificationTokenRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndFindByToken() {
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.token("verify-token-123")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.build();
|
||||
repository.save(token);
|
||||
|
||||
Optional<VerificationToken> found = repository.findByToken("verify-token-123");
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals(token.getUserId(), found.get().getUserId());
|
||||
assertFalse(found.get().isUsed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByTokenReturnsEmptyWhenNotFound() {
|
||||
Optional<VerificationToken> found = repository.findByToken("nonexistent");
|
||||
|
||||
assertTrue(found.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBySetupToken() {
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.token("verify-token")
|
||||
.setupToken("setup-token-456")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.build();
|
||||
repository.save(token);
|
||||
|
||||
Optional<VerificationToken> found = repository.findBySetupToken("setup-token-456");
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("verify-token", found.get().getToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBySetupTokenReturnsEmptyWhenNotFound() {
|
||||
Optional<VerificationToken> found = repository.findBySetupToken("nonexistent");
|
||||
|
||||
assertTrue(found.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteByUserIdRemovesToken() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(userId)
|
||||
.token("delete-me")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.build();
|
||||
repository.save(token);
|
||||
|
||||
repository.deleteByUserId(userId);
|
||||
|
||||
assertTrue(repository.findByToken("delete-me").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByUsedFalseAndExpiryDateBeforeReturnsExpiredTokens() {
|
||||
VerificationToken expired = VerificationToken.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.token("expired-token")
|
||||
.expiryDate(LocalDateTime.now().minusHours(1))
|
||||
.used(false)
|
||||
.build();
|
||||
VerificationToken valid = VerificationToken.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.token("valid-token")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.used(false)
|
||||
.build();
|
||||
repository.save(expired);
|
||||
repository.save(valid);
|
||||
|
||||
var expiredTokens = repository.findByUsedFalseAndExpiryDateBefore(LocalDateTime.now());
|
||||
|
||||
assertEquals(1, expiredTokens.size());
|
||||
assertEquals("expired-token", expiredTokens.get(0).getToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedEntityHasIdAndCreatedAt() {
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.token("new-token")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.build();
|
||||
VerificationToken saved = repository.save(token);
|
||||
|
||||
assertNotNull(saved.getId());
|
||||
assertNotNull(saved.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,6 @@ class AuthServiceTest {
|
||||
void registerCreatesUserAndSendsVerification() {
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("new@example.com");
|
||||
request.setPassword("password123");
|
||||
request.setName("New User");
|
||||
|
||||
RegistrationResponse response = authService.register(request);
|
||||
|
||||
@@ -53,12 +51,21 @@ class AuthServiceTest {
|
||||
assertFalse(userRepository.findByEmail("new@example.com").get().isEmailVerified());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerDoesNotSetName() {
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("noname@example.com");
|
||||
|
||||
authService.register(request);
|
||||
|
||||
User user = userRepository.findByEmail("noname@example.com").get();
|
||||
assertNull(user.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerSendsVerificationEmail() {
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("emailtest@example.com");
|
||||
request.setPassword("password123");
|
||||
request.setName("Email Test");
|
||||
|
||||
authService.register(request);
|
||||
|
||||
@@ -79,8 +86,6 @@ class AuthServiceTest {
|
||||
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("verified@example.com");
|
||||
request.setPassword("password123");
|
||||
request.setName("Should Not Matter");
|
||||
|
||||
RegistrationResponse response = authService.register(request);
|
||||
|
||||
@@ -100,8 +105,6 @@ class AuthServiceTest {
|
||||
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("unverified@example.com");
|
||||
request.setPassword("newpassword");
|
||||
request.setName("New Name");
|
||||
|
||||
RegistrationResponse response = authService.register(request);
|
||||
|
||||
@@ -114,8 +117,6 @@ class AuthServiceTest {
|
||||
void registerNormalizesEmailCase() {
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("UpperCase@Example.com");
|
||||
request.setPassword("password123");
|
||||
request.setName("User");
|
||||
|
||||
authService.register(request);
|
||||
|
||||
@@ -126,11 +127,10 @@ class AuthServiceTest {
|
||||
void loginWithValidCredentialsAndVerifiedEmailReturnsTokens() {
|
||||
RegisterRequest reg = new RegisterRequest();
|
||||
reg.setEmail("login@example.com");
|
||||
reg.setPassword("password123");
|
||||
reg.setName("Login User");
|
||||
authService.register(reg);
|
||||
|
||||
User user = userRepository.findByEmail("login@example.com").get();
|
||||
user.setPassword("{stub}password123");
|
||||
user.setEmailVerified(true);
|
||||
userRepository.save(user);
|
||||
|
||||
@@ -149,8 +149,6 @@ class AuthServiceTest {
|
||||
void loginThrowsWhenEmailNotVerified() {
|
||||
RegisterRequest reg = new RegisterRequest();
|
||||
reg.setEmail("unverified@example.com");
|
||||
reg.setPassword("password123");
|
||||
reg.setName("Unverified");
|
||||
authService.register(reg);
|
||||
|
||||
LoginRequest login = new LoginRequest();
|
||||
@@ -166,11 +164,10 @@ class AuthServiceTest {
|
||||
void loginThrowsOnWrongPassword() {
|
||||
RegisterRequest reg = new RegisterRequest();
|
||||
reg.setEmail("wrongpw@example.com");
|
||||
reg.setPassword("correctpw");
|
||||
reg.setName("User");
|
||||
authService.register(reg);
|
||||
|
||||
User user = userRepository.findByEmail("wrongpw@example.com").get();
|
||||
user.setPassword("{stub}correctpw");
|
||||
user.setEmailVerified(true);
|
||||
userRepository.save(user);
|
||||
|
||||
@@ -315,11 +312,13 @@ class AuthServiceTest {
|
||||
SetPasswordRequest request = new SetPasswordRequest();
|
||||
request.setSetupToken("setup-token-123");
|
||||
request.setPassword("new-password");
|
||||
request.setName("New Name");
|
||||
|
||||
authService.setPassword(request);
|
||||
|
||||
User updatedUser = userRepository.findById(user.getId()).get();
|
||||
assertTrue(updatedUser.getPassword().contains("new-password"));
|
||||
assertEquals("New Name", updatedUser.getName());
|
||||
|
||||
VerificationToken updatedToken = tokenRepository.findByToken("verify-token").get();
|
||||
assertTrue(updatedToken.isSetupTokenUsed());
|
||||
@@ -480,8 +479,6 @@ class AuthServiceTest {
|
||||
void refreshTokenReturnsNewTokens() {
|
||||
RegisterRequest reg = new RegisterRequest();
|
||||
reg.setEmail("refresh@example.com");
|
||||
reg.setPassword("password123");
|
||||
reg.setName("Refresh User");
|
||||
authService.register(reg);
|
||||
|
||||
User user = userRepository.findByEmail("refresh@example.com").get();
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.model.VerificationToken;
|
||||
import com.krrishg.support.FakeUserRepository;
|
||||
import com.krrishg.support.FakeVerificationTokenRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CleanupServiceTest {
|
||||
|
||||
private FakeUserRepository userRepository;
|
||||
private FakeVerificationTokenRepository tokenRepository;
|
||||
private CleanupService cleanupService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userRepository = new FakeUserRepository();
|
||||
tokenRepository = new FakeVerificationTokenRepository();
|
||||
cleanupService = new CleanupService(userRepository, tokenRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletesUnverifiedUserWithExpiredToken() {
|
||||
User user = User.builder()
|
||||
.email("stale@example.com")
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(user.getId())
|
||||
.token("expired-token")
|
||||
.used(false)
|
||||
.expiryDate(LocalDateTime.now().minusDays(3))
|
||||
.build();
|
||||
tokenRepository.save(token);
|
||||
|
||||
cleanupService.cleanupStaleUnverifiedAccounts();
|
||||
|
||||
assertTrue(userRepository.findById(user.getId()).isEmpty());
|
||||
assertTrue(tokenRepository.findByToken("expired-token").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesVerifiedUserWithExpiredToken() {
|
||||
User user = User.builder()
|
||||
.email("verified@example.com")
|
||||
.emailVerified(true)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(user.getId())
|
||||
.token("verified-expired")
|
||||
.used(false)
|
||||
.expiryDate(LocalDateTime.now().minusDays(3))
|
||||
.build();
|
||||
tokenRepository.save(token);
|
||||
|
||||
cleanupService.cleanupStaleUnverifiedAccounts();
|
||||
|
||||
assertTrue(userRepository.findById(user.getId()).isPresent());
|
||||
assertTrue(tokenRepository.findByToken("verified-expired").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesUnverifiedUserWithValidToken() {
|
||||
User user = User.builder()
|
||||
.email("pending@example.com")
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(user.getId())
|
||||
.token("valid-token")
|
||||
.used(false)
|
||||
.expiryDate(LocalDateTime.now().plusDays(1))
|
||||
.build();
|
||||
tokenRepository.save(token);
|
||||
|
||||
cleanupService.cleanupStaleUnverifiedAccounts();
|
||||
|
||||
assertTrue(userRepository.findById(user.getId()).isPresent());
|
||||
assertTrue(tokenRepository.findByToken("valid-token").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNothingWhenNoStaleTokens() {
|
||||
cleanupService.cleanupStaleUnverifiedAccounts();
|
||||
|
||||
assertEquals(0, userRepository.count());
|
||||
assertEquals(0, tokenRepository.count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleansUpMultipleStaleUsers() {
|
||||
User user1 = User.builder()
|
||||
.email("stale1@example.com")
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
user1.onCreate();
|
||||
userRepository.save(user1);
|
||||
|
||||
User user2 = User.builder()
|
||||
.email("stale2@example.com")
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
user2.onCreate();
|
||||
userRepository.save(user2);
|
||||
|
||||
VerificationToken token1 = VerificationToken.builder()
|
||||
.userId(user1.getId())
|
||||
.token("stale-token-1")
|
||||
.used(false)
|
||||
.expiryDate(LocalDateTime.now().minusDays(3))
|
||||
.build();
|
||||
tokenRepository.save(token1);
|
||||
|
||||
VerificationToken token2 = VerificationToken.builder()
|
||||
.userId(user2.getId())
|
||||
.token("stale-token-2")
|
||||
.used(false)
|
||||
.expiryDate(LocalDateTime.now().minusDays(3))
|
||||
.build();
|
||||
tokenRepository.save(token2);
|
||||
|
||||
cleanupService.cleanupStaleUnverifiedAccounts();
|
||||
|
||||
assertTrue(userRepository.findById(user1.getId()).isEmpty());
|
||||
assertTrue(userRepository.findById(user2.getId()).isEmpty());
|
||||
assertTrue(tokenRepository.findByToken("stale-token-1").isEmpty());
|
||||
assertTrue(tokenRepository.findByToken("stale-token-2").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsUsedToken() {
|
||||
User user = User.builder()
|
||||
.email("used-token@example.com")
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(user.getId())
|
||||
.token("used-token")
|
||||
.used(true)
|
||||
.expiryDate(LocalDateTime.now().minusDays(3))
|
||||
.build();
|
||||
tokenRepository.save(token);
|
||||
|
||||
cleanupService.cleanupStaleUnverifiedAccounts();
|
||||
|
||||
assertTrue(userRepository.findById(user.getId()).isPresent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Properties;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class EmailServiceTest {
|
||||
|
||||
@Mock
|
||||
private JavaMailSender mailSender;
|
||||
|
||||
private EmailService emailService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
emailService = new EmailService(mailSender, "http://localhost:4200", "noreply@indie.app");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsEmailWithCorrectRecipientsAndSubject() throws Exception {
|
||||
MimeMessage message = new MimeMessage(Session.getDefaultInstance(new Properties()));
|
||||
when(mailSender.createMimeMessage()).thenReturn(message);
|
||||
|
||||
emailService.sendVerificationEmail("user@example.com", "User", "token123");
|
||||
|
||||
verify(mailSender).send(message);
|
||||
String raw = writeMessage(message);
|
||||
assertTrue(raw.contains("From: noreply@indie.app"));
|
||||
assertTrue(raw.contains("To: user@example.com"));
|
||||
assertTrue(raw.contains("Subject: Verify your Indie account"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emailBodyContainsVerificationLink() throws Exception {
|
||||
MimeMessage message = new MimeMessage(Session.getDefaultInstance(new Properties()));
|
||||
when(mailSender.createMimeMessage()).thenReturn(message);
|
||||
|
||||
emailService.sendVerificationEmail("user@example.com", "User", "test-token-123");
|
||||
|
||||
String raw = writeMessage(message);
|
||||
assertTrue(raw.contains("http://localhost:4200/verify-email?token=test-token-123"));
|
||||
assertTrue(raw.contains("Hi User,"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesEmailPrefixWhenNameIsNull() throws Exception {
|
||||
MimeMessage message = new MimeMessage(Session.getDefaultInstance(new Properties()));
|
||||
when(mailSender.createMimeMessage()).thenReturn(message);
|
||||
|
||||
emailService.sendVerificationEmail("john.doe@example.com", null, "token123");
|
||||
|
||||
String raw = writeMessage(message);
|
||||
assertTrue(raw.contains("Hi john.doe,"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesExceptionFromMailSender() {
|
||||
when(mailSender.createMimeMessage()).thenThrow(new RuntimeException("mail error"));
|
||||
|
||||
RuntimeException ex = assertThrows(RuntimeException.class,
|
||||
() -> emailService.sendVerificationEmail("user@example.com", "User", "token123"));
|
||||
assertEquals("mail error", ex.getMessage());
|
||||
}
|
||||
|
||||
private String writeMessage(MimeMessage message) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
message.writeTo(baos);
|
||||
return baos.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.krrishg.service;
|
||||
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.dto.SuggestSectionsResponse;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.support.FakeEncryptionService;
|
||||
@@ -82,7 +83,9 @@ class LLMServiceTest {
|
||||
llmService = new LLMService(providerFactory, responseParser, referenceService,
|
||||
mongoTemplate, userRepository, encryptionService,
|
||||
resourceLoader, "classpath:prompts/site-generator.txt",
|
||||
"classpath:prompts/site-refiner.txt");
|
||||
"classpath:prompts/site-refiner.txt",
|
||||
"classpath:prompts/suggest-sections.txt",
|
||||
10, 0.3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -210,9 +213,11 @@ class LLMServiceTest {
|
||||
|
||||
@Test
|
||||
void refineSiteReturnsRefinedStructure() {
|
||||
String pageId = UUID.randomUUID().toString();
|
||||
SiteStructure current = SiteStructure.builder()
|
||||
.pages(List.of(
|
||||
SiteStructure.PageStructure.builder()
|
||||
.id(pageId)
|
||||
.title("Old")
|
||||
.slug("old")
|
||||
.rawHtml("<p>Old content</p>")
|
||||
@@ -223,16 +228,202 @@ class LLMServiceTest {
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"title": "Refined", "slug": "refined", "rawHtml": "<p>Hero</p>"}
|
||||
{"id": "__PAGE_ID__", "title": "Refined", "slug": "refined", "rawHtml": "<p>Updated</p>"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
""".replace("__PAGE_ID__", pageId));
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
|
||||
SiteStructure result = llmService.refineSite(userId, siteId, current, "Make it better", null, "gemini", null);
|
||||
|
||||
assertEquals("Refined", result.getPages().get(0).getTitle());
|
||||
assertEquals("<p>Updated</p>", result.getPages().get(0).getRawHtml());
|
||||
verify(mongoTemplate).save(any(GenerationVersion.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refineSitePreservesUnchangedPages() {
|
||||
String pageId1 = UUID.randomUUID().toString();
|
||||
String pageId2 = UUID.randomUUID().toString();
|
||||
SiteStructure current = SiteStructure.builder()
|
||||
.theme(SiteStructure.ThemeConfig.builder()
|
||||
.colors(Map.of("primary", "#000", "background", "#fff"))
|
||||
.build())
|
||||
.pages(List.of(
|
||||
SiteStructure.PageStructure.builder()
|
||||
.id(pageId1)
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.rawHtml("<p>Home content</p>")
|
||||
.build(),
|
||||
SiteStructure.PageStructure.builder()
|
||||
.id(pageId2)
|
||||
.title("About")
|
||||
.slug("about")
|
||||
.rawHtml("<p>About content</p>")
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"theme": {
|
||||
"colors": {
|
||||
"primary": "#1a1a2e",
|
||||
"accent": "#e94560"
|
||||
}
|
||||
},
|
||||
"pages": [
|
||||
{"id": "__PAGE_ID__", "rawHtml": "<p>Updated home</p>"}
|
||||
]
|
||||
}
|
||||
""".replace("__PAGE_ID__", pageId1));
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
|
||||
SiteStructure result = llmService.refineSite(userId, siteId, current, "Change primary color", null, "gemini", null);
|
||||
|
||||
assertEquals("#1a1a2e", result.getTheme().getColors().get("primary"));
|
||||
assertEquals("#fff", result.getTheme().getColors().get("background"));
|
||||
assertNotNull(result.getTheme().getColors().get("accent"));
|
||||
|
||||
assertEquals(2, result.getPages().size());
|
||||
assertEquals("<p>Updated home</p>", result.getPages().get(0).getRawHtml());
|
||||
assertEquals("About", result.getPages().get(1).getTitle());
|
||||
assertEquals("<p>About content</p>", result.getPages().get(1).getRawHtml());
|
||||
verify(mongoTemplate).save(any(GenerationVersion.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refineSiteDeletesPage() {
|
||||
String pageId1 = UUID.randomUUID().toString();
|
||||
String pageId2 = UUID.randomUUID().toString();
|
||||
SiteStructure current = SiteStructure.builder()
|
||||
.pages(List.of(
|
||||
SiteStructure.PageStructure.builder()
|
||||
.id(pageId1)
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.rawHtml("<p>Home</p>")
|
||||
.build(),
|
||||
SiteStructure.PageStructure.builder()
|
||||
.id(pageId2)
|
||||
.title("About")
|
||||
.slug("about")
|
||||
.rawHtml("<p>About</p>")
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"id": "__PAGE_ID__", "deleted": true}
|
||||
]
|
||||
}
|
||||
""".replace("__PAGE_ID__", pageId1));
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
|
||||
SiteStructure result = llmService.refineSite(userId, siteId, current, "Remove Home page", null, "gemini", null);
|
||||
|
||||
assertEquals(1, result.getPages().size());
|
||||
assertEquals("About", result.getPages().get(0).getTitle());
|
||||
verify(mongoTemplate).save(any(GenerationVersion.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refineSiteAddsNewPage() {
|
||||
String existingId = UUID.randomUUID().toString();
|
||||
SiteStructure current = SiteStructure.builder()
|
||||
.pages(List.of(
|
||||
SiteStructure.PageStructure.builder()
|
||||
.id(existingId)
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.rawHtml("<p>Home</p>")
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"title": "Contact", "slug": "contact", "rawHtml": "<p>Contact form</p>"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
|
||||
SiteStructure result = llmService.refineSite(userId, siteId, current, "Add Contact page", null, "gemini", null);
|
||||
|
||||
assertEquals(2, result.getPages().size());
|
||||
assertEquals("Contact", result.getPages().get(1).getTitle());
|
||||
verify(mongoTemplate).save(any(GenerationVersion.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pruneVersionsDeletesOldestVersions() {
|
||||
lenient().when(mongoTemplate.count(any(Query.class), eq(GenerationVersion.class))).thenReturn(15L);
|
||||
List<GenerationVersion> oldVersions = List.of(
|
||||
GenerationVersion.builder().id("v1").siteId(siteId).versionNumber(1).build(),
|
||||
GenerationVersion.builder().id("v2").siteId(siteId).versionNumber(2).build(),
|
||||
GenerationVersion.builder().id("v3").siteId(siteId).versionNumber(3).build(),
|
||||
GenerationVersion.builder().id("v4").siteId(siteId).versionNumber(4).build(),
|
||||
GenerationVersion.builder().id("v5").siteId(siteId).versionNumber(5).build()
|
||||
);
|
||||
lenient().when(mongoTemplate.find(any(Query.class), eq(GenerationVersion.class))).thenReturn(oldVersions);
|
||||
|
||||
int deleted = llmService.pruneVersions(siteId, 10);
|
||||
|
||||
assertEquals(5, deleted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pruneVersionsDoesNothingWhenUnderLimit() {
|
||||
lenient().when(mongoTemplate.count(any(Query.class), eq(GenerationVersion.class))).thenReturn(8L);
|
||||
|
||||
int deleted = llmService.pruneVersions(siteId, 10);
|
||||
|
||||
assertEquals(0, deleted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveVersionTriggersAutoCleanup() {
|
||||
String pageId = UUID.randomUUID().toString();
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
lenient().when(mongoTemplate.count(any(Query.class), eq(GenerationVersion.class))).thenReturn(15L);
|
||||
lenient().when(mongoTemplate.find(any(Query.class), eq(GenerationVersion.class))).thenReturn(List.of(
|
||||
GenerationVersion.builder().id("old").siteId(siteId).versionNumber(1).build(),
|
||||
GenerationVersion.builder().id("older").siteId(siteId).versionNumber(2).build(),
|
||||
GenerationVersion.builder().id("oldest").siteId(siteId).versionNumber(3).build(),
|
||||
GenerationVersion.builder().id("ancient").siteId(siteId).versionNumber(4).build(),
|
||||
GenerationVersion.builder().id("prehistoric").siteId(siteId).versionNumber(5).build()
|
||||
));
|
||||
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"id": "__PAGE_ID__", "title": "Home", "slug": "home", "rawHtml": "<p>Test</p>"}
|
||||
]
|
||||
}
|
||||
""".replace("__PAGE_ID__", pageId));
|
||||
|
||||
SiteStructure current = SiteStructure.builder()
|
||||
.pages(List.of(
|
||||
SiteStructure.PageStructure.builder()
|
||||
.id(pageId)
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.rawHtml("<p>Old</p>")
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
|
||||
llmService.refineSite(userId, siteId, current, "Update", null, "gemini", null);
|
||||
|
||||
verify(mongoTemplate).save(any(GenerationVersion.class));
|
||||
}
|
||||
|
||||
@@ -304,4 +495,57 @@ class LLMServiceTest {
|
||||
assertThrows(IllegalArgumentException.class, () -> llmService.deleteVersion("nonexistent"));
|
||||
verify(mongoTemplate, never()).remove(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestSectionsReturnsParsedSuggestions() {
|
||||
geminiProvider.withContent("""
|
||||
[
|
||||
{"title": "Home", "description": "Hero section", "slug": "home",
|
||||
"patterns": [
|
||||
{"title": "Full-screen hero", "description": "Large image with text overlay"},
|
||||
{"title": "Split layout", "description": "Text left, image right"}
|
||||
]},
|
||||
{"title": "About", "description": "Bio section", "slug": "about",
|
||||
"patterns": [
|
||||
{"title": "Timeline", "description": "Vertical story timeline"}
|
||||
]},
|
||||
{"title": "Gallery", "description": "Portfolio grid", "slug": "gallery", "patterns": []}
|
||||
]
|
||||
""");
|
||||
|
||||
SuggestSectionsResponse response = llmService.suggestSections(
|
||||
userId, "photography portfolio", "gemini", null);
|
||||
|
||||
assertEquals(3, response.sections().size());
|
||||
assertEquals("Home", response.sections().get(0).title());
|
||||
assertEquals("gallery", response.sections().get(2).slug());
|
||||
|
||||
assertEquals(2, response.sections().get(0).patterns().size());
|
||||
assertEquals("Full-screen hero", response.sections().get(0).patterns().get(0).title());
|
||||
assertEquals("Timeline", response.sections().get(1).patterns().get(0).title());
|
||||
assertEquals(0, response.sections().get(2).patterns().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestSectionsThrowsWhenEmpty() {
|
||||
geminiProvider.withContent("[]");
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> llmService.suggestSections(userId, "nothing", "gemini", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestSectionsUsesProvidedApiKey() {
|
||||
geminiProvider.withContent("""
|
||||
[
|
||||
{"title": "Home", "description": "Hero", "slug": "home", "patterns": []}
|
||||
]
|
||||
""");
|
||||
|
||||
SuggestSectionsResponse response = llmService.suggestSections(
|
||||
userId, "site", "gemini", "override-key");
|
||||
|
||||
assertEquals(1, response.sections().size());
|
||||
assertEquals("Home", response.sections().get(0).title());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.service.RenderService.SiteOutput;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@@ -48,6 +54,30 @@ class SshRsyncDeployerTest {
|
||||
assertEquals("https://example.com/", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildUrlHandlesDomainWithTrailingSlash() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.domain("example.com")
|
||||
.sslEnabled(true)
|
||||
.build();
|
||||
|
||||
String url = deployer.buildUrl(config);
|
||||
|
||||
assertEquals("https://example.com/", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildUrlHandlesIpAddress() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.domain("192.168.1.100")
|
||||
.sslEnabled(false)
|
||||
.build();
|
||||
|
||||
String url = deployer.buildUrl(config);
|
||||
|
||||
assertEquals("http://192.168.1.100/", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildNginxConfigWithSslIncludesRedirect() throws Exception {
|
||||
Method method = SshRsyncDeployer.class.getDeclaredMethod(
|
||||
@@ -76,4 +106,70 @@ class SshRsyncDeployerTest {
|
||||
assertFalse(config.contains("ssl_certificate"));
|
||||
assertFalse(config.contains("return 301"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildNginxConfigWithoutSslHasNoSslRedirect() throws Exception {
|
||||
Method method = SshRsyncDeployer.class.getDeclaredMethod(
|
||||
"buildNginxConfig", String.class, String.class, boolean.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String config = (String) method.invoke(deployer, "example.com", "/var/www/public", false);
|
||||
|
||||
assertFalse(config.contains("return 301"));
|
||||
assertFalse(config.contains("ssl_certificate"));
|
||||
assertFalse(config.contains("listen 443"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void provisionSslThrowsWhenSslEmailIsNull() throws Exception {
|
||||
Method method = SshRsyncDeployer.class.getDeclaredMethod(
|
||||
"provisionSsl", String.class, SelfHostedConfig.class, String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.domain("example.com")
|
||||
.sslEmail(null)
|
||||
.build();
|
||||
|
||||
var thrown = assertThrows(Exception.class,
|
||||
() -> method.invoke(deployer, "/path/key", config, "/var/www/public"));
|
||||
assertTrue(thrown.getCause().getMessage().contains("SSL email is required"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeSiteFilesCreatesDirectoryStructure() throws Exception {
|
||||
Method method = SshRsyncDeployer.class.getDeclaredMethod(
|
||||
"writeSiteFiles", Path.class, SiteOutput.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
Path tempDir = Files.createTempDirectory("test-deploy");
|
||||
try {
|
||||
Map<String, String> pages = Map.of(
|
||||
"home", "<h1>Home</h1>",
|
||||
"about", "<h1>About</h1>"
|
||||
);
|
||||
SiteOutput output = new SiteOutput("body {}", "console.log('hi')", pages);
|
||||
|
||||
method.invoke(deployer, tempDir, output);
|
||||
|
||||
Path siteDir = tempDir.resolve("site");
|
||||
assertTrue(Files.exists(siteDir.resolve("index.html")));
|
||||
assertTrue(Files.exists(siteDir.resolve("style.css")));
|
||||
assertTrue(Files.exists(siteDir.resolve("script.js")));
|
||||
assertTrue(Files.exists(siteDir.resolve("about/index.html")));
|
||||
assertTrue(Files.exists(siteDir.resolve("404.html")));
|
||||
assertEquals("<h1>Home</h1>", Files.readString(siteDir.resolve("index.html")));
|
||||
assertEquals("<h1>About</h1>", Files.readString(siteDir.resolve("about/index.html")));
|
||||
} finally {
|
||||
try (var walk = Files.walk(tempDir)) {
|
||||
walk.sorted(Comparator.reverseOrder())
|
||||
.forEach(p -> {
|
||||
try {
|
||||
Files.deleteIfExists(p);
|
||||
} catch (IOException e) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ class ProviderFactoryTest {
|
||||
"https://generativelanguage.googleapis.com/v1beta/models",
|
||||
"gpt-4o-mini",
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
Duration.ofSeconds(180),
|
||||
restTemplateBuilder
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ public class FakeProviderFactory extends ProviderFactory {
|
||||
public FakeProviderFactory() {
|
||||
super("gemini-2.0-flash", "https://generativelanguage.googleapis.com/v1beta/models",
|
||||
"gpt-4o-mini", "https://api.openai.com/v1/chat/completions",
|
||||
null);
|
||||
null, null);
|
||||
}
|
||||
|
||||
public FakeProviderFactory withProvider(String name, FakeLLMProvider provider) {
|
||||
|
||||
@@ -9,7 +9,7 @@ public class StubEmailService extends EmailService {
|
||||
private int sendCount;
|
||||
|
||||
public StubEmailService() {
|
||||
super(null, "http://localhost:4200");
|
||||
super(null, "http://localhost:4200", "noreply@indie.app");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
40
docker-compose.v3.yml
Normal file
40
docker-compose.v3.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
network_mode: host
|
||||
environment:
|
||||
POSTGRES_DB: indie
|
||||
POSTGRES_USER: indie
|
||||
POSTGRES_PASSWORD: indie_dev
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U indie"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
mongodb:
|
||||
image: mongo:7
|
||||
network_mode: host
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: indie
|
||||
volumes:
|
||||
- mongodata:/data/db
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
network_mode: host
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: indieadmin
|
||||
MINIO_ROOT_PASSWORD: indiesecret
|
||||
volumes:
|
||||
- miniodata:/data
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
mongodata:
|
||||
miniodata:
|
||||
@@ -10,5 +10,7 @@ server {
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_read_timeout 180s;
|
||||
proxy_connect_timeout 30s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ export const routes: Routes = [
|
||||
loadComponent: () => import('./auth/register/register.component').then(m => m.RegisterComponent),
|
||||
canActivate: [LoginGuard],
|
||||
},
|
||||
{
|
||||
path: 'resend-verification',
|
||||
loadComponent: () => import('./auth/resend-verification/resend-verification.component').then(m => m.ResendVerificationComponent),
|
||||
canActivate: [LoginGuard],
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
|
||||
|
||||
@@ -53,8 +53,10 @@ import { NgIf } from '@angular/common';
|
||||
</div>
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="loading" class="w-full py-2">
|
||||
<mat-spinner *ngIf="loading" diameter="20" class="inline-block mr-2"></mat-spinner>
|
||||
<span class="flex items-center justify-center">
|
||||
<mat-spinner *ngIf="loading" diameter="20" class="mr-2"></mat-spinner>
|
||||
{{ loading ? 'Signing in...' : 'Sign In' }}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -62,6 +64,9 @@ import { NgIf } from '@angular/common';
|
||||
Don't have an account?
|
||||
<a routerLink="/register" class="text-indigo-600 font-medium hover:underline">Register</a>
|
||||
</p>
|
||||
<p class="text-center mt-2 text-sm text-gray-500">
|
||||
<a routerLink="/resend-verification" class="text-indigo-600 font-medium hover:underline">Resend verification email</a>
|
||||
</p>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -26,12 +26,6 @@ import { AuthService } from '../../core/services/auth.service';
|
||||
</div>
|
||||
|
||||
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()" class="flex flex-col gap-4">
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Name</mat-label>
|
||||
<input matInput formControlName="name" placeholder="Your name" />
|
||||
<mat-error *ngIf="registerForm.get('name')?.hasError('required')">Name is required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Email</mat-label>
|
||||
<input matInput type="email" formControlName="email" placeholder="you@example.com" />
|
||||
@@ -39,18 +33,13 @@ import { AuthService } from '../../core/services/auth.service';
|
||||
<mat-error *ngIf="registerForm.get('email')?.hasError('email')">Invalid email format</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Password</mat-label>
|
||||
<input matInput type="password" formControlName="password" />
|
||||
<mat-error *ngIf="registerForm.get('password')?.hasError('required')">Password is required</mat-error>
|
||||
<mat-error *ngIf="registerForm.get('password')?.hasError('minlength')">At least 8 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div *ngIf="error" class="text-red-600 text-sm">{{ error }}</div>
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="loading" class="w-full py-2">
|
||||
<mat-spinner *ngIf="loading" diameter="20" class="inline-block mr-2"></mat-spinner>
|
||||
<span class="flex items-center justify-center">
|
||||
<mat-spinner *ngIf="loading" diameter="20" class="mr-2"></mat-spinner>
|
||||
{{ loading ? 'Creating account...' : 'Create Account' }}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -64,9 +53,7 @@ import { AuthService } from '../../core/services/auth.service';
|
||||
})
|
||||
export class RegisterComponent {
|
||||
registerForm = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', [Validators.required, Validators.minLength(8)]],
|
||||
});
|
||||
loading = false;
|
||||
error = '';
|
||||
@@ -81,8 +68,8 @@ export class RegisterComponent {
|
||||
if (this.registerForm.invalid) return;
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
const { name, email, password } = this.registerForm.value;
|
||||
this.authService.register(name!, email!, password!).subscribe({
|
||||
const { email } = this.registerForm.value;
|
||||
this.authService.register(email!).subscribe({
|
||||
next: () => this.router.navigate(['/verify-email'], { queryParams: { email: email! } }),
|
||||
error: (err) => {
|
||||
this.error = err.error?.message || err.error?.error || 'Registration failed';
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { NgIf } from '@angular/common';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-resend-verification',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink, ReactiveFormsModule, NgIf,
|
||||
MatCardModule, MatFormFieldModule, MatInputModule, MatButtonModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<mat-card class="w-full max-w-md p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-indigo-600">Indie</h1>
|
||||
<p class="text-gray-500 mt-1">Resend verification email</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="resendForm" (ngSubmit)="onSubmit()" class="flex flex-col gap-4">
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Email</mat-label>
|
||||
<input matInput type="email" formControlName="email" placeholder="you@example.com" />
|
||||
<mat-error *ngIf="resendForm.get('email')?.hasError('required')">Email is required</mat-error>
|
||||
<mat-error *ngIf="resendForm.get('email')?.hasError('email')">Invalid email format</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div *ngIf="message" [class.text-green-600]="success" [class.text-red-600]="!success" class="text-sm text-center">{{ message }}</div>
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="loading" class="w-full py-2">
|
||||
<span class="flex items-center justify-center">
|
||||
<mat-spinner *ngIf="loading" diameter="20" class="mr-2"></mat-spinner>
|
||||
{{ loading ? 'Sending...' : 'Send verification email' }}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center mt-6 text-sm text-gray-500">
|
||||
<a routerLink="/login" class="text-indigo-600 font-medium hover:underline">Back to login</a>
|
||||
</p>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ResendVerificationComponent {
|
||||
resendForm = this.fb.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
});
|
||||
loading = false;
|
||||
message = '';
|
||||
success = false;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.resendForm.invalid) return;
|
||||
this.loading = true;
|
||||
this.message = '';
|
||||
this.authService.resendVerification(this.resendForm.value.email!).subscribe({
|
||||
next: () => {
|
||||
this.message = 'If this email is registered, a verification link has been sent.';
|
||||
this.success = true;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.message = 'Failed to send. Please try again later.';
|
||||
this.success = false;
|
||||
this.loading = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,10 @@ type PageState = 'check-email' | 'verifying' | 'verified' | 'set-password' | 'er
|
||||
<div *ngIf="resendError" class="text-red-600 text-sm mb-4">{{ resendError }}</div>
|
||||
|
||||
<button mat-stroked-button (click)="resend()" [disabled]="resending" class="w-full mb-3">
|
||||
<mat-spinner *ngIf="resending" diameter="16" class="inline-block mr-2"></mat-spinner>
|
||||
<span class="flex items-center justify-center">
|
||||
<mat-spinner *ngIf="resending" diameter="16" class="mr-2"></mat-spinner>
|
||||
{{ resending ? 'Sending...' : 'Resend verification email' }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<a routerLink="/login" class="block text-sm text-indigo-600 font-medium hover:underline">Back to login</a>
|
||||
@@ -60,8 +62,14 @@ type PageState = 'check-email' | 'verifying' | 'verified' | 'set-password' | 'er
|
||||
</div>
|
||||
|
||||
<div *ngIf="state === 'set-password'">
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-4 text-center">Set your password</h2>
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-4 text-center">Set your name and password</h2>
|
||||
<form [formGroup]="passwordForm" (ngSubmit)="onSetPassword()" class="flex flex-col gap-4">
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Name</mat-label>
|
||||
<input matInput formControlName="name" placeholder="Your name" />
|
||||
<mat-error *ngIf="passwordForm.get('name')?.hasError('required')">Name is required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>New Password</mat-label>
|
||||
<input matInput type="password" formControlName="password" />
|
||||
@@ -79,8 +87,10 @@ type PageState = 'check-email' | 'verifying' | 'verified' | 'set-password' | 'er
|
||||
<div *ngIf="passwordError" class="text-red-600 text-sm">{{ passwordError }}</div>
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="passwordLoading || passwordForm.invalid" class="w-full py-2">
|
||||
<mat-spinner *ngIf="passwordLoading" diameter="20" class="inline-block mr-2"></mat-spinner>
|
||||
<span class="flex items-center justify-center">
|
||||
<mat-spinner *ngIf="passwordLoading" diameter="20" class="mr-2"></mat-spinner>
|
||||
{{ passwordLoading ? 'Setting password...' : 'Activate Account' }}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -116,6 +126,7 @@ export class VerifyEmailComponent implements OnInit {
|
||||
passwordError = '';
|
||||
|
||||
passwordForm = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
password: ['', [Validators.required, Validators.minLength(8)]],
|
||||
confirmPassword: ['', Validators.required],
|
||||
}, { validators: this.passwordsMatch });
|
||||
@@ -207,7 +218,7 @@ export class VerifyEmailComponent implements OnInit {
|
||||
this.passwordLoading = true;
|
||||
this.passwordError = '';
|
||||
|
||||
this.authService.setPassword(this.setupToken, this.passwordForm.value.password!).subscribe({
|
||||
this.authService.setPassword(this.setupToken, this.passwordForm.value.password!, this.passwordForm.value.name!).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/login'], { queryParams: { verified: 'true' } });
|
||||
},
|
||||
|
||||
@@ -73,6 +73,40 @@ export interface GenerationVersion {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PatternSuggestion {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SectionSuggestion {
|
||||
title: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
patterns: PatternSuggestion[];
|
||||
}
|
||||
|
||||
export interface SuggestSectionsRequest {
|
||||
briefDescription: string;
|
||||
provider: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface SuggestSectionsResponse {
|
||||
sections: SectionSuggestion[];
|
||||
}
|
||||
|
||||
export interface SelectedSection {
|
||||
title: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
selected: boolean;
|
||||
customNotes: string;
|
||||
selectedPattern: string | null;
|
||||
customPatternDesc: string;
|
||||
}
|
||||
|
||||
export interface LLMKeyEntry {
|
||||
configured: boolean;
|
||||
baseUrl?: string;
|
||||
|
||||
@@ -14,8 +14,8 @@ export class AuthService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
register(name: string, email: string, password: string): Observable<{ message: string }> {
|
||||
return this.http.post<{ message: string }>('/api/auth/register', { name, email, password });
|
||||
register(email: string): Observable<{ message: string }> {
|
||||
return this.http.post<{ message: string }>('/api/auth/register', { email });
|
||||
}
|
||||
|
||||
login(email: string, password: string): Observable<AuthResponse> {
|
||||
@@ -41,8 +41,8 @@ export class AuthService {
|
||||
return this.http.post<{ message: string }>('/api/auth/resend-verification', { email });
|
||||
}
|
||||
|
||||
setPassword(setupToken: string, password: string): Observable<void> {
|
||||
return this.http.post<void>('/api/auth/set-password', { setupToken, password });
|
||||
setPassword(setupToken: string, password: string, name: string): Observable<void> {
|
||||
return this.http.post<void>('/api/auth/set-password', { setupToken, password, name });
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { LLMGenerateRequest, LLMRefineRequest, SiteStructure, GenerationVersion } from '../models/llm';
|
||||
import { LLMGenerateRequest, LLMRefineRequest, SiteStructure, GenerationVersion, SuggestSectionsRequest, SuggestSectionsResponse } from '../models/llm';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LlmApiService {
|
||||
@@ -15,6 +15,10 @@ export class LlmApiService {
|
||||
return this.http.post<SiteStructure>('/api/llm/refine', request);
|
||||
}
|
||||
|
||||
suggestSections(request: SuggestSectionsRequest): Observable<SuggestSectionsResponse> {
|
||||
return this.http.post<SuggestSectionsResponse>('/api/llm/suggest-sections', request);
|
||||
}
|
||||
|
||||
getVersions(siteId: string): Observable<GenerationVersion[]> {
|
||||
return this.http.get<GenerationVersion[]>(`/api/llm/versions/${siteId}`);
|
||||
}
|
||||
@@ -26,4 +30,8 @@ export class LlmApiService {
|
||||
deleteVersion(versionId: string): Observable<void> {
|
||||
return this.http.delete<void>(`/api/llm/versions/${versionId}`);
|
||||
}
|
||||
|
||||
pruneVersions(siteId: string, keep: number = 10): Observable<{deleted: number}> {
|
||||
return this.http.post<{deleted: number}>(`/api/llm/versions/${siteId}/prune`, { keep });
|
||||
}
|
||||
}
|
||||
|
||||
93
frontend/src/app/llm-workspace/generation.service.ts
Normal file
93
frontend/src/app/llm-workspace/generation.service.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
|
||||
import { LlmApiService } from '../core/services/llm-api.service';
|
||||
import { LlmSessionService } from './llm-session.service';
|
||||
import { LLMGenerateRequest, LLMRefineRequest, SiteStructure } from '../core/models/llm';
|
||||
|
||||
export interface GenerationState {
|
||||
status: 'idle' | 'generating' | 'completed' | 'error';
|
||||
result: SiteStructure | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GenerationService {
|
||||
private api = inject(LlmApiService);
|
||||
private session = inject(LlmSessionService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
private generationSubscription: Subscription | null = null;
|
||||
private stateSubject = new BehaviorSubject<GenerationState>({
|
||||
status: 'idle', result: null, error: null,
|
||||
});
|
||||
private refreshVersionsSubject = new Subject<string>();
|
||||
|
||||
readonly state$ = this.stateSubject.asObservable();
|
||||
readonly refreshVersions$ = this.refreshVersionsSubject.asObservable();
|
||||
|
||||
generate(request: LLMGenerateRequest, siteId: string): void {
|
||||
this.cancel();
|
||||
this.stateSubject.next({ status: 'generating', result: null, error: null });
|
||||
this.session.setGenerating(true);
|
||||
this.session.clearError();
|
||||
|
||||
this.generationSubscription = this.api.generate(request).subscribe({
|
||||
next: (data) => {
|
||||
this.stateSubject.next({ status: 'completed', result: data, error: null });
|
||||
this.session.setResult(data);
|
||||
this.session.setCurrentVersionId(null);
|
||||
this.refreshVersionsSubject.next(siteId);
|
||||
this.snackBar.open('Site generated successfully!', 'Close', { duration: 5000 });
|
||||
this.generationSubscription = null;
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
|
||||
this.stateSubject.next({ status: 'error', result: null, error: msg });
|
||||
this.session.setError(msg);
|
||||
this.snackBar.open('Generation failed: ' + msg, 'Dismiss', { duration: 8000 });
|
||||
this.generationSubscription = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
refine(request: LLMRefineRequest, siteId: string): void {
|
||||
this.cancel();
|
||||
this.stateSubject.next({ status: 'generating', result: null, error: null });
|
||||
this.session.setGenerating(true);
|
||||
this.session.clearError();
|
||||
|
||||
this.generationSubscription = this.api.refine(request).subscribe({
|
||||
next: (data) => {
|
||||
this.stateSubject.next({ status: 'completed', result: data, error: null });
|
||||
this.session.setResult(data);
|
||||
this.session.setCurrentVersionId(null);
|
||||
this.refreshVersionsSubject.next(siteId);
|
||||
this.snackBar.open('Site refined successfully!', 'Close', { duration: 5000 });
|
||||
this.generationSubscription = null;
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
|
||||
this.stateSubject.next({ status: 'error', result: null, error: msg });
|
||||
this.session.setError(msg);
|
||||
this.snackBar.open('Refinement failed: ' + msg, 'Dismiss', { duration: 8000 });
|
||||
this.generationSubscription = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.generationSubscription) {
|
||||
this.generationSubscription.unsubscribe();
|
||||
this.generationSubscription = null;
|
||||
}
|
||||
this.stateSubject.next({ status: 'idle', result: null, error: null });
|
||||
this.session.setGenerating(false);
|
||||
this.session.clearError();
|
||||
}
|
||||
|
||||
resetState(): void {
|
||||
this.cancel();
|
||||
this.stateSubject.next({ status: 'idle', result: null, error: null });
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,14 @@ import { takeUntil } from 'rxjs/operators';
|
||||
import { LlmApiService } from '../core/services/llm-api.service';
|
||||
import { LlmConfigService } from '../core/services/llm-config.service';
|
||||
import { LlmSessionService } from './llm-session.service';
|
||||
import { SiteStructure, GenerationVersion, LLMKeyStatus } from '../core/models/llm';
|
||||
import { GenerationService } from './generation.service';
|
||||
import { SiteStructure, GenerationVersion, LLMKeyStatus, SectionSuggestion } from '../core/models/llm';
|
||||
import { SectionConfiguratorComponent, SectionConfig } from './section-configurator/section-configurator.component';
|
||||
import { PreviewDialogComponent } from './preview-dialog/preview-dialog.component';
|
||||
import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dialog';
|
||||
|
||||
type Phase = 'idle' | 'suggesting' | 'configuring' | 'generating';
|
||||
|
||||
@Component({
|
||||
selector: 'app-llm-workspace',
|
||||
standalone: true,
|
||||
@@ -32,6 +36,7 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule, MatSelectModule,
|
||||
MatSnackBarModule, MatCardModule,
|
||||
MatDividerModule, MatExpansionModule, MatDialogModule,
|
||||
SectionConfiguratorComponent,
|
||||
],
|
||||
styles: [`
|
||||
.provider-select { width: 140px; }
|
||||
@@ -51,26 +56,24 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
</button>
|
||||
</mat-toolbar>
|
||||
|
||||
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="px-6 py-6 space-y-6">
|
||||
|
||||
<mat-card class="p-4">
|
||||
<mat-card class="p-4" *ngIf="currentPhase === 'idle'">
|
||||
<mat-card-content class="!p-0">
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Describe your website</mat-label>
|
||||
<mat-label>Describe your website idea</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="5"
|
||||
placeholder="e.g. A personal portfolio for a photographer with a gallery page, about section, and contact form. Dark theme with accent gold."
|
||||
[(ngModel)]="prompt"
|
||||
[disabled]="(session.generating$ | async) === true"
|
||||
rows="4"
|
||||
placeholder="e.g. A photography portfolio for Alex Chen"
|
||||
[(ngModel)]="briefDescription"
|
||||
[disabled]="suggesting"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<span class="text-xs text-gray-400">
|
||||
Describe the pages, layout, theme, and content you want.
|
||||
Briefly describe the type of site you want. The AI will suggest sections you can customize.
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<mat-form-field appearance="outline" class="!mb-0 provider-select" *ngIf="configuredProviders.length > 1">
|
||||
@@ -83,18 +86,88 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
[disabled]="!prompt.trim() || (session.generating$ | async) || configuredProviders.length === 0"
|
||||
(click)="generate()"
|
||||
[disabled]="!briefDescription.trim() || suggesting || configuredProviders.length === 0"
|
||||
(click)="suggestSections()"
|
||||
>
|
||||
<mat-icon>auto_awesome</mat-icon>
|
||||
Generate
|
||||
Plan My Site
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="configuredProviders.length === 0 && !(session.generating$ | async)" class="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p class="text-sm text-yellow-800">
|
||||
No API keys configured. Go to <a class="underline font-medium cursor-pointer" (click)="goToSettings()">Settings</a> to add an API key for an AI provider.
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<div *ngIf="suggesting" class="flex flex-col items-center justify-center py-12 space-y-4">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
<div class="text-center">
|
||||
<p class="text-lg font-medium text-gray-700">Analyzing your idea...</p>
|
||||
<p class="text-sm text-gray-400">Finding the right sections for your site</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 3: Section Configurator -->
|
||||
<div *ngIf="currentPhase === 'configuring'" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
{{ configStepTitle }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ configStepSubtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<button mat-stroked-button (click)="backToIdle()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<app-section-configurator
|
||||
[suggestions]="sectionSuggestions"
|
||||
(configComplete)="onConfigComplete($event)"
|
||||
(stepChange)="onConfigStepChange($event)"
|
||||
></app-section-configurator>
|
||||
</div>
|
||||
|
||||
<!-- API key setup (shown when no providers configured) -->
|
||||
<mat-card *ngIf="configuredProviders.length === 0 && !(session.generating$ | async) && currentPhase !== 'generating'" class="p-4" appearance="outlined">
|
||||
<mat-card-content class="!p-0">
|
||||
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Configure AI Provider</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Add an API key to enable AI site generation. Keys are encrypted at rest.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Provider</mat-label>
|
||||
<mat-select [(ngModel)]="setupProvider">
|
||||
<mat-option value="gemini">Gemini</mat-option>
|
||||
<mat-option value="openai">OpenAI / Compatible</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>API Key</mat-label>
|
||||
<input matInput [type]="setupKeyVisible ? 'text' : 'password'" [(ngModel)]="setupApiKey" />
|
||||
<button mat-icon-button matSuffix (click)="setupKeyVisible = !setupKeyVisible" type="button"
|
||||
[attr.aria-label]="setupKeyVisible ? 'Hide key' : 'Show key'">
|
||||
<mat-icon>{{ setupKeyVisible ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="w-full" *ngIf="setupProvider === 'openai'">
|
||||
<mat-label>Base URL (optional)</mat-label>
|
||||
<input matInput placeholder="https://api.deepseek.com/v1" [(ngModel)]="setupBaseUrl" />
|
||||
<mat-hint>For OpenAI-compatible providers like DeepSeek</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Model (optional)</mat-label>
|
||||
<input matInput placeholder="{{ setupProvider === 'gemini' ? 'gemini-2.0-flash-exp' : 'gpt-4o-mini' }}" [(ngModel)]="setupModel" />
|
||||
<mat-hint>Override the default model name</mat-hint>
|
||||
</mat-form-field>
|
||||
<div class="flex justify-end">
|
||||
<button mat-flat-button color="primary" [disabled]="!setupApiKey.trim() || setupSaving" (click)="saveSetupKey()">
|
||||
<mat-icon>save</mat-icon>
|
||||
{{ setupSaving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@@ -107,6 +180,15 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="suggestError" class="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<mat-icon class="text-red-500 mt-0.5">error_outline</mat-icon>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-red-800">Suggestion failed</p>
|
||||
<p class="text-sm text-red-600 mt-1">{{ suggestError }}</p>
|
||||
<button mat-button color="warn" class="mt-2" (click)="suggestError = null">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="session.error$ | async as error" class="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<mat-icon class="text-red-500 mt-0.5">error_outline</mat-icon>
|
||||
<div class="flex-1">
|
||||
@@ -122,14 +204,26 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
<mat-card-content class="!p-0">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">History</h4>
|
||||
<span class="text-xs text-gray-400">{{ versions.length }} version{{ versions.length !== 1 ? 's' : '' }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
*ngIf="((session.versions$ | async)?.length || 0) > 10"
|
||||
mat-stroked-button
|
||||
class="!text-[10px] !leading-none !min-w-0 !px-2 !py-0.5 !h-auto"
|
||||
color="warn"
|
||||
(click)="pruneOldVersions()"
|
||||
title="Keep only the 10 most recent versions"
|
||||
>
|
||||
Clear old
|
||||
</button>
|
||||
<span class="text-xs text-gray-400">{{ (session.versions$ | async)?.length || 0 }} version{{ ((session.versions$ | async)?.length || 0) !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="session.loadingVersions$ | async" class="flex justify-center py-4">
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
</div>
|
||||
<div class="space-y-1 max-h-[calc(100vh-20rem)] overflow-y-auto overflow-x-hidden">
|
||||
<div
|
||||
*ngFor="let v of versions"
|
||||
*ngFor="let v of (session.versions$ | async) || []"
|
||||
(click)="switchToVersion(v)"
|
||||
class="w-full p-3 rounded-lg border transition-colors duration-150 flex flex-col gap-0.5 cursor-pointer group min-w-0"
|
||||
[class.border-blue-500]="isCurrentVersion(v.id)"
|
||||
@@ -151,7 +245,7 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 truncate">{{ truncatePrompt(v.prompt, 50) }}</p>
|
||||
</div>
|
||||
<p *ngIf="versions.length === 0 && !(session.loadingVersions$ | async)" class="text-xs text-gray-400 py-2 text-center">
|
||||
<p *ngIf="((session.versions$ | async) || []).length === 0 && !(session.loadingVersions$ | async)" class="text-xs text-gray-400 py-2 text-center">
|
||||
No versions yet
|
||||
</p>
|
||||
</div>
|
||||
@@ -274,11 +368,11 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!(session.generating$ | async) && !(session.currentResult$ | async) && !(session.error$ | async)" class="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<div *ngIf="configuredProviders.length > 0 && currentPhase === 'idle' && !(session.currentResult$ | async) && !(session.error$ | async)" class="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<mat-icon class="text-6xl mb-4" style="width: auto; height: auto; font-size: 4rem;">auto_awesome</mat-icon>
|
||||
<h3 class="text-xl font-medium text-gray-500 mb-2">Describe your dream site</h3>
|
||||
<p class="text-sm text-gray-400 text-center max-w-md">
|
||||
Tell the AI what kind of website you want — pages, style, content — and it will generate a complete site structure ready for you to customize.
|
||||
Tell the AI what kind of website you want. It will suggest pages and sections that you can customize before generating.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,31 +387,58 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
private llmConfigService = inject(LlmConfigService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
private generationService = inject(GenerationService);
|
||||
session = inject(LlmSessionService);
|
||||
|
||||
currentPhase: Phase = 'idle';
|
||||
|
||||
briefDescription = '';
|
||||
sectionSuggestions: SectionSuggestion[] = [];
|
||||
sectionConfig: SectionConfig | null = null;
|
||||
configStepTitle = 'Customize Your Site';
|
||||
configStepSubtitle = '';
|
||||
|
||||
suggestError: string | null = null;
|
||||
suggesting = false;
|
||||
|
||||
prompt = '';
|
||||
refinePrompt = '';
|
||||
showRefine = false;
|
||||
siteId: string | null = null;
|
||||
versions: GenerationVersion[] = [];
|
||||
llmKeyStatus: LLMKeyStatus = {};
|
||||
llmBaseUrls: Record<string, string> = {};
|
||||
llmModels: Record<string, string> = {};
|
||||
configuredProviders: string[] = [];
|
||||
selectedProvider = '';
|
||||
|
||||
setupProvider = 'gemini';
|
||||
setupApiKey = '';
|
||||
setupBaseUrl = '';
|
||||
setupModel = '';
|
||||
setupSaving = false;
|
||||
setupKeyVisible = false;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.generationService.refreshVersions$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => this.loadVersions());
|
||||
|
||||
this.route.paramMap
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(params => {
|
||||
const siteId = params.get('siteId');
|
||||
if (siteId !== this.siteId) {
|
||||
this.siteId = siteId;
|
||||
this.briefDescription = '';
|
||||
this.prompt = '';
|
||||
this.refinePrompt = '';
|
||||
this.showRefine = false;
|
||||
this.versions = [];
|
||||
this.currentPhase = 'idle';
|
||||
this.sectionSuggestions = [];
|
||||
this.sectionConfig = null;
|
||||
this.suggestError = null;
|
||||
this.session.clearHistory();
|
||||
this.loadVersions();
|
||||
this.loadLlmConfig();
|
||||
@@ -357,7 +478,6 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (v) => {
|
||||
this.versions = v;
|
||||
this.session.setVersions(v);
|
||||
if (v.length > 0 && !this.session.currentResult) {
|
||||
this.api.restoreVersion(v[0].id)
|
||||
@@ -378,74 +498,132 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
suggestSections(): void {
|
||||
if (!this.briefDescription.trim() || !this.siteId || !this.selectedProvider) return;
|
||||
|
||||
|
||||
generate(): void {
|
||||
if (!this.prompt.trim() || !this.siteId || !this.selectedProvider) return;
|
||||
this.showRefine = false;
|
||||
this.session.setPrompt(this.prompt);
|
||||
this.session.setGenerating(true);
|
||||
this.session.clearError();
|
||||
this.suggesting = true;
|
||||
this.suggestError = null;
|
||||
|
||||
const baseUrl = this.llmBaseUrls[this.selectedProvider];
|
||||
const model = this.llmModels[this.selectedProvider];
|
||||
this.api.generate({
|
||||
prompt: this.prompt,
|
||||
|
||||
this.api.suggestSections({
|
||||
briefDescription: this.briefDescription,
|
||||
provider: this.selectedProvider,
|
||||
baseUrl,
|
||||
model,
|
||||
}).pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (response) => {
|
||||
this.sectionSuggestions = response.sections;
|
||||
this.suggesting = false;
|
||||
this.currentPhase = 'configuring';
|
||||
},
|
||||
error: (err) => {
|
||||
this.suggesting = false;
|
||||
this.suggestError = err.error?.error || err.statusText || 'Failed to get section suggestions';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onConfigComplete(config: SectionConfig): void {
|
||||
this.sectionConfig = config;
|
||||
this.generate();
|
||||
}
|
||||
|
||||
onConfigStepChange(step: string): void {
|
||||
if (step.startsWith('section-')) {
|
||||
const idx = parseInt(step.split('-')[1], 10);
|
||||
const section = this.sectionSuggestions[idx];
|
||||
this.configStepTitle = section ? `Configure ${section.title}` : 'Configure Sections';
|
||||
this.configStepSubtitle = 'Choose a layout pattern or describe your own.';
|
||||
} else if (step === 'theme') {
|
||||
this.configStepTitle = 'Choose Theme Direction';
|
||||
this.configStepSubtitle = 'Pick a general aesthetic for your site, or describe your own.';
|
||||
} else if (step === 'details') {
|
||||
this.configStepTitle = 'Additional Details';
|
||||
this.configStepSubtitle = 'Any final preferences for the generated site.';
|
||||
}
|
||||
}
|
||||
|
||||
backToIdle(): void {
|
||||
this.currentPhase = 'idle';
|
||||
this.sectionSuggestions = [];
|
||||
this.sectionConfig = null;
|
||||
}
|
||||
|
||||
generate(): void {
|
||||
if (!this.sectionConfig || !this.siteId || !this.selectedProvider) return;
|
||||
|
||||
const compiledPrompt = this.buildPrompt();
|
||||
this.prompt = compiledPrompt;
|
||||
this.currentPhase = 'generating';
|
||||
this.showRefine = false;
|
||||
this.session.setPrompt(compiledPrompt);
|
||||
|
||||
const baseUrl = this.llmBaseUrls[this.selectedProvider];
|
||||
const model = this.llmModels[this.selectedProvider];
|
||||
this.generationService.generate({
|
||||
prompt: compiledPrompt,
|
||||
siteId: this.siteId,
|
||||
provider: this.selectedProvider,
|
||||
baseUrl,
|
||||
model,
|
||||
})
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (data) => {
|
||||
this.session.setResult(data);
|
||||
this.session.setCurrentVersionId(null);
|
||||
this.loadVersions();
|
||||
this.snackBar.open('Site generated successfully!', 'Close', { duration: 3000 });
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
|
||||
this.session.setError(msg);
|
||||
},
|
||||
});
|
||||
}, this.siteId);
|
||||
}
|
||||
|
||||
private buildPrompt(): string {
|
||||
const config = this.sectionConfig;
|
||||
if (!config) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`Site type: ${this.briefDescription}`);
|
||||
|
||||
if (config.themeVibe !== 'Any') {
|
||||
lines.push(`Theme direction: ${config.themeVibe}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Pages:');
|
||||
|
||||
const selectedSections = config.sections.filter(s => s.selected);
|
||||
for (const section of selectedSections) {
|
||||
let desc = section.description;
|
||||
if (section.customNotes.trim()) {
|
||||
desc += ' ' + section.customNotes.trim();
|
||||
}
|
||||
lines.push(`- ${section.title}: ${desc}`);
|
||||
}
|
||||
|
||||
if (config.additionalRequirements.trim()) {
|
||||
lines.push('');
|
||||
lines.push('Additional requirements:');
|
||||
lines.push(config.additionalRequirements.trim());
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
refine(): void {
|
||||
const result = this.session.currentResult;
|
||||
if (!result || !this.refinePrompt.trim() || !this.siteId || !this.selectedProvider) return;
|
||||
this.session.setGenerating(true);
|
||||
this.session.clearError();
|
||||
|
||||
const baseUrl = this.llmBaseUrls[this.selectedProvider];
|
||||
const model = this.llmModels[this.selectedProvider];
|
||||
this.api.refine({
|
||||
this.generationService.refine({
|
||||
prompt: this.refinePrompt,
|
||||
siteId: this.siteId,
|
||||
provider: this.selectedProvider,
|
||||
baseUrl,
|
||||
model,
|
||||
currentStructure: result,
|
||||
})
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (data) => {
|
||||
this.session.setResult(data);
|
||||
this.session.setCurrentVersionId(null);
|
||||
this.loadVersions();
|
||||
}, this.siteId);
|
||||
|
||||
this.refinePrompt = '';
|
||||
this.showRefine = false;
|
||||
this.snackBar.open('Site refined successfully!', 'Close', { duration: 3000 });
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
|
||||
this.session.setError(msg);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteVersion(version: GenerationVersion): void {
|
||||
if (this.versions.length <= 1) {
|
||||
if (this.session.versions.length <= 1) {
|
||||
this.snackBar.open('Cannot delete the last version', 'Close', { duration: 3000 });
|
||||
return;
|
||||
}
|
||||
@@ -472,6 +650,23 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
pruneOldVersions(): void {
|
||||
if (!this.siteId) return;
|
||||
this.api.pruneVersions(this.siteId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.loadVersions();
|
||||
if (result.deleted > 0) {
|
||||
this.snackBar.open(`Cleaned up ${result.deleted} old versions`, 'Close', { duration: 3000 });
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.snackBar.open('Failed to clean up versions', 'Close', { duration: 3000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
switchToVersion(version: GenerationVersion): void {
|
||||
if (version.id === this.session.currentVersionId) return;
|
||||
this.session.setGenerating(true);
|
||||
@@ -520,7 +715,7 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
get currentVersionPrompt(): string | null {
|
||||
const version = this.versions.find(v => v.id === this.session.currentVersionId);
|
||||
const version = this.session.versions.find(v => v.id === this.session.currentVersionId);
|
||||
return version ? version.prompt : null;
|
||||
}
|
||||
|
||||
@@ -528,6 +723,30 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
return prompt.length > maxLen ? prompt.slice(0, maxLen) + '...' : prompt;
|
||||
}
|
||||
|
||||
saveSetupKey(): void {
|
||||
const key = this.setupApiKey?.trim();
|
||||
const baseUrl = this.setupBaseUrl?.trim() || undefined;
|
||||
const model = this.setupModel?.trim() || undefined;
|
||||
if (!key) return;
|
||||
this.setupSaving = true;
|
||||
this.llmConfigService.saveKey(this.setupProvider, key, baseUrl, model)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.setupSaving = false;
|
||||
this.setupApiKey = '';
|
||||
this.setupBaseUrl = '';
|
||||
this.setupModel = '';
|
||||
this.snackBar.open(`${this.setupProvider} API key saved`, 'Close', { duration: 2000 });
|
||||
this.loadLlmConfig();
|
||||
},
|
||||
error: () => {
|
||||
this.setupSaving = false;
|
||||
this.snackBar.open('Failed to save API key', 'Close', { duration: 3000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { NgFor, NgIf } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { SectionSuggestion, SelectedSection, PatternSuggestion } from '../../core/models/llm';
|
||||
|
||||
export interface SectionConfig {
|
||||
sections: SelectedSection[];
|
||||
themeVibe: string;
|
||||
additionalRequirements: string;
|
||||
}
|
||||
|
||||
const THEME_VIBES = [
|
||||
'Dark & Moody',
|
||||
'Light & Airy',
|
||||
'Bold & Vibrant',
|
||||
'Minimal & Corporate',
|
||||
'Playful & Fun',
|
||||
'Luxe & Elegant',
|
||||
'Earthy & Organic',
|
||||
'Edgy & Urban',
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-section-configurator',
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgFor, NgIf, FormsModule,
|
||||
MatButtonModule, MatIconModule, MatInputModule,
|
||||
MatFormFieldModule, MatSelectModule,
|
||||
],
|
||||
styles: [`
|
||||
.crumb { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8rem; font-weight: 500; padding: 0.25rem 0.75rem; border-radius: 9999px; transition: all 0.2s; }
|
||||
.crumb.active { background: #3b82f6; color: #fff; }
|
||||
.crumb.completed { color: #22c55e; }
|
||||
.crumb.pending { color: #9ca3af; }
|
||||
.crumb-line { flex: 1; height: 1.5px; background: #e5e7eb; min-width: 2rem; }
|
||||
.crumb-line.completed { background: #22c55e; }
|
||||
|
||||
.pattern-option { display: block; width: 100%; text-align: left; border: 1.5px solid #e5e7eb; border-radius: 0.75rem; padding: 0.875rem 1rem; cursor: pointer; transition: all 0.15s; background: #fff; }
|
||||
.pattern-option:hover { border-color: #93c5fd; background: #fafcff; }
|
||||
.pattern-option.selected { border-color: #3b82f6; background: #eff6ff; }
|
||||
`],
|
||||
template: `
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="crumb" [class.active]="phase === 'sections'" [class.completed]="phase !== 'sections'">Sections</div>
|
||||
<div class="crumb-line" [class.completed]="phase !== 'sections'"></div>
|
||||
<div class="crumb" [class.active]="phase === 'theme'" [class.completed]="phase === 'details'">Theme</div>
|
||||
<div class="crumb-line" [class.completed]="phase === 'details'"></div>
|
||||
<div class="crumb" [class.active]="phase === 'details'">Details</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="phase === 'sections' && currentSection" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-gray-900">Configure {{ currentSection.title }}</h4>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Section {{ sectionIndex + 1 }} of {{ sections.length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600">{{ currentSection.description }}</p>
|
||||
|
||||
<div *ngIf="!currentSection.selected" class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<mat-icon class="text-yellow-600 text-lg">warning_amber</mat-icon>
|
||||
<span class="text-sm text-yellow-800">This section won't be included</span>
|
||||
</div>
|
||||
<button mat-stroked-button class="!text-xs !h-8 !px-3" (click)="currentSection.selected = true">
|
||||
Include it
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2.5">
|
||||
<div
|
||||
*ngFor="let pattern of currentSectionPatterns; let pi = index"
|
||||
class="pattern-option"
|
||||
[class.selected]="currentSection.selectedPattern === pattern.title"
|
||||
(click)="selectPattern(pattern.title)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-4 h-4 rounded-full border-2 mt-0.5 shrink-0 flex items-center justify-center"
|
||||
[class.border-blue-500]="currentSection.selectedPattern === pattern.title"
|
||||
[class.border-gray-300]="currentSection.selectedPattern !== pattern.title"
|
||||
>
|
||||
<div class="w-2 h-2 rounded-full" [class.bg-blue-500]="currentSection.selectedPattern === pattern.title"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">{{ pattern.title }}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">{{ pattern.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pattern-option"
|
||||
[class.selected]="currentSection.selectedPattern === '__custom__'"
|
||||
(click)="selectPattern('__custom__')"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-4 h-4 rounded-full border-2 mt-0.5 shrink-0 flex items-center justify-center"
|
||||
[class.border-blue-500]="currentSection.selectedPattern === '__custom__'"
|
||||
[class.border-gray-300]="currentSection.selectedPattern !== '__custom__'"
|
||||
>
|
||||
<div class="w-2 h-2 rounded-full" [class.bg-blue-500]="currentSection.selectedPattern === '__custom__'"></div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-gray-900">Custom</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">Describe your own layout and design for this section.</div>
|
||||
<div *ngIf="currentSection.selectedPattern === '__custom__'" class="mt-3">
|
||||
<mat-form-field appearance="outline" class="w-full !mb-0">
|
||||
<mat-label>Describe your custom layout</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="2"
|
||||
placeholder="e.g. Full-width video background with a floating CTA and social links in the bottom corner"
|
||||
[(ngModel)]="currentSection.customPatternDesc"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="currentSectionPatterns.length === 0" class="mt-3">
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Custom notes for {{ currentSection.title }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="2"
|
||||
placeholder="Describe what you want in this section..."
|
||||
[(ngModel)]="currentSection.customNotes"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 pt-4 border-t border-gray-100 text-center">
|
||||
<button mat-button color="warn" (click)="skipSection()">
|
||||
<mat-icon>remove_circle_outline</mat-icon>
|
||||
Don't include this section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="phase === 'theme'">
|
||||
<h4 class="text-base font-semibold text-gray-900 mb-1">Choose Theme Direction</h4>
|
||||
<p class="text-sm text-gray-500 mb-4">Pick a general aesthetic for your site, or describe your own.</p>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-full sm:w-72">
|
||||
<mat-label>Theme vibe</mat-label>
|
||||
<mat-select [(ngModel)]="selectedVibe">
|
||||
<mat-option value="Any">Any</mat-option>
|
||||
<mat-option *ngFor="let vibe of themeVibes" [value]="vibe">{{ vibe }}</mat-option>
|
||||
<mat-option value="__custom__">Custom...</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-full" *ngIf="selectedVibe === '__custom__'">
|
||||
<mat-label>Describe your custom theme</mat-label>
|
||||
<input
|
||||
matInput
|
||||
placeholder="e.g. Cyberpunk neon with glitch effects"
|
||||
[(ngModel)]="customVibeText"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Phase: Details -->
|
||||
<div *ngIf="phase === 'details'">
|
||||
<h4 class="text-base font-semibold text-gray-900 mb-1">Additional Details</h4>
|
||||
<p class="text-sm text-gray-500 mb-4">Anything else you want the site to have — specific features, layout preferences, content ideas.</p>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Additional requirements (optional)</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="5"
|
||||
placeholder="e.g. Include a newsletter signup form, add a sticky navigation bar, use a masonry grid layout for the gallery..."
|
||||
[(ngModel)]="additionalRequirements"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-6 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="prev()"
|
||||
[disabled]="phase === 'sections' && sectionIndex === 0"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<div class="text-xs text-gray-400">
|
||||
{{ navLabel }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="next()"
|
||||
>
|
||||
{{ isLastStep ? 'Done' : 'Next' }}
|
||||
<mat-icon *ngIf="!isLastStep">arrow_forward</mat-icon>
|
||||
<mat-icon *ngIf="isLastStep">check</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class SectionConfiguratorComponent implements OnInit {
|
||||
@Input() suggestions: SectionSuggestion[] = [];
|
||||
@Output() configComplete = new EventEmitter<SectionConfig>();
|
||||
@Output() stepChange = new EventEmitter<string>();
|
||||
|
||||
phase: 'sections' | 'theme' | 'details' = 'sections';
|
||||
sectionIndex = 0;
|
||||
sections: SelectedSection[] = [];
|
||||
|
||||
themeVibes = THEME_VIBES;
|
||||
selectedVibe = 'Any';
|
||||
customVibeText = '';
|
||||
additionalRequirements = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.sections = this.suggestions.map(s => ({
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
slug: s.slug,
|
||||
selected: true,
|
||||
customNotes: '',
|
||||
selectedPattern: s.patterns?.length ? null : null,
|
||||
customPatternDesc: '',
|
||||
}));
|
||||
if (this.currentSection && this.currentSectionPatterns.length > 0) {
|
||||
this.currentSection.selectedPattern = this.currentSectionPatterns[0].title;
|
||||
}
|
||||
}
|
||||
|
||||
get currentSection(): SelectedSection | null {
|
||||
return this.sections[this.sectionIndex] ?? null;
|
||||
}
|
||||
|
||||
get currentSectionPatterns(): PatternSuggestion[] {
|
||||
const sug = this.suggestions[this.sectionIndex];
|
||||
return sug?.patterns ?? [];
|
||||
}
|
||||
|
||||
selectPattern(title: string): void {
|
||||
if (this.currentSection) {
|
||||
this.currentSection.selectedPattern = title;
|
||||
if (title !== '__custom__') {
|
||||
this.currentSection.customPatternDesc = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get isLastStep(): boolean {
|
||||
return this.phase === 'details';
|
||||
}
|
||||
|
||||
get navLabel(): string {
|
||||
if (this.phase === 'sections') {
|
||||
return `Section ${this.sectionIndex + 1} of ${this.sections.length}`;
|
||||
}
|
||||
if (this.phase === 'theme') {
|
||||
return 'Theme';
|
||||
}
|
||||
return 'Additional details';
|
||||
}
|
||||
|
||||
skipSection(): void {
|
||||
if (this.currentSection) {
|
||||
this.currentSection.selected = false;
|
||||
this.currentSection.selectedPattern = null;
|
||||
}
|
||||
if (this.sectionIndex < this.sections.length - 1) {
|
||||
this.sectionIndex++;
|
||||
this.stepChange.emit(`section-${this.sectionIndex}`);
|
||||
} else {
|
||||
this.phase = 'theme';
|
||||
this.stepChange.emit('theme');
|
||||
}
|
||||
}
|
||||
|
||||
prev(): void {
|
||||
if (this.phase === 'sections') {
|
||||
if (this.sectionIndex > 0) {
|
||||
this.sectionIndex--;
|
||||
this.stepChange.emit(`section-${this.sectionIndex}`);
|
||||
}
|
||||
} else if (this.phase === 'theme') {
|
||||
this.phase = 'sections';
|
||||
this.sectionIndex = this.sections.length - 1;
|
||||
this.stepChange.emit('sections');
|
||||
} else if (this.phase === 'details') {
|
||||
this.phase = 'theme';
|
||||
this.stepChange.emit('theme');
|
||||
}
|
||||
}
|
||||
|
||||
next(): void {
|
||||
if (this.phase === 'sections') {
|
||||
if (this.sectionIndex < this.sections.length - 1) {
|
||||
this.sectionIndex++;
|
||||
this.stepChange.emit(`section-${this.sectionIndex}`);
|
||||
} else {
|
||||
this.phase = 'theme';
|
||||
this.stepChange.emit('theme');
|
||||
}
|
||||
} else if (this.phase === 'theme') {
|
||||
this.phase = 'details';
|
||||
this.stepChange.emit('details');
|
||||
} else if (this.phase === 'details') {
|
||||
const vibe = this.selectedVibe === '__custom__'
|
||||
? this.customVibeText.trim() || 'Any'
|
||||
: this.selectedVibe;
|
||||
this.configComplete.emit({
|
||||
sections: this.sections,
|
||||
themeVibe: vibe,
|
||||
additionalRequirements: this.additionalRequirements,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user