live update while generating site
This commit is contained in:
24
backend/src/main/java/com/krrishg/config/AsyncConfig.java
Normal file
24
backend/src/main/java/com/krrishg/config/AsyncConfig.java
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package com.krrishg.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncConfig {
|
||||||
|
|
||||||
|
@Bean(name = "generationExecutor")
|
||||||
|
public Executor generationExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(2);
|
||||||
|
executor.setMaxPoolSize(4);
|
||||||
|
executor.setQueueCapacity(10);
|
||||||
|
executor.setThreadNamePrefix("gen-");
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import org.springframework.validation.FieldError;
|
|||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -36,6 +37,11 @@ public class GlobalExceptionHandler {
|
|||||||
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, message);
|
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ResponseStatusException.class)
|
||||||
|
public ProblemDetail handleResponseStatus(ResponseStatusException ex) {
|
||||||
|
return ProblemDetail.forStatusAndDetail(ex.getStatusCode(), ex.getReason());
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(ClientAbortException.class)
|
@ExceptionHandler(ClientAbortException.class)
|
||||||
public void handleClientAbort(ClientAbortException ex) {
|
public void handleClientAbort(ClientAbortException ex) {
|
||||||
if (log.isDebugEnabled()) {
|
if (log.isDebugEnabled()) {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/auth/**").permitAll()
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
.requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
|
.requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/sites/**").authenticated()
|
.requestMatchers(HttpMethod.GET, "/api/sites/**").authenticated()
|
||||||
|
.requestMatchers("/api/llm/generate/*/events").permitAll()
|
||||||
|
.requestMatchers("/api/llm/refine/*/events").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.exceptionHandling(ex -> ex
|
.exceptionHandling(ex -> ex
|
||||||
@@ -84,10 +86,9 @@ public class SecurityConfig {
|
|||||||
HttpServletResponse response,
|
HttpServletResponse response,
|
||||||
FilterChain filterChain)
|
FilterChain filterChain)
|
||||||
throws ServletException, IOException {
|
throws ServletException, IOException {
|
||||||
String header = request.getHeader("Authorization");
|
String token = extractToken(request);
|
||||||
|
|
||||||
if (header != null && header.startsWith("Bearer ")) {
|
if (token != null) {
|
||||||
String token = header.substring(7);
|
|
||||||
try {
|
try {
|
||||||
Claims claims = jwtConfig.validateToken(token);
|
Claims claims = jwtConfig.validateToken(token);
|
||||||
UUID userId = UUID.fromString(claims.getSubject());
|
UUID userId = UUID.fromString(claims.getSubject());
|
||||||
@@ -107,6 +108,18 @@ public class SecurityConfig {
|
|||||||
|
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String extractToken(HttpServletRequest request) {
|
||||||
|
String header = request.getHeader("Authorization");
|
||||||
|
if (header != null && header.startsWith("Bearer ")) {
|
||||||
|
return header.substring(7);
|
||||||
|
}
|
||||||
|
String queryToken = request.getParameter("token");
|
||||||
|
if (queryToken != null && !queryToken.isBlank()) {
|
||||||
|
return queryToken;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.krrishg.controller;
|
package com.krrishg.controller;
|
||||||
|
|
||||||
|
import com.krrishg.config.JwtConfig;
|
||||||
import com.krrishg.config.RateLimitConfig;
|
import com.krrishg.config.RateLimitConfig;
|
||||||
import com.krrishg.config.SuggestRateLimitConfig;
|
import com.krrishg.config.SuggestRateLimitConfig;
|
||||||
import com.krrishg.config.UserPrincipal;
|
import com.krrishg.config.UserPrincipal;
|
||||||
@@ -8,12 +9,16 @@ import com.krrishg.dto.SiteStructure;
|
|||||||
import com.krrishg.dto.SuggestSectionsResponse;
|
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.service.ProgressService;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -24,18 +29,37 @@ import java.util.UUID;
|
|||||||
public class LLMController {
|
public class LLMController {
|
||||||
|
|
||||||
private final LLMService llmService;
|
private final LLMService llmService;
|
||||||
|
private final ProgressService progressService;
|
||||||
|
private final JwtConfig jwtConfig;
|
||||||
private final RateLimitConfig rateLimitConfig;
|
private final RateLimitConfig rateLimitConfig;
|
||||||
private final SuggestRateLimitConfig suggestRateLimitConfig;
|
private final SuggestRateLimitConfig suggestRateLimitConfig;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public LLMController(LLMService llmService, RateLimitConfig rateLimitConfig,
|
public LLMController(LLMService llmService, ProgressService progressService,
|
||||||
|
JwtConfig jwtConfig,
|
||||||
|
RateLimitConfig rateLimitConfig,
|
||||||
SuggestRateLimitConfig suggestRateLimitConfig, ObjectMapper objectMapper) {
|
SuggestRateLimitConfig suggestRateLimitConfig, ObjectMapper objectMapper) {
|
||||||
this.llmService = llmService;
|
this.llmService = llmService;
|
||||||
|
this.progressService = progressService;
|
||||||
|
this.jwtConfig = jwtConfig;
|
||||||
this.rateLimitConfig = rateLimitConfig;
|
this.rateLimitConfig = rateLimitConfig;
|
||||||
this.suggestRateLimitConfig = suggestRateLimitConfig;
|
this.suggestRateLimitConfig = suggestRateLimitConfig;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private UserPrincipal resolveToken(String token) {
|
||||||
|
if (token == null || token.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
var claims = jwtConfig.validateToken(token);
|
||||||
|
UUID userId = UUID.fromString(claims.getSubject());
|
||||||
|
String email = claims.get("email", String.class);
|
||||||
|
String name = claims.get("name", String.class);
|
||||||
|
return new UserPrincipal(userId, email, name);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/generate")
|
@PostMapping("/generate")
|
||||||
public ResponseEntity<?> generate(
|
public ResponseEntity<?> generate(
|
||||||
@AuthenticationPrincipal UserPrincipal principal,
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@@ -73,13 +97,17 @@ public class LLMController {
|
|||||||
request.get("references"), new TypeReference<List<Reference>>() {});
|
request.get("references"), new TypeReference<List<Reference>>() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
llmService.generateSiteAsync(principal.id(), siteId, prompt, references, provider, apiKey, baseUrl, model);
|
||||||
SiteStructure structure = llmService.generateSite(
|
return ResponseEntity.accepted().body(Map.of("siteId", siteIdStr));
|
||||||
principal.id(), siteId, prompt, references, provider, apiKey, baseUrl, model);
|
}
|
||||||
return ResponseEntity.ok(structure);
|
|
||||||
} catch (IllegalStateException | IllegalArgumentException e) {
|
@GetMapping(value = "/generate/{siteId}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
public SseEmitter generateEvents(@PathVariable UUID siteId, @RequestParam(required = false) String token) {
|
||||||
|
UserPrincipal principal = resolveToken(token);
|
||||||
|
if (principal == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid or missing token");
|
||||||
}
|
}
|
||||||
|
return progressService.register(siteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/suggest-sections")
|
@PostMapping("/suggest-sections")
|
||||||
@@ -165,13 +193,17 @@ public class LLMController {
|
|||||||
request.get("references"), new TypeReference<List<Reference>>() {});
|
request.get("references"), new TypeReference<List<Reference>>() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
llmService.refineSiteAsync(principal.id(), siteId, currentStructure, prompt, references, provider, apiKey, baseUrl, model);
|
||||||
SiteStructure structure = llmService.refineSite(principal.id(), siteId,
|
return ResponseEntity.accepted().body(Map.of("siteId", siteIdStr));
|
||||||
currentStructure, prompt, references, provider, apiKey, baseUrl, model);
|
}
|
||||||
return ResponseEntity.ok(structure);
|
|
||||||
} catch (IllegalStateException | IllegalArgumentException e) {
|
@GetMapping(value = "/refine/{siteId}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
public SseEmitter refineEvents(@PathVariable UUID siteId, @RequestParam(required = false) String token) {
|
||||||
|
UserPrincipal principal = resolveToken(token);
|
||||||
|
if (principal == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid or missing token");
|
||||||
}
|
}
|
||||||
|
return progressService.register(siteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/versions/{siteId}")
|
@GetMapping("/versions/{siteId}")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.krrishg.service;
|
package com.krrishg.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonParser;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
@@ -25,6 +26,7 @@ public class LLMResponseParser {
|
|||||||
public LLMResponseParser(ObjectMapper objectMapper) {
|
public LLMResponseParser(ObjectMapper objectMapper) {
|
||||||
this.objectMapper = objectMapper.copy()
|
this.objectMapper = objectMapper.copy()
|
||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
this.objectMapper.configure(JsonParser.Feature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public SiteStructure parse(String llmOutput) {
|
public SiteStructure parse(String llmOutput) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import org.springframework.data.domain.Sort;
|
|||||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||||
import org.springframework.data.mongodb.core.query.Criteria;
|
import org.springframework.data.mongodb.core.query.Criteria;
|
||||||
import org.springframework.data.mongodb.core.query.Query;
|
import org.springframework.data.mongodb.core.query.Query;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
@@ -39,6 +40,9 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -49,6 +53,7 @@ public class LLMService {
|
|||||||
private final ProviderFactory providerFactory;
|
private final ProviderFactory providerFactory;
|
||||||
private final LLMResponseParser responseParser;
|
private final LLMResponseParser responseParser;
|
||||||
private final ReferenceService referenceService;
|
private final ReferenceService referenceService;
|
||||||
|
private final ProgressService progressService;
|
||||||
private final MongoTemplate mongoTemplate;
|
private final MongoTemplate mongoTemplate;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final EncryptionService encryptionService;
|
private final EncryptionService encryptionService;
|
||||||
@@ -62,6 +67,7 @@ public class LLMService {
|
|||||||
public LLMService(ProviderFactory providerFactory,
|
public LLMService(ProviderFactory providerFactory,
|
||||||
LLMResponseParser responseParser,
|
LLMResponseParser responseParser,
|
||||||
ReferenceService referenceService,
|
ReferenceService referenceService,
|
||||||
|
ProgressService progressService,
|
||||||
MongoTemplate mongoTemplate,
|
MongoTemplate mongoTemplate,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
EncryptionService encryptionService,
|
EncryptionService encryptionService,
|
||||||
@@ -74,6 +80,7 @@ public class LLMService {
|
|||||||
this.providerFactory = providerFactory;
|
this.providerFactory = providerFactory;
|
||||||
this.responseParser = responseParser;
|
this.responseParser = responseParser;
|
||||||
this.referenceService = referenceService;
|
this.referenceService = referenceService;
|
||||||
|
this.progressService = progressService;
|
||||||
this.mongoTemplate = mongoTemplate;
|
this.mongoTemplate = mongoTemplate;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.encryptionService = encryptionService;
|
this.encryptionService = encryptionService;
|
||||||
@@ -115,6 +122,62 @@ public class LLMService {
|
|||||||
return structure;
|
return structure;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Async("generationExecutor")
|
||||||
|
public void generateSiteAsync(UUID userId, UUID siteId, String prompt,
|
||||||
|
List<Reference> references, String provider, String apiKey, String baseUrl, String model) {
|
||||||
|
try {
|
||||||
|
progressService.sendProgress(siteId, "resolve_config", "Resolving API configuration...");
|
||||||
|
|
||||||
|
String resolvedKey = resolveApiKey(userId, provider, apiKey);
|
||||||
|
String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl);
|
||||||
|
String resolvedModel = resolveModel(userId, provider, model);
|
||||||
|
|
||||||
|
progressService.sendProgress(siteId, "prepare_prompt", "Preparing prompt context...");
|
||||||
|
|
||||||
|
String referenceContext = referenceService.buildReferenceContext(references);
|
||||||
|
String fullPrompt = prompt + referenceContext;
|
||||||
|
|
||||||
|
progressService.sendProgress(siteId, "calling_ai", "Contacting " + provider + " AI...");
|
||||||
|
progressService.sendProgress(siteId, "generating_section", "Generating pages...");
|
||||||
|
|
||||||
|
LLMOptions options = LLMOptions.builder().build();
|
||||||
|
StringBuilder jsonBuffer = new StringBuilder();
|
||||||
|
Set<String> seenTitles = new HashSet<>();
|
||||||
|
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel,
|
||||||
|
systemPrompt, fullPrompt, references, options,
|
||||||
|
chunk -> {
|
||||||
|
progressService.sendStreamingChunk(siteId, chunk);
|
||||||
|
jsonBuffer.append(chunk);
|
||||||
|
detectAndReportNewPages(siteId, jsonBuffer.toString(), seenTitles);
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("LLM generation completed: model={}, latency={}ms, tokens={}+{}",
|
||||||
|
result.getModel(), result.getLatencyMs(),
|
||||||
|
result.getPromptTokens(), result.getCompletionTokens());
|
||||||
|
|
||||||
|
progressService.sendProgress(siteId, "generating_section", "");
|
||||||
|
progressService.sendProgress(siteId, "parsing", "Processing AI response...");
|
||||||
|
|
||||||
|
SiteStructure structure = responseParser.parse(result.getContent());
|
||||||
|
|
||||||
|
for (SiteStructure.PageStructure page : structure.getPages()) {
|
||||||
|
if (seenTitles.add(page.getTitle())) {
|
||||||
|
log.info("Detected page from final JSON: title={}, site={}", page.getTitle(), siteId);
|
||||||
|
progressService.sendProgress(siteId, "generating_section", "Generating page: " + page.getTitle());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressService.sendProgress(siteId, "saving", "Saving generation version...");
|
||||||
|
|
||||||
|
saveVersion(userId, siteId, prompt, references, result.getContent());
|
||||||
|
|
||||||
|
progressService.sendComplete(siteId, structure);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Async generation failed for site {}: {}", siteId, e.getMessage(), e);
|
||||||
|
progressService.sendError(siteId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public SuggestSectionsResponse suggestSections(UUID userId, String briefDescription,
|
public SuggestSectionsResponse suggestSections(UUID userId, String briefDescription,
|
||||||
String provider, String apiKey) {
|
String provider, String apiKey) {
|
||||||
return suggestSections(userId, briefDescription, provider, apiKey, null, null);
|
return suggestSections(userId, briefDescription, provider, apiKey, null, null);
|
||||||
@@ -133,7 +196,7 @@ public class LLMService {
|
|||||||
|
|
||||||
LLMOptions options = LLMOptions.builder()
|
LLMOptions options = LLMOptions.builder()
|
||||||
.temperature(0.2)
|
.temperature(0.2)
|
||||||
.maxTokens(2000)
|
.maxTokens(8192)
|
||||||
.build();
|
.build();
|
||||||
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel,
|
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel,
|
||||||
suggestSectionsPrompt, briefDescription, null, options);
|
suggestSectionsPrompt, briefDescription, null, options);
|
||||||
@@ -187,6 +250,65 @@ public class LLMService {
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Async("generationExecutor")
|
||||||
|
public void refineSiteAsync(UUID userId, UUID siteId, SiteStructure currentStructure,
|
||||||
|
String refinementPrompt, List<Reference> references,
|
||||||
|
String provider, String apiKey, String baseUrl, String model) {
|
||||||
|
try {
|
||||||
|
progressService.sendProgress(siteId, "resolve_config", "Resolving API configuration...");
|
||||||
|
|
||||||
|
String resolvedKey = resolveApiKey(userId, provider, apiKey);
|
||||||
|
String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl);
|
||||||
|
String resolvedModel = resolveModel(userId, provider, model);
|
||||||
|
|
||||||
|
progressService.sendProgress(siteId, "prepare_prompt", "Preparing refinement context...");
|
||||||
|
|
||||||
|
String currentJson = serializeStructure(currentStructure);
|
||||||
|
String referenceContext = referenceService.buildReferenceContext(references);
|
||||||
|
String combinedPrompt = "Current site structure:\n" + currentJson
|
||||||
|
+ "\n\nRefinement request: " + refinementPrompt
|
||||||
|
+ referenceContext
|
||||||
|
+ "\n\nOutput only the fields that changed. Fields you omit will remain unchanged."
|
||||||
|
+ " Include the \"id\" field for any page you modify or add."
|
||||||
|
+ " To delete a page, include it with \"id\" and \"deleted\": true."
|
||||||
|
+ " Pages not included in your output will stay as-is.";
|
||||||
|
|
||||||
|
progressService.sendProgress(siteId, "calling_ai", "Contacting " + provider + " AI...");
|
||||||
|
progressService.sendProgress(siteId, "generating_section", "Generating pages...");
|
||||||
|
|
||||||
|
LLMOptions options = LLMOptions.builder()
|
||||||
|
.temperature(refineTemperature)
|
||||||
|
.build();
|
||||||
|
StringBuilder jsonBuffer = new StringBuilder();
|
||||||
|
Set<String> seenTitles = new HashSet<>();
|
||||||
|
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, refinerPrompt, combinedPrompt, references, options,
|
||||||
|
chunk -> {
|
||||||
|
progressService.sendStreamingChunk(siteId, chunk);
|
||||||
|
jsonBuffer.append(chunk);
|
||||||
|
detectAndReportNewPages(siteId, jsonBuffer.toString(), seenTitles);
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("LLM refinement completed: model={}, latency={}ms, tokens={}+{}",
|
||||||
|
result.getModel(), result.getLatencyMs(),
|
||||||
|
result.getPromptTokens(), result.getCompletionTokens());
|
||||||
|
|
||||||
|
progressService.sendProgress(siteId, "generating_section", "");
|
||||||
|
progressService.sendProgress(siteId, "parsing", "Processing AI response...");
|
||||||
|
|
||||||
|
SiteStructure patch = responseParser.parseLenient(result.getContent());
|
||||||
|
SiteStructure merged = mergePatches(currentStructure, patch);
|
||||||
|
|
||||||
|
progressService.sendProgress(siteId, "saving", "Saving refinement version...");
|
||||||
|
|
||||||
|
saveVersion(userId, siteId, refinementPrompt, references, serializeStructure(merged));
|
||||||
|
|
||||||
|
progressService.sendRefineComplete(siteId, merged);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Async refinement failed for site {}: {}", siteId, e.getMessage(), e);
|
||||||
|
progressService.sendError(siteId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public List<GenerationVersion> getVersions(UUID siteId) {
|
public List<GenerationVersion> getVersions(UUID siteId) {
|
||||||
Query query = new Query(Criteria.where("siteId").is(siteId))
|
Query query = new Query(Criteria.where("siteId").is(siteId))
|
||||||
.with(Sort.by(Sort.Direction.DESC, "versionNumber"))
|
.with(Sort.by(Sort.Direction.DESC, "versionNumber"))
|
||||||
@@ -397,6 +519,41 @@ public class LLMService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private LLMResult callLLM(String provider, String apiKey, String baseUrl, String model, String systemPrompt,
|
||||||
|
String userPrompt, List<Reference> references, LLMOptions options, Consumer<String> onChunk) {
|
||||||
|
LLMProvider p = providerFactory.createProvider(provider, apiKey, baseUrl, model);
|
||||||
|
try {
|
||||||
|
return p.generate(systemPrompt, userPrompt, references, options, onChunk);
|
||||||
|
} catch (ResourceAccessException e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
provider + " timed out. Please try again or use a simpler prompt.");
|
||||||
|
} catch (HttpClientErrorException e) {
|
||||||
|
String detail = e.getResponseBodyAsString();
|
||||||
|
String msg = switch (e.getStatusCode().value()) {
|
||||||
|
case 401 -> provider + " rejected the API key (401 Unauthorized). Verify the key is valid in Settings.";
|
||||||
|
case 429 -> provider + " rate limit exceeded (429). Try again later.";
|
||||||
|
default -> {
|
||||||
|
String body = (detail != null && !detail.isBlank()) ? ": " + detail : "";
|
||||||
|
yield provider + " API error: " + e.getStatusCode() + " " + e.getStatusText() + body;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
throw new IllegalStateException(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Pattern TITLE_PATTERN = Pattern.compile("\"title\"\\s*:\\s*\"([^\"]+)\"");
|
||||||
|
|
||||||
|
private void detectAndReportNewPages(UUID siteId, String json, Set<String> seenTitles) {
|
||||||
|
Matcher matcher = TITLE_PATTERN.matcher(json);
|
||||||
|
while (matcher.find()) {
|
||||||
|
String title = matcher.group(1);
|
||||||
|
if (seenTitles.add(title)) {
|
||||||
|
log.info("Detected page from stream: title={}, site={}", title, siteId);
|
||||||
|
progressService.sendProgress(siteId, "generating_section", "Generating page: " + title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void saveVersion(UUID userId, UUID siteId, String prompt,
|
private void saveVersion(UUID userId, UUID siteId, String prompt,
|
||||||
List<Reference> references, String generatedJson) {
|
List<Reference> references, String generatedJson) {
|
||||||
int nextVersion = getNextVersionNumber(siteId);
|
int nextVersion = getNextVersionNumber(siteId);
|
||||||
|
|||||||
149
backend/src/main/java/com/krrishg/service/ProgressService.java
Normal file
149
backend/src/main/java/com/krrishg/service/ProgressService.java
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package com.krrishg.service;
|
||||||
|
|
||||||
|
import com.krrishg.dto.SiteStructure;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ProgressService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ProgressService.class);
|
||||||
|
private static final long SSE_TIMEOUT = 300_000L;
|
||||||
|
|
||||||
|
private final Map<UUID, SseEmitter> emitters = new ConcurrentHashMap<>();
|
||||||
|
private final Map<UUID, List<Map<String, Object>>> pendingEvents = new ConcurrentHashMap<>();
|
||||||
|
private final Object bufferLock = new Object();
|
||||||
|
|
||||||
|
public SseEmitter register(UUID siteId) {
|
||||||
|
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT);
|
||||||
|
|
||||||
|
List<Map<String, Object>> buffer;
|
||||||
|
synchronized (bufferLock) {
|
||||||
|
emitters.put(siteId, emitter);
|
||||||
|
buffer = pendingEvents.remove(siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer != null) {
|
||||||
|
for (Map<String, Object> event : buffer) {
|
||||||
|
String type = (String) event.get("type");
|
||||||
|
Object data = event.get("data");
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name(type).data(data));
|
||||||
|
} catch (IOException e) {
|
||||||
|
emitters.remove(siteId);
|
||||||
|
log.debug("Failed to replay event for site {}: {}", siteId, e.getMessage());
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.onCompletion(() -> {
|
||||||
|
emitters.remove(siteId);
|
||||||
|
log.debug("SSE completed for site {}", siteId);
|
||||||
|
});
|
||||||
|
emitter.onTimeout(() -> {
|
||||||
|
emitters.remove(siteId);
|
||||||
|
log.debug("SSE timed out for site {}", siteId);
|
||||||
|
});
|
||||||
|
emitter.onError(e -> {
|
||||||
|
emitters.remove(siteId);
|
||||||
|
log.debug("SSE error for site {}: {}", siteId, e.getMessage());
|
||||||
|
});
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendProgress(UUID siteId, String stage, String message) {
|
||||||
|
Map<String, Object> data = Map.of("stage", stage, "message", message);
|
||||||
|
SseEmitter emitter = emitters.get(siteId);
|
||||||
|
if (emitter != null) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name("progress").data(data));
|
||||||
|
return;
|
||||||
|
} catch (IOException e) {
|
||||||
|
emitters.remove(siteId);
|
||||||
|
log.debug("Failed to send progress for site {}: {}", siteId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bufferEvent(siteId, "progress", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendComplete(UUID siteId, SiteStructure result) {
|
||||||
|
SseEmitter emitter = emitters.get(siteId);
|
||||||
|
if (emitter != null) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name("complete").data(result));
|
||||||
|
emitter.complete();
|
||||||
|
return;
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("Failed to send complete for site {}: {}", siteId, e.getMessage());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bufferEvent(siteId, "complete", result);
|
||||||
|
}
|
||||||
|
emitters.remove(siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendRefineComplete(UUID siteId, SiteStructure result) {
|
||||||
|
SseEmitter emitter = emitters.get(siteId);
|
||||||
|
if (emitter != null) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name("complete").data(result));
|
||||||
|
emitter.complete();
|
||||||
|
return;
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("Failed to send refine complete for site {}: {}", siteId, e.getMessage());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bufferEvent(siteId, "complete", result);
|
||||||
|
}
|
||||||
|
emitters.remove(siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendStreamingChunk(UUID siteId, String chunk) {
|
||||||
|
Map<String, String> data = Map.of("chunk", chunk);
|
||||||
|
SseEmitter emitter = emitters.get(siteId);
|
||||||
|
if (emitter != null) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name("streaming").data(data));
|
||||||
|
} catch (IOException e) {
|
||||||
|
emitters.remove(siteId);
|
||||||
|
log.debug("Failed to send streaming chunk for site {}: {}", siteId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendError(UUID siteId, String error) {
|
||||||
|
Map<String, String> data = Map.of("error", error);
|
||||||
|
SseEmitter emitter = emitters.get(siteId);
|
||||||
|
if (emitter != null) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name("error").data(data));
|
||||||
|
emitter.complete();
|
||||||
|
return;
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("Failed to send error for site {}: {}", siteId, e.getMessage());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bufferEvent(siteId, "error", data);
|
||||||
|
}
|
||||||
|
emitters.remove(siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bufferEvent(UUID siteId, String type, Object data) {
|
||||||
|
synchronized (bufferLock) {
|
||||||
|
if (!emitters.containsKey(siteId)) {
|
||||||
|
pendingEvents.computeIfAbsent(siteId, k -> new ArrayList<>())
|
||||||
|
.add(Map.of("type", type, "data", data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,24 @@
|
|||||||
package com.krrishg.service.llm;
|
package com.krrishg.service.llm;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.krrishg.dto.LLMResult;
|
import com.krrishg.dto.LLMResult;
|
||||||
import com.krrishg.dto.LLMOptions;
|
import com.krrishg.dto.LLMOptions;
|
||||||
import com.krrishg.dto.Reference;
|
import com.krrishg.dto.Reference;
|
||||||
import org.springframework.http.*;
|
import org.springframework.http.*;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public class GeminiProvider implements LLMProvider {
|
public class GeminiProvider implements LLMProvider {
|
||||||
|
|
||||||
@@ -16,64 +26,29 @@ public class GeminiProvider implements LLMProvider {
|
|||||||
private final String apiKey;
|
private final String apiKey;
|
||||||
private final String model;
|
private final String model;
|
||||||
private final String baseUrl;
|
private final String baseUrl;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public GeminiProvider(String apiKey, String model, String baseUrl, RestTemplate restTemplate) {
|
public GeminiProvider(String apiKey, String model, String baseUrl, RestTemplate restTemplate) {
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
this.model = model;
|
this.model = model;
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
this.restTemplate = restTemplate;
|
this.restTemplate = restTemplate;
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public LLMResult generate(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options) {
|
public LLMResult generate(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options) {
|
||||||
long start = System.currentTimeMillis();
|
long start = System.currentTimeMillis();
|
||||||
|
|
||||||
List<Map<String, Object>> parts = new ArrayList<>();
|
Map<String, Object> requestBody = buildRequestBody(systemPrompt, userPrompt, references, options);
|
||||||
parts.add(Map.of("text", userPrompt));
|
|
||||||
|
|
||||||
if (references != null) {
|
|
||||||
for (Reference ref : references) {
|
|
||||||
if (ref.getType() == Reference.ReferenceType.IMAGE && ref.getUrl() != null) {
|
|
||||||
parts.add(Map.of(
|
|
||||||
"inlineData", Map.of(
|
|
||||||
"mimeType", "image/jpeg",
|
|
||||||
"data", ref.getUrl()
|
|
||||||
)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> systemInstruction = Map.of(
|
|
||||||
"parts", List.of(Map.of("text", systemPrompt))
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, Object> contents = Map.of(
|
|
||||||
"parts", parts
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, Object> generationConfig = Map.of(
|
|
||||||
"temperature", options.getTemperature(),
|
|
||||||
"maxOutputTokens", options.getMaxTokens()
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, Object> requestBody = Map.of(
|
|
||||||
"system_instruction", systemInstruction,
|
|
||||||
"contents", List.of(contents),
|
|
||||||
"generationConfig", generationConfig
|
|
||||||
);
|
|
||||||
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
|
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
|
||||||
|
|
||||||
String url = baseUrl + "/" + model + ":generateContent?key=" + apiKey;
|
ResponseEntity<Map> response = restTemplate.exchange(buildUrl(), HttpMethod.POST, request, Map.class);
|
||||||
|
|
||||||
ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.POST, request, Map.class);
|
|
||||||
|
|
||||||
long latencyMs = System.currentTimeMillis() - start;
|
long latencyMs = System.currentTimeMillis() - start;
|
||||||
|
|
||||||
Map<String, Object> responseBody = response.getBody();
|
Map<String, Object> responseBody = response.getBody();
|
||||||
String content = extractContent(responseBody);
|
String content = extractContent(responseBody);
|
||||||
|
|
||||||
@@ -94,6 +69,133 @@ public class GeminiProvider implements LLMProvider {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LLMResult generate(String systemPrompt, String userPrompt,
|
||||||
|
List<Reference> references, LLMOptions options, Consumer<String> onChunk) {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
HttpURLConnection conn = openConnection(buildStreamingUrl(), options);
|
||||||
|
writeRequestBody(conn, buildRequestBody(systemPrompt, userPrompt, references, options));
|
||||||
|
verifyResponse(conn);
|
||||||
|
return readStream(conn, onChunk, start);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Gemini streaming failed: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildRequestBody(String systemPrompt, String userPrompt,
|
||||||
|
List<Reference> references, LLMOptions options) {
|
||||||
|
List<Map<String, Object>> parts = new ArrayList<>();
|
||||||
|
parts.add(Map.of("text", userPrompt));
|
||||||
|
if (references != null) {
|
||||||
|
for (Reference ref : references) {
|
||||||
|
if (ref.getType() == Reference.ReferenceType.IMAGE && ref.getUrl() != null) {
|
||||||
|
parts.add(Map.of("inlineData", Map.of("mimeType", "image/jpeg", "data", ref.getUrl())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Map.of(
|
||||||
|
"system_instruction", Map.of("parts", List.of(Map.of("text", systemPrompt))),
|
||||||
|
"contents", List.of(Map.of("parts", parts)),
|
||||||
|
"generationConfig", Map.of("temperature", options.getTemperature(), "maxOutputTokens", options.getMaxTokens())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildUrl() {
|
||||||
|
return baseUrl + "/" + model + ":generateContent?key=" + apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildStreamingUrl() {
|
||||||
|
return baseUrl + "/" + model + ":streamGenerateContent?alt=sse&key=" + apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpURLConnection openConnection(String url, LLMOptions options) throws IOException {
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
|
||||||
|
conn.setRequestMethod("POST");
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json");
|
||||||
|
conn.setRequestProperty("Accept", "text/event-stream");
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setConnectTimeout(30000);
|
||||||
|
conn.setReadTimeout(options.getTimeoutSeconds() * 1000);
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeRequestBody(HttpURLConnection conn, Map<String, Object> requestBody) throws IOException {
|
||||||
|
byte[] bodyBytes = objectMapper.writeValueAsBytes(requestBody);
|
||||||
|
try (OutputStream os = conn.getOutputStream()) {
|
||||||
|
os.write(bodyBytes);
|
||||||
|
os.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifyResponse(HttpURLConnection conn) throws IOException {
|
||||||
|
int status = conn.getResponseCode();
|
||||||
|
if (status != 200) {
|
||||||
|
String errorBody = new String(conn.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
throw new RuntimeException("Gemini API error " + status + ": " + errorBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class StreamState {
|
||||||
|
StringBuilder content = new StringBuilder();
|
||||||
|
int promptTokens;
|
||||||
|
int completionTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LLMResult readStream(HttpURLConnection conn, Consumer<String> onChunk, long start) throws IOException {
|
||||||
|
StreamState state = new StreamState();
|
||||||
|
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
processSseLine(line, state, onChunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return LLMResult.builder()
|
||||||
|
.content(state.content.toString())
|
||||||
|
.promptTokens(state.promptTokens)
|
||||||
|
.completionTokens(state.completionTokens)
|
||||||
|
.model(model)
|
||||||
|
.latencyMs(System.currentTimeMillis() - start)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processSseLine(String line, StreamState state, Consumer<String> onChunk) {
|
||||||
|
if (!line.startsWith("data: ")) return;
|
||||||
|
String data = line.substring(6).trim();
|
||||||
|
if (data.isEmpty()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(data);
|
||||||
|
|
||||||
|
if (node.has("usageMetadata")) {
|
||||||
|
JsonNode usage = node.get("usageMetadata");
|
||||||
|
state.promptTokens = usage.has("promptTokenCount") ? usage.get("promptTokenCount").asInt() : 0;
|
||||||
|
state.completionTokens = usage.has("candidatesTokenCount") ? usage.get("candidatesTokenCount").asInt() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode candidates = node.get("candidates");
|
||||||
|
if (candidates != null && candidates.isArray() && candidates.size() > 0) {
|
||||||
|
JsonNode content = candidates.get(0).get("content");
|
||||||
|
if (content != null && content.has("parts") && content.get("parts").isArray()) {
|
||||||
|
JsonNode partsNode = content.get("parts").get(0);
|
||||||
|
if (partsNode != null) {
|
||||||
|
JsonNode textNode = partsNode.get("text");
|
||||||
|
if (textNode != null && !textNode.isNull()) {
|
||||||
|
String text = textNode.asText();
|
||||||
|
if (!text.isEmpty()) {
|
||||||
|
state.content.append(text);
|
||||||
|
onChunk.accept(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private String extractContent(Map<String, Object> responseBody) {
|
private String extractContent(Map<String, Object> responseBody) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ import com.krrishg.dto.LLMOptions;
|
|||||||
import com.krrishg.dto.Reference;
|
import com.krrishg.dto.Reference;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public interface LLMProvider {
|
public interface LLMProvider {
|
||||||
|
|
||||||
LLMResult generate(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options);
|
LLMResult generate(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options);
|
||||||
|
|
||||||
|
default LLMResult generate(String systemPrompt, String userPrompt,
|
||||||
|
List<Reference> references, LLMOptions options, Consumer<String> onChunk) {
|
||||||
|
return generate(systemPrompt, userPrompt, references, options);
|
||||||
|
}
|
||||||
|
|
||||||
String getName();
|
String getName();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
package com.krrishg.service.llm;
|
package com.krrishg.service.llm;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.krrishg.dto.LLMResult;
|
import com.krrishg.dto.LLMResult;
|
||||||
import com.krrishg.dto.LLMOptions;
|
import com.krrishg.dto.LLMOptions;
|
||||||
import com.krrishg.dto.Reference;
|
import com.krrishg.dto.Reference;
|
||||||
import org.springframework.http.*;
|
import org.springframework.http.*;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public class OpenAIProvider implements LLMProvider {
|
public class OpenAIProvider implements LLMProvider {
|
||||||
|
|
||||||
@@ -15,27 +25,21 @@ public class OpenAIProvider implements LLMProvider {
|
|||||||
private final String apiKey;
|
private final String apiKey;
|
||||||
private final String model;
|
private final String model;
|
||||||
private final String url;
|
private final String url;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public OpenAIProvider(String apiKey, String model, String url, RestTemplate restTemplate) {
|
public OpenAIProvider(String apiKey, String model, String url, RestTemplate restTemplate) {
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
this.model = model;
|
this.model = model;
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.restTemplate = restTemplate;
|
this.restTemplate = restTemplate;
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public LLMResult generate(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options) {
|
public LLMResult generate(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options) {
|
||||||
long start = System.currentTimeMillis();
|
long start = System.currentTimeMillis();
|
||||||
|
|
||||||
Map<String, Object> requestBody = Map.of(
|
Map<String, Object> requestBody = buildRequestBody(systemPrompt, userPrompt, options);
|
||||||
"model", model,
|
|
||||||
"messages", List.of(
|
|
||||||
Map.of("role", "system", "content", systemPrompt),
|
|
||||||
Map.of("role", "user", "content", userPrompt)
|
|
||||||
),
|
|
||||||
"temperature", options.getTemperature(),
|
|
||||||
"max_tokens", options.getMaxTokens()
|
|
||||||
);
|
|
||||||
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
@@ -67,6 +71,123 @@ public class OpenAIProvider implements LLMProvider {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LLMResult generate(String systemPrompt, String userPrompt,
|
||||||
|
List<Reference> references, LLMOptions options, Consumer<String> onChunk) {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
HttpURLConnection conn = openStreamingConnection(options);
|
||||||
|
writeRequestBody(conn, buildStreamingRequest(systemPrompt, userPrompt, options));
|
||||||
|
verifyResponse(conn);
|
||||||
|
String fullContent = readStream(conn, onChunk);
|
||||||
|
return buildResult(fullContent, start);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("OpenAI streaming failed: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildRequestBody(String systemPrompt, String userPrompt, LLMOptions options) {
|
||||||
|
return Map.of(
|
||||||
|
"model", model,
|
||||||
|
"messages", List.of(
|
||||||
|
Map.of("role", "system", "content", systemPrompt),
|
||||||
|
Map.of("role", "user", "content", userPrompt)
|
||||||
|
),
|
||||||
|
"temperature", options.getTemperature(),
|
||||||
|
"max_tokens", options.getMaxTokens()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildStreamingRequest(String systemPrompt, String userPrompt, LLMOptions options) {
|
||||||
|
return Map.of(
|
||||||
|
"model", model,
|
||||||
|
"messages", List.of(
|
||||||
|
Map.of("role", "system", "content", systemPrompt),
|
||||||
|
Map.of("role", "user", "content", userPrompt)
|
||||||
|
),
|
||||||
|
"temperature", options.getTemperature(),
|
||||||
|
"max_tokens", options.getMaxTokens(),
|
||||||
|
"stream", true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpURLConnection openStreamingConnection(LLMOptions options) throws IOException {
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
|
||||||
|
conn.setRequestMethod("POST");
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json");
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer " + apiKey);
|
||||||
|
conn.setRequestProperty("Accept", "text/event-stream");
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setConnectTimeout(30000);
|
||||||
|
conn.setReadTimeout(options.getTimeoutSeconds() * 1000);
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeRequestBody(HttpURLConnection conn, Map<String, Object> requestBody) throws IOException {
|
||||||
|
byte[] bodyBytes = objectMapper.writeValueAsBytes(requestBody);
|
||||||
|
try (OutputStream os = conn.getOutputStream()) {
|
||||||
|
os.write(bodyBytes);
|
||||||
|
os.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifyResponse(HttpURLConnection conn) throws IOException {
|
||||||
|
int status = conn.getResponseCode();
|
||||||
|
if (status != 200) {
|
||||||
|
String errorBody = new String(conn.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
throw new RuntimeException("OpenAI API error " + status + ": " + errorBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readStream(HttpURLConnection conn, Consumer<String> onChunk) throws IOException {
|
||||||
|
StringBuilder fullContent = new StringBuilder();
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
if (!processSseLine(line, fullContent, onChunk)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fullContent.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean processSseLine(String line, StringBuilder fullContent, Consumer<String> onChunk) {
|
||||||
|
if (!line.startsWith("data: ")) return true;
|
||||||
|
String data = line.substring(6).trim();
|
||||||
|
if ("[DONE]".equals(data)) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(data);
|
||||||
|
JsonNode choices = node.get("choices");
|
||||||
|
if (choices != null && choices.isArray() && choices.size() > 0) {
|
||||||
|
JsonNode delta = choices.get(0).get("delta");
|
||||||
|
if (delta != null) {
|
||||||
|
JsonNode content = delta.get("content");
|
||||||
|
if (content != null && !content.isNull()) {
|
||||||
|
String text = content.asText();
|
||||||
|
if (!text.isEmpty()) {
|
||||||
|
fullContent.append(text);
|
||||||
|
onChunk.accept(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LLMResult buildResult(String fullContent, long start) {
|
||||||
|
return LLMResult.builder()
|
||||||
|
.content(fullContent)
|
||||||
|
.promptTokens(0)
|
||||||
|
.completionTokens(0)
|
||||||
|
.model(model)
|
||||||
|
.latencyMs(System.currentTimeMillis() - start)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private String extractContent(Map<String, Object> responseBody) {
|
private String extractContent(Map<String, Object> responseBody) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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.JwtConfig;
|
||||||
import com.krrishg.config.RateLimitConfig;
|
import com.krrishg.config.RateLimitConfig;
|
||||||
import com.krrishg.config.SuggestRateLimitConfig;
|
import com.krrishg.config.SuggestRateLimitConfig;
|
||||||
import com.krrishg.config.UserPrincipal;
|
import com.krrishg.config.UserPrincipal;
|
||||||
@@ -10,6 +11,7 @@ import com.krrishg.dto.SiteStructure;
|
|||||||
import com.krrishg.dto.SuggestSectionsResponse;
|
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.service.ProgressService;
|
||||||
import com.krrishg.support.TestSecurityConfig;
|
import com.krrishg.support.TestSecurityConfig;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -44,6 +46,12 @@ class LLMControllerTest {
|
|||||||
@MockBean
|
@MockBean
|
||||||
private LLMService llmService;
|
private LLMService llmService;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private ProgressService progressService;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private JwtConfig jwtConfig;
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private RateLimitConfig rateLimitConfig;
|
private RateLimitConfig rateLimitConfig;
|
||||||
|
|
||||||
@@ -70,22 +78,13 @@ class LLMControllerTest {
|
|||||||
class Generate {
|
class Generate {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void returns200WithSiteStructure() throws Exception {
|
void returns202Accepted() throws Exception {
|
||||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||||
|
|
||||||
SiteStructure structure = SiteStructure.builder()
|
String siteId = UUID.randomUUID().toString();
|
||||||
.pages(List.of(
|
|
||||||
SiteStructure.PageStructure.builder()
|
|
||||||
.title("Home").slug("home")
|
|
||||||
.rawHtml("<h1>Home</h1>")
|
|
||||||
.build()
|
|
||||||
))
|
|
||||||
.build();
|
|
||||||
when(llmService.generateSite(any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(structure);
|
|
||||||
|
|
||||||
Map<String, Object> request = Map.of(
|
Map<String, Object> request = Map.of(
|
||||||
"prompt", "Build a site",
|
"prompt", "Build a site",
|
||||||
"siteId", UUID.randomUUID().toString(),
|
"siteId", siteId,
|
||||||
"provider", "gemini"
|
"provider", "gemini"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -93,27 +92,20 @@ class LLMControllerTest {
|
|||||||
.with(authentication(auth))
|
.with(authentication(auth))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isAccepted())
|
||||||
.andExpect(jsonPath("$.pages[0].title").value("Home"));
|
.andExpect(jsonPath("$.siteId").value(siteId));
|
||||||
|
|
||||||
|
verify(llmService).generateSiteAsync(any(), any(), anyString(), any(), anyString(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void returns200WithReferences() throws Exception {
|
void returns202WithReferences() throws Exception {
|
||||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||||
|
|
||||||
SiteStructure structure = SiteStructure.builder()
|
String siteId = UUID.randomUUID().toString();
|
||||||
.pages(List.of(
|
|
||||||
SiteStructure.PageStructure.builder()
|
|
||||||
.title("Home").slug("home")
|
|
||||||
.rawHtml("<h1>Home</h1>")
|
|
||||||
.build()
|
|
||||||
))
|
|
||||||
.build();
|
|
||||||
when(llmService.generateSite(any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(structure);
|
|
||||||
|
|
||||||
Map<String, Object> request = Map.of(
|
Map<String, Object> request = Map.of(
|
||||||
"prompt", "Build a site",
|
"prompt", "Build a site",
|
||||||
"siteId", UUID.randomUUID().toString(),
|
"siteId", siteId,
|
||||||
"provider", "gemini",
|
"provider", "gemini",
|
||||||
"references", List.of(
|
"references", List.of(
|
||||||
Map.of("type", "IMAGE", "url", "data:img", "altText", "Photo")
|
Map.of("type", "IMAGE", "url", "data:img", "altText", "Photo")
|
||||||
@@ -124,26 +116,20 @@ class LLMControllerTest {
|
|||||||
.with(authentication(auth))
|
.with(authentication(auth))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isAccepted())
|
||||||
|
.andExpect(jsonPath("$.siteId").value(siteId));
|
||||||
|
|
||||||
|
verify(llmService).generateSiteAsync(any(), any(), anyString(), any(), anyString(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void returns200WithPerRequestApiKey() throws Exception {
|
void returns202WithPerRequestApiKey() throws Exception {
|
||||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||||
|
|
||||||
SiteStructure structure = SiteStructure.builder()
|
String siteId = UUID.randomUUID().toString();
|
||||||
.pages(List.of(
|
|
||||||
SiteStructure.PageStructure.builder()
|
|
||||||
.title("Home").slug("home")
|
|
||||||
.rawHtml("<h1>Home</h1>")
|
|
||||||
.build()
|
|
||||||
))
|
|
||||||
.build();
|
|
||||||
when(llmService.generateSite(any(), any(), anyString(), any(), eq("gemini"), eq("custom-key"), any(), any())).thenReturn(structure);
|
|
||||||
|
|
||||||
Map<String, Object> request = Map.of(
|
Map<String, Object> request = Map.of(
|
||||||
"prompt", "Build a site",
|
"prompt", "Build a site",
|
||||||
"siteId", UUID.randomUUID().toString(),
|
"siteId", siteId,
|
||||||
"provider", "gemini",
|
"provider", "gemini",
|
||||||
"apiKey", "custom-key"
|
"apiKey", "custom-key"
|
||||||
);
|
);
|
||||||
@@ -152,7 +138,10 @@ class LLMControllerTest {
|
|||||||
.with(authentication(auth))
|
.with(authentication(auth))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isAccepted())
|
||||||
|
.andExpect(jsonPath("$.siteId").value(siteId));
|
||||||
|
|
||||||
|
verify(llmService).generateSiteAsync(any(), any(), anyString(), any(), eq("gemini"), eq("custom-key"), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -206,14 +195,13 @@ class LLMControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void returns400WhenNoKeyConfigured() throws Exception {
|
void returns202WhenNoKeyConfigured() throws Exception {
|
||||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||||
when(llmService.generateSite(any(), any(), anyString(), any(), anyString(), any(), any(), any()))
|
|
||||||
.thenThrow(new IllegalStateException("No API key configured for gemini"));
|
|
||||||
|
|
||||||
|
String siteId = UUID.randomUUID().toString();
|
||||||
Map<String, Object> request = Map.of(
|
Map<String, Object> request = Map.of(
|
||||||
"prompt", "Build a site",
|
"prompt", "Build a site",
|
||||||
"siteId", UUID.randomUUID().toString(),
|
"siteId", siteId,
|
||||||
"provider", "gemini"
|
"provider", "gemini"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -221,8 +209,8 @@ class LLMControllerTest {
|
|||||||
.with(authentication(auth))
|
.with(authentication(auth))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isAccepted())
|
||||||
.andExpect(jsonPath("$.error").value("No API key configured for gemini"));
|
.andExpect(jsonPath("$.siteId").value(siteId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -246,9 +234,10 @@ class LLMControllerTest {
|
|||||||
class Refine {
|
class Refine {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void returns200WithRefinedStructure() throws Exception {
|
void returns202Accepted() throws Exception {
|
||||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||||
|
|
||||||
|
String siteId = UUID.randomUUID().toString();
|
||||||
SiteStructure current = SiteStructure.builder()
|
SiteStructure current = SiteStructure.builder()
|
||||||
.pages(List.of(
|
.pages(List.of(
|
||||||
SiteStructure.PageStructure.builder()
|
SiteStructure.PageStructure.builder()
|
||||||
@@ -257,19 +246,9 @@ class LLMControllerTest {
|
|||||||
.build()
|
.build()
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
SiteStructure refined = SiteStructure.builder()
|
|
||||||
.pages(List.of(
|
|
||||||
SiteStructure.PageStructure.builder()
|
|
||||||
.title("Refined").slug("refined")
|
|
||||||
.rawHtml("<h1>Refined</h1>")
|
|
||||||
.build()
|
|
||||||
))
|
|
||||||
.build();
|
|
||||||
when(llmService.refineSite(any(), any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(refined);
|
|
||||||
|
|
||||||
Map<String, Object> request = Map.of(
|
Map<String, Object> request = Map.of(
|
||||||
"prompt", "Make it better",
|
"prompt", "Make it better",
|
||||||
"siteId", UUID.randomUUID().toString(),
|
"siteId", siteId,
|
||||||
"provider", "gemini",
|
"provider", "gemini",
|
||||||
"currentStructure", current
|
"currentStructure", current
|
||||||
);
|
);
|
||||||
@@ -278,8 +257,10 @@ class LLMControllerTest {
|
|||||||
.with(authentication(auth))
|
.with(authentication(auth))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isAccepted())
|
||||||
.andExpect(jsonPath("$.pages[0].title").value("Refined"));
|
.andExpect(jsonPath("$.siteId").value(siteId));
|
||||||
|
|
||||||
|
verify(llmService).refineSiteAsync(any(), any(), any(), anyString(), any(), anyString(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -239,5 +239,20 @@ class LLMResponseParserTest {
|
|||||||
SiteStructure structure = parser.parse(json);
|
SiteStructure structure = parser.parse(json);
|
||||||
assertNull(structure.getPages().get(0).getRawHtml());
|
assertNull(structure.getPages().get(0).getRawHtml());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesRawHtmlWithInvalidBackslashEscape() {
|
||||||
|
String rawHtml = "before\\>after";
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
{"title": "Home", "slug": "home", "rawHtml": "%s"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".formatted(rawHtml);
|
||||||
|
|
||||||
|
SiteStructure structure = parser.parse(json);
|
||||||
|
assertEquals("before>after", structure.getPages().get(0).getRawHtml());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ class LLMServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private ResourceLoader resourceLoader;
|
private ResourceLoader resourceLoader;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ProgressService progressService;
|
||||||
|
|
||||||
private FakeLLMProvider geminiProvider;
|
private FakeLLMProvider geminiProvider;
|
||||||
private FakeProviderFactory providerFactory;
|
private FakeProviderFactory providerFactory;
|
||||||
private LLMResponseParser responseParser;
|
private LLMResponseParser responseParser;
|
||||||
@@ -80,7 +83,7 @@ class LLMServiceTest {
|
|||||||
new ByteArrayInputStream("You are a site generator.".getBytes(StandardCharsets.UTF_8)));
|
new ByteArrayInputStream("You are a site generator.".getBytes(StandardCharsets.UTF_8)));
|
||||||
when(resourceLoader.getResource(anyString())).thenReturn(mockResource);
|
when(resourceLoader.getResource(anyString())).thenReturn(mockResource);
|
||||||
|
|
||||||
llmService = new LLMService(providerFactory, responseParser, referenceService,
|
llmService = new LLMService(providerFactory, responseParser, referenceService, progressService,
|
||||||
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",
|
||||||
|
|||||||
@@ -1,24 +1,40 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
import { LLMGenerateRequest, LLMRefineRequest, SiteStructure, GenerationVersion, SuggestSectionsRequest, SuggestSectionsResponse } 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 {
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
generate(request: LLMGenerateRequest): Observable<SiteStructure> {
|
private withToken(url: string): string {
|
||||||
return this.http.post<SiteStructure>('/api/llm/generate', request);
|
const token = this.authService.getAccessToken();
|
||||||
|
return token ? `${url}?token=${encodeURIComponent(token)}` : url;
|
||||||
}
|
}
|
||||||
|
|
||||||
refine(request: LLMRefineRequest): Observable<SiteStructure> {
|
generate(request: LLMGenerateRequest): Observable<{siteId: string}> {
|
||||||
return this.http.post<SiteStructure>('/api/llm/refine', request);
|
return this.http.post<{siteId: string}>('/api/llm/generate', request);
|
||||||
|
}
|
||||||
|
|
||||||
|
refine(request: LLMRefineRequest): Observable<{siteId: string}> {
|
||||||
|
return this.http.post<{siteId: string}>('/api/llm/refine', request);
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestSections(request: SuggestSectionsRequest): Observable<SuggestSectionsResponse> {
|
suggestSections(request: SuggestSectionsRequest): Observable<SuggestSectionsResponse> {
|
||||||
return this.http.post<SuggestSectionsResponse>('/api/llm/suggest-sections', request);
|
return this.http.post<SuggestSectionsResponse>('/api/llm/suggest-sections', request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getGenerationEvents(siteId: string): EventSource {
|
||||||
|
return new EventSource(this.withToken(`/api/llm/generate/${siteId}/events`));
|
||||||
|
}
|
||||||
|
|
||||||
|
getRefineEvents(siteId: string): EventSource {
|
||||||
|
return new EventSource(this.withToken(`/api/llm/refine/${siteId}/events`));
|
||||||
|
}
|
||||||
|
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, NgZone, inject } from '@angular/core';
|
||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
|
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
|
||||||
import { LlmApiService } from '../core/services/llm-api.service';
|
import { LlmApiService } from '../core/services/llm-api.service';
|
||||||
@@ -16,8 +16,10 @@ export class GenerationService {
|
|||||||
private api = inject(LlmApiService);
|
private api = inject(LlmApiService);
|
||||||
private session = inject(LlmSessionService);
|
private session = inject(LlmSessionService);
|
||||||
private snackBar = inject(MatSnackBar);
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private zone = inject(NgZone);
|
||||||
|
|
||||||
private generationSubscription: Subscription | null = null;
|
private generationSubscription: Subscription | null = null;
|
||||||
|
private eventSource: EventSource | null = null;
|
||||||
private stateSubject = new BehaviorSubject<GenerationState>({
|
private stateSubject = new BehaviorSubject<GenerationState>({
|
||||||
status: 'idle', result: null, error: null,
|
status: 'idle', result: null, error: null,
|
||||||
});
|
});
|
||||||
@@ -31,17 +33,17 @@ export class GenerationService {
|
|||||||
this.stateSubject.next({ status: 'generating', result: null, error: null });
|
this.stateSubject.next({ status: 'generating', result: null, error: null });
|
||||||
this.session.setGenerating(true);
|
this.session.setGenerating(true);
|
||||||
this.session.clearError();
|
this.session.clearError();
|
||||||
|
this.session.clearProgressMessages();
|
||||||
|
this.session.clearStreamingContent();
|
||||||
|
|
||||||
|
this.openGenerationEventSource(siteId);
|
||||||
|
|
||||||
this.generationSubscription = this.api.generate(request).subscribe({
|
this.generationSubscription = this.api.generate(request).subscribe({
|
||||||
next: (data) => {
|
next: () => {
|
||||||
this.stateSubject.next({ status: 'completed', result: data, error: null });
|
|
||||||
this.session.setResult(data);
|
|
||||||
this.session.setCurrentVersionId(null);
|
|
||||||
this.refreshVersionsSubject.next(siteId);
|
|
||||||
this.snackBar.open('Site generated successfully!', 'Close', { duration: 5000 });
|
|
||||||
this.generationSubscription = null;
|
this.generationSubscription = null;
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
|
this.closeEventSource();
|
||||||
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
|
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
|
||||||
this.stateSubject.next({ status: 'error', result: null, error: msg });
|
this.stateSubject.next({ status: 'error', result: null, error: msg });
|
||||||
this.session.setError(msg);
|
this.session.setError(msg);
|
||||||
@@ -56,17 +58,17 @@ export class GenerationService {
|
|||||||
this.stateSubject.next({ status: 'generating', result: null, error: null });
|
this.stateSubject.next({ status: 'generating', result: null, error: null });
|
||||||
this.session.setGenerating(true);
|
this.session.setGenerating(true);
|
||||||
this.session.clearError();
|
this.session.clearError();
|
||||||
|
this.session.clearProgressMessages();
|
||||||
|
this.session.clearStreamingContent();
|
||||||
|
|
||||||
|
this.openRefineEventSource(siteId);
|
||||||
|
|
||||||
this.generationSubscription = this.api.refine(request).subscribe({
|
this.generationSubscription = this.api.refine(request).subscribe({
|
||||||
next: (data) => {
|
next: () => {
|
||||||
this.stateSubject.next({ status: 'completed', result: data, error: null });
|
|
||||||
this.session.setResult(data);
|
|
||||||
this.session.setCurrentVersionId(null);
|
|
||||||
this.refreshVersionsSubject.next(siteId);
|
|
||||||
this.snackBar.open('Site refined successfully!', 'Close', { duration: 5000 });
|
|
||||||
this.generationSubscription = null;
|
this.generationSubscription = null;
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
|
this.closeEventSource();
|
||||||
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
|
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
|
||||||
this.stateSubject.next({ status: 'error', result: null, error: msg });
|
this.stateSubject.next({ status: 'error', result: null, error: msg });
|
||||||
this.session.setError(msg);
|
this.session.setError(msg);
|
||||||
@@ -76,14 +78,123 @@ export class GenerationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private openGenerationEventSource(siteId: string): void {
|
||||||
|
this.closeEventSource();
|
||||||
|
this.eventSource = this.api.getGenerationEvents(siteId);
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('progress', (event: MessageEvent) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
this.zone.run(() => {
|
||||||
|
if (data.stage === 'generating_section') {
|
||||||
|
this.session.setCurrentSection(data.message);
|
||||||
|
} else {
|
||||||
|
this.session.addProgressMessage({ stage: data.stage, message: data.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('streaming', (event: MessageEvent) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.chunk) {
|
||||||
|
this.zone.run(() => this.session.appendStreamingContent(data.chunk));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('complete', (event: MessageEvent) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.stateSubject.next({ status: 'completed', result: data, error: null });
|
||||||
|
this.session.setResult(data);
|
||||||
|
this.session.setCurrentVersionId(null);
|
||||||
|
this.refreshVersionsSubject.next(siteId);
|
||||||
|
this.snackBar.open('Site generated successfully!', 'Close', { duration: 5000 });
|
||||||
|
});
|
||||||
|
this.closeEventSource();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('error', (event: MessageEvent) => {
|
||||||
|
let msg = 'Generation failed';
|
||||||
|
try {
|
||||||
|
const data = JSON.parse((event as any).data);
|
||||||
|
msg = data.error || msg;
|
||||||
|
} catch {}
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.stateSubject.next({ status: 'error', result: null, error: msg });
|
||||||
|
this.session.setError(msg);
|
||||||
|
this.snackBar.open('Generation failed: ' + msg, 'Dismiss', { duration: 8000 });
|
||||||
|
});
|
||||||
|
this.closeEventSource();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private openRefineEventSource(siteId: string): void {
|
||||||
|
this.closeEventSource();
|
||||||
|
this.eventSource = this.api.getRefineEvents(siteId);
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('progress', (event: MessageEvent) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
this.zone.run(() => {
|
||||||
|
if (data.stage === 'generating_section') {
|
||||||
|
this.session.setCurrentSection(data.message);
|
||||||
|
} else {
|
||||||
|
this.session.addProgressMessage({ stage: data.stage, message: data.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('streaming', (event: MessageEvent) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.chunk) {
|
||||||
|
this.zone.run(() => this.session.appendStreamingContent(data.chunk));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('complete', (event: MessageEvent) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.stateSubject.next({ status: 'completed', result: data, error: null });
|
||||||
|
this.session.setResult(data);
|
||||||
|
this.session.setCurrentVersionId(null);
|
||||||
|
this.refreshVersionsSubject.next(siteId);
|
||||||
|
this.snackBar.open('Site refined successfully!', 'Close', { duration: 5000 });
|
||||||
|
});
|
||||||
|
this.closeEventSource();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('error', (event: MessageEvent) => {
|
||||||
|
let msg = 'Refinement failed';
|
||||||
|
try {
|
||||||
|
const data = JSON.parse((event as any).data);
|
||||||
|
msg = data.error || msg;
|
||||||
|
} catch {}
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.stateSubject.next({ status: 'error', result: null, error: msg });
|
||||||
|
this.session.setError(msg);
|
||||||
|
this.snackBar.open('Refinement failed: ' + msg, 'Dismiss', { duration: 8000 });
|
||||||
|
});
|
||||||
|
this.closeEventSource();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeEventSource(): void {
|
||||||
|
if (this.eventSource) {
|
||||||
|
this.eventSource.close();
|
||||||
|
this.eventSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
if (this.generationSubscription) {
|
if (this.generationSubscription) {
|
||||||
this.generationSubscription.unsubscribe();
|
this.generationSubscription.unsubscribe();
|
||||||
this.generationSubscription = null;
|
this.generationSubscription = null;
|
||||||
}
|
}
|
||||||
|
this.closeEventSource();
|
||||||
this.stateSubject.next({ status: 'idle', result: null, error: null });
|
this.stateSubject.next({ status: 'idle', result: null, error: null });
|
||||||
this.session.setGenerating(false);
|
this.session.setGenerating(false);
|
||||||
this.session.clearError();
|
this.session.clearError();
|
||||||
|
this.session.clearProgressMessages();
|
||||||
|
this.session.clearStreamingContent();
|
||||||
|
this.session.setCurrentSection('');
|
||||||
}
|
}
|
||||||
|
|
||||||
resetState(): void {
|
resetState(): void {
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { Injectable } from '@angular/core';
|
|||||||
import { BehaviorSubject, Observable } from 'rxjs';
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
import { SiteStructure, GenerationVersion } from '../core/models/llm';
|
import { SiteStructure, GenerationVersion } from '../core/models/llm';
|
||||||
|
|
||||||
|
export interface ProgressMessage {
|
||||||
|
stage: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PromptEntry {
|
export interface PromptEntry {
|
||||||
originalPrompt: string;
|
originalPrompt: string;
|
||||||
refinedPrompt: string | null;
|
refinedPrompt: string | null;
|
||||||
@@ -19,6 +24,9 @@ export class LlmSessionService {
|
|||||||
private versionsSubject = new BehaviorSubject<GenerationVersion[]>([]);
|
private versionsSubject = new BehaviorSubject<GenerationVersion[]>([]);
|
||||||
private currentVersionIdSubject = new BehaviorSubject<string | null>(null);
|
private currentVersionIdSubject = new BehaviorSubject<string | null>(null);
|
||||||
private loadingVersionsSubject = new BehaviorSubject<boolean>(false);
|
private loadingVersionsSubject = new BehaviorSubject<boolean>(false);
|
||||||
|
private progressMessagesSubject = new BehaviorSubject<ProgressMessage[]>([]);
|
||||||
|
private streamingContentSubject = new BehaviorSubject<string>('');
|
||||||
|
private currentSectionSubject = new BehaviorSubject<string>('');
|
||||||
|
|
||||||
currentPrompt$ = this.currentPromptSubject.asObservable();
|
currentPrompt$ = this.currentPromptSubject.asObservable();
|
||||||
currentResult$ = this.currentResultSubject.asObservable();
|
currentResult$ = this.currentResultSubject.asObservable();
|
||||||
@@ -28,6 +36,9 @@ export class LlmSessionService {
|
|||||||
versions$ = this.versionsSubject.asObservable();
|
versions$ = this.versionsSubject.asObservable();
|
||||||
currentVersionId$ = this.currentVersionIdSubject.asObservable();
|
currentVersionId$ = this.currentVersionIdSubject.asObservable();
|
||||||
loadingVersions$ = this.loadingVersionsSubject.asObservable();
|
loadingVersions$ = this.loadingVersionsSubject.asObservable();
|
||||||
|
progressMessages$ = this.progressMessagesSubject.asObservable();
|
||||||
|
streamingContent$ = this.streamingContentSubject.asObservable();
|
||||||
|
currentSection$ = this.currentSectionSubject.asObservable();
|
||||||
|
|
||||||
get currentPrompt(): string { return this.currentPromptSubject.value; }
|
get currentPrompt(): string { return this.currentPromptSubject.value; }
|
||||||
get currentResult(): SiteStructure | null { return this.currentResultSubject.value; }
|
get currentResult(): SiteStructure | null { return this.currentResultSubject.value; }
|
||||||
@@ -35,6 +46,9 @@ export class LlmSessionService {
|
|||||||
get error(): string | null { return this.errorSubject.value; }
|
get error(): string | null { return this.errorSubject.value; }
|
||||||
get versions(): GenerationVersion[] { return this.versionsSubject.value; }
|
get versions(): GenerationVersion[] { return this.versionsSubject.value; }
|
||||||
get currentVersionId(): string | null { return this.currentVersionIdSubject.value; }
|
get currentVersionId(): string | null { return this.currentVersionIdSubject.value; }
|
||||||
|
get progressMessages(): ProgressMessage[] { return this.progressMessagesSubject.value; }
|
||||||
|
get streamingContent(): string { return this.streamingContentSubject.value; }
|
||||||
|
get currentSection(): string { return this.currentSectionSubject.value; }
|
||||||
|
|
||||||
setPrompt(prompt: string): void {
|
setPrompt(prompt: string): void {
|
||||||
this.currentPromptSubject.next(prompt);
|
this.currentPromptSubject.next(prompt);
|
||||||
@@ -72,12 +86,37 @@ export class LlmSessionService {
|
|||||||
this.errorSubject.next(null);
|
this.errorSubject.next(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addProgressMessage(msg: ProgressMessage): void {
|
||||||
|
const msgs = this.progressMessagesSubject.value;
|
||||||
|
this.progressMessagesSubject.next([...msgs, msg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearProgressMessages(): void {
|
||||||
|
this.progressMessagesSubject.next([]);
|
||||||
|
this.currentSectionSubject.next('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentSection(section: string): void {
|
||||||
|
this.currentSectionSubject.next(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendStreamingContent(chunk: string): void {
|
||||||
|
this.streamingContentSubject.next(this.streamingContentSubject.value + chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStreamingContent(): void {
|
||||||
|
this.streamingContentSubject.next('');
|
||||||
|
}
|
||||||
|
|
||||||
clearHistory(): void {
|
clearHistory(): void {
|
||||||
this.historySubject.next([]);
|
this.historySubject.next([]);
|
||||||
this.currentResultSubject.next(null);
|
this.currentResultSubject.next(null);
|
||||||
this.currentPromptSubject.next('');
|
this.currentPromptSubject.next('');
|
||||||
this.currentVersionIdSubject.next(null);
|
this.currentVersionIdSubject.next(null);
|
||||||
this.errorSubject.next(null);
|
this.errorSubject.next(null);
|
||||||
|
this.progressMessagesSubject.next([]);
|
||||||
|
this.streamingContentSubject.next('');
|
||||||
|
this.currentSectionSubject.next('');
|
||||||
}
|
}
|
||||||
|
|
||||||
setVersions(versions: GenerationVersion[]): void {
|
setVersions(versions: GenerationVersion[]): void {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type Phase = 'idle' | 'suggesting' | 'configuring' | 'generating';
|
|||||||
imports: [
|
imports: [
|
||||||
NgIf, NgFor, AsyncPipe, KeyValuePipe, DatePipe, FormsModule, RouterModule,
|
NgIf, NgFor, AsyncPipe, KeyValuePipe, DatePipe, FormsModule, RouterModule,
|
||||||
MatToolbarModule, MatButtonModule, MatIconModule,
|
MatToolbarModule, MatButtonModule, MatIconModule,
|
||||||
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule, MatSelectModule,
|
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule, MatSelectModule,
|
||||||
MatSnackBarModule, MatCardModule,
|
MatSnackBarModule, MatCardModule,
|
||||||
MatDividerModule, MatExpansionModule, MatDialogModule,
|
MatDividerModule, MatExpansionModule, MatDialogModule,
|
||||||
SectionConfiguratorComponent,
|
SectionConfiguratorComponent,
|
||||||
@@ -105,7 +105,6 @@ type Phase = 'idle' | 'suggesting' | 'configuring' | 'generating';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Phase 3: Section Configurator -->
|
|
||||||
<div *ngIf="currentPhase === 'configuring'" class="space-y-4">
|
<div *ngIf="currentPhase === 'configuring'" class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -129,7 +128,6 @@ type Phase = 'idle' | 'suggesting' | 'configuring' | 'generating';
|
|||||||
></app-section-configurator>
|
></app-section-configurator>
|
||||||
</div>
|
</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 *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>
|
||||||
@@ -172,11 +170,17 @@ type Phase = 'idle' | 'suggesting' | 'configuring' | 'generating';
|
|||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
<div *ngIf="session.generating$ | async" class="flex flex-col items-center justify-center py-12 space-y-4">
|
<div *ngIf="session.generating$ | async" class="flex flex-col items-center py-12 space-y-4">
|
||||||
<mat-spinner diameter="48"></mat-spinner>
|
<mat-spinner diameter="48"></mat-spinner>
|
||||||
<div class="text-center">
|
<div class="space-y-2 w-80">
|
||||||
<p class="text-lg font-medium text-gray-700">Generating your site...</p>
|
<div *ngFor="let msg of session.progressMessages$ | async" class="flex items-center gap-2 text-sm">
|
||||||
<p class="text-sm text-gray-400">This usually takes 15-30 seconds</p>
|
<mat-icon class="text-green-500 text-lg shrink-0">check_circle</mat-icon>
|
||||||
|
<span class="text-gray-600">{{ msg.message }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="session.currentSection$ | async as section" class="flex items-center gap-2 text-sm pt-1">
|
||||||
|
<mat-icon class="text-blue-400 text-lg shrink-0">arrow_forward</mat-icon>
|
||||||
|
<span class="text-blue-600 font-medium">{{ section }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user