From ec4d030c0a7c7f8be6efcb549996dec3c94eaebc Mon Sep 17 00:00:00 2001 From: Krrish Ghimire Date: Sun, 12 Jul 2026 00:10:15 +0545 Subject: [PATCH] update site generation flow --- .../config/SuggestRateLimitConfig.java | 40 +++ .../com/krrishg/controller/LLMController.java | 41 ++- .../krrishg/dto/SuggestSectionsRequest.java | 9 + .../krrishg/dto/SuggestSectionsResponse.java | 19 + .../java/com/krrishg/service/LLMService.java | 98 ++++++ .../config/GlobalExceptionHandlerTest.java | 15 + .../config/SuggestRateLimitConfigTest.java | 76 ++++ .../krrishg/controller/LLMControllerTest.java | 227 ++++++++++++ .../krrishg/model/VerificationTokenTest.java | 54 +++ .../VerificationTokenRepositoryTest.java | 119 +++++++ .../com/krrishg/service/LLMServiceTest.java | 55 +++ frontend/src/app/core/models/llm.ts | 34 ++ .../src/app/core/services/llm-api.service.ts | 6 +- .../llm-workspace/llm-workspace.component.ts | 186 ++++++++-- .../section-configurator.component.ts | 328 ++++++++++++++++++ 15 files changed, 1285 insertions(+), 22 deletions(-) create mode 100644 backend/src/main/java/com/krrishg/config/SuggestRateLimitConfig.java create mode 100644 backend/src/main/java/com/krrishg/dto/SuggestSectionsRequest.java create mode 100644 backend/src/main/java/com/krrishg/dto/SuggestSectionsResponse.java create mode 100644 backend/src/test/java/com/krrishg/config/SuggestRateLimitConfigTest.java create mode 100644 backend/src/test/java/com/krrishg/model/VerificationTokenTest.java create mode 100644 backend/src/test/java/com/krrishg/repository/VerificationTokenRepositoryTest.java create mode 100644 frontend/src/app/llm-workspace/section-configurator/section-configurator.component.ts diff --git a/backend/src/main/java/com/krrishg/config/SuggestRateLimitConfig.java b/backend/src/main/java/com/krrishg/config/SuggestRateLimitConfig.java new file mode 100644 index 0000000..c9d07be --- /dev/null +++ b/backend/src/main/java/com/krrishg/config/SuggestRateLimitConfig.java @@ -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 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(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/krrishg/controller/LLMController.java b/backend/src/main/java/com/krrishg/controller/LLMController.java index a9a7cc3..d2867a1 100644 --- a/backend/src/main/java/com/krrishg/controller/LLMController.java +++ b/backend/src/main/java/com/krrishg/controller/LLMController.java @@ -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; } @@ -77,6 +82,40 @@ public class LLMController { } } + @PostMapping("/suggest-sections") + public ResponseEntity suggestSections( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody Map 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())); + } + } + @PostMapping("/refine") public ResponseEntity refine( @AuthenticationPrincipal UserPrincipal principal, diff --git a/backend/src/main/java/com/krrishg/dto/SuggestSectionsRequest.java b/backend/src/main/java/com/krrishg/dto/SuggestSectionsRequest.java new file mode 100644 index 0000000..0354158 --- /dev/null +++ b/backend/src/main/java/com/krrishg/dto/SuggestSectionsRequest.java @@ -0,0 +1,9 @@ +package com.krrishg.dto; + +public record SuggestSectionsRequest( + String briefDescription, + String provider, + String apiKey, + String baseUrl, + String model +) {} \ No newline at end of file diff --git a/backend/src/main/java/com/krrishg/dto/SuggestSectionsResponse.java b/backend/src/main/java/com/krrishg/dto/SuggestSectionsResponse.java new file mode 100644 index 0000000..3aede31 --- /dev/null +++ b/backend/src/main/java/com/krrishg/dto/SuggestSectionsResponse.java @@ -0,0 +1,19 @@ +package com.krrishg.dto; + +import java.util.List; + +public record SuggestSectionsResponse( + List sections +) { + public record SectionSuggestion( + String title, + String description, + String slug, + List patterns + ) {} + + public record PatternSuggestion( + String title, + String description + ) {} +} \ No newline at end of file diff --git a/backend/src/main/java/com/krrishg/service/LLMService.java b/backend/src/main/java/com/krrishg/service/LLMService.java index 1b981dc..f38a594 100644 --- a/backend/src/main/java/com/krrishg/service/LLMService.java +++ b/backend/src/main/java/com/krrishg/service/LLMService.java @@ -5,6 +5,7 @@ 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; @@ -12,6 +13,8 @@ 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; @@ -51,6 +54,8 @@ 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; @@ -63,6 +68,7 @@ public class LLMService { 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.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; @@ -73,6 +79,8 @@ 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; } @@ -107,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 references, String provider, String apiKey) { @@ -421,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 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 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; + } } diff --git a/backend/src/test/java/com/krrishg/config/GlobalExceptionHandlerTest.java b/backend/src/test/java/com/krrishg/config/GlobalExceptionHandlerTest.java index 3ca343b..776bf2b 100644 --- a/backend/src/test/java/com/krrishg/config/GlobalExceptionHandlerTest.java +++ b/backend/src/test/java/com/krrishg/config/GlobalExceptionHandlerTest.java @@ -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")); diff --git a/backend/src/test/java/com/krrishg/config/SuggestRateLimitConfigTest.java b/backend/src/test/java/com/krrishg/config/SuggestRateLimitConfigTest.java new file mode 100644 index 0000000..6edb3f5 --- /dev/null +++ b/backend/src/test/java/com/krrishg/config/SuggestRateLimitConfigTest.java @@ -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)); + } +} diff --git a/backend/src/test/java/com/krrishg/controller/LLMControllerTest.java b/backend/src/test/java/com/krrishg/controller/LLMControllerTest.java index ad9c17a..5f8fe92 100644 --- a/backend/src/test/java/com/krrishg/controller/LLMControllerTest.java +++ b/backend/src/test/java/com/krrishg/controller/LLMControllerTest.java @@ -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 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 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 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 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 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 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 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 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 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 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)); + } + } } diff --git a/backend/src/test/java/com/krrishg/model/VerificationTokenTest.java b/backend/src/test/java/com/krrishg/model/VerificationTokenTest.java new file mode 100644 index 0000000..d43da98 --- /dev/null +++ b/backend/src/test/java/com/krrishg/model/VerificationTokenTest.java @@ -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()); + } +} diff --git a/backend/src/test/java/com/krrishg/repository/VerificationTokenRepositoryTest.java b/backend/src/test/java/com/krrishg/repository/VerificationTokenRepositoryTest.java new file mode 100644 index 0000000..9041116 --- /dev/null +++ b/backend/src/test/java/com/krrishg/repository/VerificationTokenRepositoryTest.java @@ -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 found = repository.findByToken("verify-token-123"); + + assertTrue(found.isPresent()); + assertEquals(token.getUserId(), found.get().getUserId()); + assertFalse(found.get().isUsed()); + } + + @Test + void findByTokenReturnsEmptyWhenNotFound() { + Optional 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 found = repository.findBySetupToken("setup-token-456"); + + assertTrue(found.isPresent()); + assertEquals("verify-token", found.get().getToken()); + } + + @Test + void findBySetupTokenReturnsEmptyWhenNotFound() { + Optional 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()); + } +} diff --git a/backend/src/test/java/com/krrishg/service/LLMServiceTest.java b/backend/src/test/java/com/krrishg/service/LLMServiceTest.java index 2cc7686..451ca5e 100644 --- a/backend/src/test/java/com/krrishg/service/LLMServiceTest.java +++ b/backend/src/test/java/com/krrishg/service/LLMServiceTest.java @@ -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; @@ -83,6 +84,7 @@ class LLMServiceTest { mongoTemplate, userRepository, encryptionService, resourceLoader, "classpath:prompts/site-generator.txt", "classpath:prompts/site-refiner.txt", + "classpath:prompts/suggest-sections.txt", 10, 0.3); } @@ -493,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()); + } } diff --git a/frontend/src/app/core/models/llm.ts b/frontend/src/app/core/models/llm.ts index 73a2a90..4d491f2 100644 --- a/frontend/src/app/core/models/llm.ts +++ b/frontend/src/app/core/models/llm.ts @@ -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; diff --git a/frontend/src/app/core/services/llm-api.service.ts b/frontend/src/app/core/services/llm-api.service.ts index bdc5422..54da045 100644 --- a/frontend/src/app/core/services/llm-api.service.ts +++ b/frontend/src/app/core/services/llm-api.service.ts @@ -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('/api/llm/refine', request); } + suggestSections(request: SuggestSectionsRequest): Observable { + return this.http.post('/api/llm/suggest-sections', request); + } + getVersions(siteId: string): Observable { return this.http.get(`/api/llm/versions/${siteId}`); } diff --git a/frontend/src/app/llm-workspace/llm-workspace.component.ts b/frontend/src/app/llm-workspace/llm-workspace.component.ts index 1be4fe1..65f43f5 100644 --- a/frontend/src/app/llm-workspace/llm-workspace.component.ts +++ b/frontend/src/app/llm-workspace/llm-workspace.component.ts @@ -20,10 +20,13 @@ import { LlmApiService } from '../core/services/llm-api.service'; import { LlmConfigService } from '../core/services/llm-config.service'; import { LlmSessionService } from './llm-session.service'; import { GenerationService } from './generation.service'; -import { SiteStructure, GenerationVersion, LLMKeyStatus } from '../core/models/llm'; +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, @@ -33,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; } @@ -52,26 +56,24 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial - -
- + - Describe your website + Describe your website idea
- 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.
@@ -84,19 +86,51 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
-
- +
+ +
+

