update site generation flow
This commit is contained in:
@@ -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;
|
package com.krrishg.controller;
|
||||||
|
|
||||||
import com.krrishg.config.RateLimitConfig;
|
import com.krrishg.config.RateLimitConfig;
|
||||||
|
import com.krrishg.config.SuggestRateLimitConfig;
|
||||||
import com.krrishg.config.UserPrincipal;
|
import com.krrishg.config.UserPrincipal;
|
||||||
import com.krrishg.dto.Reference;
|
import com.krrishg.dto.Reference;
|
||||||
import com.krrishg.dto.SiteStructure;
|
import com.krrishg.dto.SiteStructure;
|
||||||
|
import com.krrishg.dto.SuggestSectionsResponse;
|
||||||
import com.krrishg.model.GenerationVersion;
|
import com.krrishg.model.GenerationVersion;
|
||||||
import com.krrishg.service.LLMService;
|
import com.krrishg.service.LLMService;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
@@ -23,11 +25,14 @@ public class LLMController {
|
|||||||
|
|
||||||
private final LLMService llmService;
|
private final LLMService llmService;
|
||||||
private final RateLimitConfig rateLimitConfig;
|
private final RateLimitConfig rateLimitConfig;
|
||||||
|
private final SuggestRateLimitConfig suggestRateLimitConfig;
|
||||||
private final ObjectMapper objectMapper;
|
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.llmService = llmService;
|
||||||
this.rateLimitConfig = rateLimitConfig;
|
this.rateLimitConfig = rateLimitConfig;
|
||||||
|
this.suggestRateLimitConfig = suggestRateLimitConfig;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +82,40 @@ public class LLMController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/refine")
|
@PostMapping("/refine")
|
||||||
public ResponseEntity<?> refine(
|
public ResponseEntity<?> refine(
|
||||||
@AuthenticationPrincipal UserPrincipal principal,
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.krrishg.dto.LLMOptions;
|
|||||||
import com.krrishg.dto.LLMResult;
|
import com.krrishg.dto.LLMResult;
|
||||||
import com.krrishg.dto.Reference;
|
import com.krrishg.dto.Reference;
|
||||||
import com.krrishg.dto.SiteStructure;
|
import com.krrishg.dto.SiteStructure;
|
||||||
|
import com.krrishg.dto.SuggestSectionsResponse;
|
||||||
import org.springframework.web.client.HttpClientErrorException;
|
import org.springframework.web.client.HttpClientErrorException;
|
||||||
import org.springframework.web.client.ResourceAccessException;
|
import org.springframework.web.client.ResourceAccessException;
|
||||||
import com.krrishg.model.GenerationVersion;
|
import com.krrishg.model.GenerationVersion;
|
||||||
@@ -12,6 +13,8 @@ import com.krrishg.model.User;
|
|||||||
import com.krrishg.repository.UserRepository;
|
import com.krrishg.repository.UserRepository;
|
||||||
import com.krrishg.service.llm.LLMProvider;
|
import com.krrishg.service.llm.LLMProvider;
|
||||||
import com.krrishg.service.llm.ProviderFactory;
|
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 com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -51,6 +54,8 @@ public class LLMService {
|
|||||||
private final EncryptionService encryptionService;
|
private final EncryptionService encryptionService;
|
||||||
private final String systemPrompt;
|
private final String systemPrompt;
|
||||||
private final String refinerPrompt;
|
private final String refinerPrompt;
|
||||||
|
private final String suggestSectionsPrompt;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
private final int maxVersionsPerSite;
|
private final int maxVersionsPerSite;
|
||||||
private final double refineTemperature;
|
private final double refineTemperature;
|
||||||
|
|
||||||
@@ -63,6 +68,7 @@ public class LLMService {
|
|||||||
ResourceLoader resourceLoader,
|
ResourceLoader resourceLoader,
|
||||||
@Value("${indie.llm.system-prompt-path:classpath:prompts/site-generator.txt}") String promptPath,
|
@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.max-versions-per-site:10}") int maxVersionsPerSite,
|
||||||
@Value("${indie.llm.refine.temperature:0.3}") double refineTemperature) {
|
@Value("${indie.llm.refine.temperature:0.3}") double refineTemperature) {
|
||||||
this.providerFactory = providerFactory;
|
this.providerFactory = providerFactory;
|
||||||
@@ -73,6 +79,8 @@ public class LLMService {
|
|||||||
this.encryptionService = encryptionService;
|
this.encryptionService = encryptionService;
|
||||||
this.systemPrompt = loadSystemPrompt(resourceLoader, promptPath);
|
this.systemPrompt = loadSystemPrompt(resourceLoader, promptPath);
|
||||||
this.refinerPrompt = loadSystemPrompt(resourceLoader, refinerPath);
|
this.refinerPrompt = loadSystemPrompt(resourceLoader, refinerPath);
|
||||||
|
this.suggestSectionsPrompt = loadSystemPrompt(resourceLoader, suggestSectionsPath);
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
this.maxVersionsPerSite = maxVersionsPerSite;
|
this.maxVersionsPerSite = maxVersionsPerSite;
|
||||||
this.refineTemperature = refineTemperature;
|
this.refineTemperature = refineTemperature;
|
||||||
}
|
}
|
||||||
@@ -107,6 +115,35 @@ public class LLMService {
|
|||||||
return structure;
|
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,
|
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
|
||||||
String refinementPrompt, List<Reference> references,
|
String refinementPrompt, List<Reference> references,
|
||||||
String provider, String apiKey) {
|
String provider, String apiKey) {
|
||||||
@@ -421,4 +458,65 @@ public class LLMService {
|
|||||||
throw new RuntimeException("Failed to serialize site structure", e);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.krrishg.config;
|
package com.krrishg.config;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.JwtException;
|
||||||
|
import org.apache.catalina.connector.ClientAbortException;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ProblemDetail;
|
import org.springframework.http.ProblemDetail;
|
||||||
@@ -13,6 +15,19 @@ class GlobalExceptionHandlerTest {
|
|||||||
|
|
||||||
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
|
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
|
@Test
|
||||||
void illegalArgumentReturns400() {
|
void illegalArgumentReturns400() {
|
||||||
ProblemDetail detail = handler.handleBadRequest(new IllegalArgumentException("bad input"));
|
ProblemDetail detail = handler.handleBadRequest(new IllegalArgumentException("bad input"));
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ package com.krrishg.controller;
|
|||||||
import com.krrishg.config.GlobalExceptionHandler;
|
import com.krrishg.config.GlobalExceptionHandler;
|
||||||
import com.krrishg.config.JwtAuthenticationToken;
|
import com.krrishg.config.JwtAuthenticationToken;
|
||||||
import com.krrishg.config.RateLimitConfig;
|
import com.krrishg.config.RateLimitConfig;
|
||||||
|
import com.krrishg.config.SuggestRateLimitConfig;
|
||||||
import com.krrishg.config.UserPrincipal;
|
import com.krrishg.config.UserPrincipal;
|
||||||
import com.krrishg.dto.Reference;
|
import com.krrishg.dto.Reference;
|
||||||
import com.krrishg.dto.SiteStructure;
|
import com.krrishg.dto.SiteStructure;
|
||||||
|
import com.krrishg.dto.SuggestSectionsResponse;
|
||||||
import com.krrishg.model.GenerationVersion;
|
import com.krrishg.model.GenerationVersion;
|
||||||
import com.krrishg.service.LLMService;
|
import com.krrishg.service.LLMService;
|
||||||
import com.krrishg.support.TestSecurityConfig;
|
import com.krrishg.support.TestSecurityConfig;
|
||||||
@@ -45,6 +47,9 @@ class LLMControllerTest {
|
|||||||
@MockBean
|
@MockBean
|
||||||
private RateLimitConfig rateLimitConfig;
|
private RateLimitConfig rateLimitConfig;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private SuggestRateLimitConfig suggestRateLimitConfig;
|
||||||
|
|
||||||
private UUID userId;
|
private UUID userId;
|
||||||
private UserPrincipal principal;
|
private UserPrincipal principal;
|
||||||
private JwtAuthenticationToken auth;
|
private JwtAuthenticationToken auth;
|
||||||
@@ -219,6 +224,22 @@ class LLMControllerTest {
|
|||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(jsonPath("$.error").value("No API key configured for gemini"));
|
.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
|
@Nested
|
||||||
@@ -296,6 +317,171 @@ class LLMControllerTest {
|
|||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isBadRequest());
|
.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
|
@Nested
|
||||||
@@ -383,4 +569,45 @@ class LLMControllerTest {
|
|||||||
.andExpect(status().isNotFound());
|
.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.krrishg.service;
|
|||||||
|
|
||||||
import com.krrishg.dto.Reference;
|
import com.krrishg.dto.Reference;
|
||||||
import com.krrishg.dto.SiteStructure;
|
import com.krrishg.dto.SiteStructure;
|
||||||
|
import com.krrishg.dto.SuggestSectionsResponse;
|
||||||
import com.krrishg.model.GenerationVersion;
|
import com.krrishg.model.GenerationVersion;
|
||||||
import com.krrishg.model.User;
|
import com.krrishg.model.User;
|
||||||
import com.krrishg.support.FakeEncryptionService;
|
import com.krrishg.support.FakeEncryptionService;
|
||||||
@@ -83,6 +84,7 @@ class LLMServiceTest {
|
|||||||
mongoTemplate, userRepository, encryptionService,
|
mongoTemplate, userRepository, encryptionService,
|
||||||
resourceLoader, "classpath:prompts/site-generator.txt",
|
resourceLoader, "classpath:prompts/site-generator.txt",
|
||||||
"classpath:prompts/site-refiner.txt",
|
"classpath:prompts/site-refiner.txt",
|
||||||
|
"classpath:prompts/suggest-sections.txt",
|
||||||
10, 0.3);
|
10, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,4 +495,57 @@ class LLMServiceTest {
|
|||||||
assertThrows(IllegalArgumentException.class, () -> llmService.deleteVersion("nonexistent"));
|
assertThrows(IllegalArgumentException.class, () -> llmService.deleteVersion("nonexistent"));
|
||||||
verify(mongoTemplate, never()).remove(any());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,40 @@ export interface GenerationVersion {
|
|||||||
createdAt: string;
|
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 {
|
export interface LLMKeyEntry {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class LlmApiService {
|
export class LlmApiService {
|
||||||
@@ -15,6 +15,10 @@ export class LlmApiService {
|
|||||||
return this.http.post<SiteStructure>('/api/llm/refine', request);
|
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[]> {
|
getVersions(siteId: string): Observable<GenerationVersion[]> {
|
||||||
return this.http.get<GenerationVersion[]>(`/api/llm/versions/${siteId}`);
|
return this.http.get<GenerationVersion[]>(`/api/llm/versions/${siteId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ import { LlmApiService } from '../core/services/llm-api.service';
|
|||||||
import { LlmConfigService } from '../core/services/llm-config.service';
|
import { LlmConfigService } from '../core/services/llm-config.service';
|
||||||
import { LlmSessionService } from './llm-session.service';
|
import { LlmSessionService } from './llm-session.service';
|
||||||
import { GenerationService } from './generation.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 { PreviewDialogComponent } from './preview-dialog/preview-dialog.component';
|
||||||
import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dialog';
|
import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dialog';
|
||||||
|
|
||||||
|
type Phase = 'idle' | 'suggesting' | 'configuring' | 'generating';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-llm-workspace',
|
selector: 'app-llm-workspace',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -33,6 +36,7 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
|||||||
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule, MatSelectModule,
|
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule, MatSelectModule,
|
||||||
MatSnackBarModule, MatCardModule,
|
MatSnackBarModule, MatCardModule,
|
||||||
MatDividerModule, MatExpansionModule, MatDialogModule,
|
MatDividerModule, MatExpansionModule, MatDialogModule,
|
||||||
|
SectionConfiguratorComponent,
|
||||||
],
|
],
|
||||||
styles: [`
|
styles: [`
|
||||||
.provider-select { width: 140px; }
|
.provider-select { width: 140px; }
|
||||||
@@ -52,26 +56,24 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
|||||||
</button>
|
</button>
|
||||||
</mat-toolbar>
|
</mat-toolbar>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
<div class="px-6 py-6 space-y-6">
|
<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-card-content class="!p-0">
|
||||||
<mat-form-field appearance="outline" class="w-full">
|
<mat-form-field appearance="outline" class="w-full">
|
||||||
<mat-label>Describe your website</mat-label>
|
<mat-label>Describe your website idea</mat-label>
|
||||||
<textarea
|
<textarea
|
||||||
matInput
|
matInput
|
||||||
rows="5"
|
rows="4"
|
||||||
placeholder="e.g. A personal portfolio for a photographer with a gallery page, about section, and contact form. Dark theme with accent gold."
|
placeholder="e.g. A photography portfolio for Alex Chen"
|
||||||
[(ngModel)]="prompt"
|
[(ngModel)]="briefDescription"
|
||||||
[disabled]="(session.generating$ | async) === true"
|
[disabled]="suggesting"
|
||||||
></textarea>
|
></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<div class="flex items-center justify-between mt-2">
|
<div class="flex items-center justify-between mt-2">
|
||||||
<span class="text-xs text-gray-400">
|
<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>
|
</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<mat-form-field appearance="outline" class="!mb-0 provider-select" *ngIf="configuredProviders.length > 1">
|
<mat-form-field appearance="outline" class="!mb-0 provider-select" *ngIf="configuredProviders.length > 1">
|
||||||
@@ -84,19 +86,51 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
|||||||
<button
|
<button
|
||||||
mat-raised-button
|
mat-raised-button
|
||||||
color="primary"
|
color="primary"
|
||||||
[disabled]="!prompt.trim() || (session.generating$ | async) || configuredProviders.length === 0"
|
[disabled]="!briefDescription.trim() || suggesting || configuredProviders.length === 0"
|
||||||
(click)="generate()"
|
(click)="suggestSections()"
|
||||||
>
|
>
|
||||||
<mat-icon>auto_awesome</mat-icon>
|
<mat-icon>auto_awesome</mat-icon>
|
||||||
Generate
|
Plan My Site
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
<mat-card *ngIf="configuredProviders.length === 0 && !(session.generating$ | async)" class="p-4" appearance="outlined">
|
<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">
|
<mat-card-content class="!p-0">
|
||||||
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Configure AI Provider</h4>
|
<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">
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
@@ -146,6 +180,15 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<mat-icon class="text-red-500 mt-0.5">error_outline</mat-icon>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -325,11 +368,11 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="configuredProviders.length > 0 && !(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>
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,6 +390,17 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
private generationService = inject(GenerationService);
|
private generationService = inject(GenerationService);
|
||||||
session = inject(LlmSessionService);
|
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 = '';
|
prompt = '';
|
||||||
refinePrompt = '';
|
refinePrompt = '';
|
||||||
showRefine = false;
|
showRefine = false;
|
||||||
@@ -377,9 +431,14 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
const siteId = params.get('siteId');
|
const siteId = params.get('siteId');
|
||||||
if (siteId !== this.siteId) {
|
if (siteId !== this.siteId) {
|
||||||
this.siteId = siteId;
|
this.siteId = siteId;
|
||||||
|
this.briefDescription = '';
|
||||||
this.prompt = '';
|
this.prompt = '';
|
||||||
this.refinePrompt = '';
|
this.refinePrompt = '';
|
||||||
this.showRefine = false;
|
this.showRefine = false;
|
||||||
|
this.currentPhase = 'idle';
|
||||||
|
this.sectionSuggestions = [];
|
||||||
|
this.sectionConfig = null;
|
||||||
|
this.suggestError = null;
|
||||||
this.session.clearHistory();
|
this.session.clearHistory();
|
||||||
this.loadVersions();
|
this.loadVersions();
|
||||||
this.loadLlmConfig();
|
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 {
|
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.showRefine = false;
|
||||||
this.session.setPrompt(this.prompt);
|
this.session.setPrompt(compiledPrompt);
|
||||||
|
|
||||||
const baseUrl = this.llmBaseUrls[this.selectedProvider];
|
const baseUrl = this.llmBaseUrls[this.selectedProvider];
|
||||||
const model = this.llmModels[this.selectedProvider];
|
const model = this.llmModels[this.selectedProvider];
|
||||||
this.generationService.generate({
|
this.generationService.generate({
|
||||||
prompt: this.prompt,
|
prompt: compiledPrompt,
|
||||||
siteId: this.siteId,
|
siteId: this.siteId,
|
||||||
provider: this.selectedProvider,
|
provider: this.selectedProvider,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -457,6 +571,38 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
}, this.siteId);
|
}, 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 {
|
refine(): void {
|
||||||
const result = this.session.currentResult;
|
const result = this.session.currentResult;
|
||||||
if (!result || !this.refinePrompt.trim() || !this.siteId || !this.selectedProvider) return;
|
if (!result || !this.refinePrompt.trim() || !this.siteId || !this.selectedProvider) return;
|
||||||
|
|||||||
@@ -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