Analyzing your idea...

+

Finding the right sections for your site

+
+
+ + +
+
+
+

+ {{ configStepTitle }} +

+

+ {{ configStepSubtitle }} +

+
+ +
+ + +
+ + +

Configure AI Provider

@@ -146,6 +180,15 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial

+
+ error_outline +
+

Suggestion failed

+

{{ suggestError }}

+ +
+
+
error_outline
@@ -325,11 +368,11 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
-
+
auto_awesome

Describe your dream site

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

@@ -347,6 +390,17 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy { 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; @@ -377,9 +431,14 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy { const siteId = params.get('siteId'); if (siteId !== this.siteId) { this.siteId = siteId; + this.briefDescription = ''; this.prompt = ''; this.refinePrompt = ''; this.showRefine = false; + this.currentPhase = 'idle'; + this.sectionSuggestions = []; + this.sectionConfig = null; + this.suggestError = null; this.session.clearHistory(); this.loadVersions(); this.loadLlmConfig(); @@ -439,17 +498,72 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy { }); } + suggestSections(): void { + if (!this.briefDescription.trim() || !this.siteId || !this.selectedProvider) return; + this.suggesting = true; + this.suggestError = null; + + const baseUrl = this.llmBaseUrls[this.selectedProvider]; + const model = this.llmModels[this.selectedProvider]; + + 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.prompt.trim() || !this.siteId || !this.selectedProvider) return; + if (!this.sectionConfig || !this.siteId || !this.selectedProvider) return; + + const compiledPrompt = this.buildPrompt(); + this.prompt = compiledPrompt; + this.currentPhase = 'generating'; this.showRefine = false; - this.session.setPrompt(this.prompt); + this.session.setPrompt(compiledPrompt); const baseUrl = this.llmBaseUrls[this.selectedProvider]; const model = this.llmModels[this.selectedProvider]; this.generationService.generate({ - prompt: this.prompt, + prompt: compiledPrompt, siteId: this.siteId, provider: this.selectedProvider, baseUrl, @@ -457,6 +571,38 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy { }, 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; diff --git a/frontend/src/app/llm-workspace/section-configurator/section-configurator.component.ts b/frontend/src/app/llm-workspace/section-configurator/section-configurator.component.ts new file mode 100644 index 0000000..f16fb51 --- /dev/null +++ b/frontend/src/app/llm-workspace/section-configurator/section-configurator.component.ts @@ -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: ` +
+
Sections
+
+
Theme
+
+
Details
+
+ +
+
+
+

Configure {{ currentSection.title }}

+

Section {{ sectionIndex + 1 }} of {{ sections.length }}

+
+
+ +

{{ currentSection.description }}

+ +
+
+ warning_amber + This section won't be included +
+ +
+ +
+
+
+
+
+
+
+
{{ pattern.title }}
+
{{ pattern.description }}
+
+
+
+ +
+
+
+
+
+
+
Custom
+
Describe your own layout and design for this section.
+
+ + Describe your custom layout + + +
+
+
+
+
+ +
+ + Custom notes for {{ currentSection.title }} + + +
+ +
+ +
+
+ +
+

Choose Theme Direction

+

Pick a general aesthetic for your site, or describe your own.

+ + + Theme vibe + + Any + {{ vibe }} + Custom... + + + + + Describe your custom theme + + +
+ + +
+

Additional Details

+

Anything else you want the site to have — specific features, layout preferences, content ideas.

+ + + Additional requirements (optional) + + +
+ +
+ + +
+ {{ navLabel }} +
+ + +
+ `, +}) +export class SectionConfiguratorComponent implements OnInit { + @Input() suggestions: SectionSuggestion[] = []; + @Output() configComplete = new EventEmitter(); + @Output() stepChange = new EventEmitter(); + + 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, + }); + } + } +}