generate site using llm prompt

This commit is contained in:
Krrish Ghimire
2026-07-02 01:15:16 +05:45
parent 18fed113f9
commit 3ccbd2ebec
75 changed files with 2220 additions and 3173 deletions

View File

@@ -0,0 +1,40 @@
package com.indie.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 RateLimitConfig {
private final Map<UUID, Bucket> cache = new ConcurrentHashMap<>();
private final int maxRequestsPerHour;
public RateLimitConfig(
@Value("${indie.llm.rate-limit.max-requests-per-hour:10}") 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();
}
}

View File

@@ -1,115 +0,0 @@
package com.indie.controller;
import com.indie.config.UserPrincipal;
import com.indie.dto.ComponentResponse;
import com.indie.dto.PageWithComponentsResponse;
import com.indie.dto.SaveComponentsRequest;
import com.indie.model.Component;
import com.indie.model.Page;
import com.indie.model.Site;
import com.indie.repository.ComponentRepository;
import com.indie.repository.PageRepository;
import com.indie.repository.SiteRepository;
import com.indie.service.BuilderService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/builder/sites/{siteId}")
public class BuilderController {
private final SiteRepository siteRepository;
private final PageRepository pageRepository;
private final ComponentRepository componentRepository;
private final BuilderService builderService;
public BuilderController(SiteRepository siteRepository, PageRepository pageRepository,
ComponentRepository componentRepository, BuilderService builderService) {
this.siteRepository = siteRepository;
this.pageRepository = pageRepository;
this.componentRepository = componentRepository;
this.builderService = builderService;
}
@GetMapping("/pages")
public ResponseEntity<List<PageWithComponentsResponse>> listPagesWithComponents(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId) {
Site site = findSiteForUser(siteId, principal.id());
List<Page> pages = pageRepository.findBySiteIdOrderByOrderIndexAsc(site.getId());
List<PageWithComponentsResponse> response = pages.stream()
.map(page -> {
List<ComponentResponse> components = componentRepository
.findByPageIdOrderByOrderIndexAsc(page.getId())
.stream()
.map(ComponentResponse::from)
.toList();
return PageWithComponentsResponse.from(page, components);
})
.toList();
return ResponseEntity.ok(response);
}
@PutMapping("/pages/{pageId}/components")
public ResponseEntity<List<ComponentResponse>> saveComponents(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId,
@PathVariable UUID pageId,
@Valid @RequestBody SaveComponentsRequest request) {
Site site = findSiteForUser(siteId, principal.id());
Page page = findPageForSite(pageId, site.getId());
List<Component> components = builderService.saveComponents(page.getId(), request);
List<ComponentResponse> response = components.stream()
.map(ComponentResponse::from)
.toList();
return ResponseEntity.ok(response);
}
@GetMapping("/theme")
public ResponseEntity<Map<String, Object>> getTheme(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId) {
Site site = findSiteForUser(siteId, principal.id());
return ResponseEntity.ok(site.getThemeConfig());
}
@PutMapping("/theme")
public ResponseEntity<Map<String, Object>> updateTheme(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId,
@RequestBody Map<String, Object> themeConfig) {
Site site = findSiteForUser(siteId, principal.id());
site.setThemeConfig(themeConfig);
siteRepository.save(site);
return ResponseEntity.ok(site.getThemeConfig());
}
private Site findSiteForUser(UUID siteId, UUID userId) {
Site site = siteRepository.findById(siteId)
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
if (!site.getUserId().equals(userId)) {
throw new IllegalArgumentException("Site not found");
}
return site;
}
private Page findPageForSite(UUID pageId, UUID siteId) {
Page page = pageRepository.findById(pageId)
.orElseThrow(() -> new IllegalArgumentException("Page not found"));
if (!page.getSiteId().equals(siteId)) {
throw new IllegalArgumentException("Page not found");
}
return page;
}
}

View File

@@ -0,0 +1,138 @@
package com.indie.controller;
import com.indie.config.RateLimitConfig;
import com.indie.config.UserPrincipal;
import com.indie.dto.Reference;
import com.indie.dto.SiteStructure;
import com.indie.model.GenerationVersion;
import com.indie.service.LLMService;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/llm")
public class LLMController {
private final LLMService llmService;
private final RateLimitConfig rateLimitConfig;
private final ObjectMapper objectMapper;
public LLMController(LLMService llmService, RateLimitConfig rateLimitConfig, ObjectMapper objectMapper) {
this.llmService = llmService;
this.rateLimitConfig = rateLimitConfig;
this.objectMapper = objectMapper;
}
@PostMapping("/generate")
public ResponseEntity<?> generate(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, Object> request) {
if (!rateLimitConfig.tryConsume(principal.id())) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of("error", "Rate limit exceeded. Max " +
rateLimitConfig.getAvailableTokens(principal.id()) +
" requests per hour."));
}
String prompt = (String) request.get("prompt");
if (prompt == null || prompt.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "Prompt is required"));
}
String siteIdStr = (String) request.get("siteId");
if (siteIdStr == null || siteIdStr.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "siteId is required"));
}
UUID siteId = UUID.fromString(siteIdStr);
List<Reference> references = null;
if (request.containsKey("references")) {
references = objectMapper.convertValue(
request.get("references"), new TypeReference<List<Reference>>() {});
}
SiteStructure structure = llmService.generateSite(principal.id(), siteId, prompt, references);
return ResponseEntity.ok(structure);
}
@PostMapping("/refine")
public ResponseEntity<?> refine(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, Object> request) {
if (!rateLimitConfig.tryConsume(principal.id())) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of("error", "Rate limit exceeded. Max " +
rateLimitConfig.getAvailableTokens(principal.id()) +
" requests per hour."));
}
String prompt = (String) request.get("prompt");
if (prompt == null || prompt.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "Prompt is required"));
}
String siteIdStr = (String) request.get("siteId");
if (siteIdStr == null || siteIdStr.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "siteId is required"));
}
UUID siteId = UUID.fromString(siteIdStr);
SiteStructure currentStructure;
try {
currentStructure = objectMapper.convertValue(
request.get("currentStructure"), SiteStructure.class);
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid currentStructure"));
}
if (currentStructure == null) {
return ResponseEntity.badRequest().body(Map.of("error", "currentStructure is required"));
}
List<Reference> references = null;
if (request.containsKey("references")) {
references = objectMapper.convertValue(
request.get("references"), new TypeReference<List<Reference>>() {});
}
SiteStructure structure = llmService.refineSite(principal.id(), siteId,
currentStructure, prompt, references);
return ResponseEntity.ok(structure);
}
@GetMapping("/versions/{siteId}")
public ResponseEntity<List<GenerationVersion>> getVersions(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId) {
List<GenerationVersion> versions = llmService.getVersions(siteId);
return ResponseEntity.ok(versions);
}
@PostMapping("/versions/{id}/restore")
public ResponseEntity<?> restoreVersion(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String id) {
SiteStructure structure = llmService.restoreVersion(id);
return ResponseEntity.ok(structure);
}
@DeleteMapping("/versions/{id}")
public ResponseEntity<?> deleteVersion(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String id) {
try {
llmService.deleteVersion(id);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
}

View File

@@ -1,124 +0,0 @@
package com.indie.controller;
import com.indie.config.UserPrincipal;
import com.indie.dto.PageRequest;
import com.indie.dto.PageResponse;
import com.indie.model.Page;
import com.indie.model.Site;
import com.indie.repository.PageRepository;
import com.indie.repository.SiteRepository;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/sites/{siteId}/pages")
public class PageController {
private final PageRepository pageRepository;
private final SiteRepository siteRepository;
public PageController(PageRepository pageRepository, SiteRepository siteRepository) {
this.pageRepository = pageRepository;
this.siteRepository = siteRepository;
}
@GetMapping
public ResponseEntity<List<PageResponse>> listPages(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId) {
Site site = findSiteForUser(siteId, principal.id());
List<Page> pages = pageRepository.findBySiteIdOrderByOrderIndexAsc(site.getId());
List<PageResponse> response = pages.stream()
.map(PageResponse::from)
.toList();
return ResponseEntity.ok(response);
}
@PostMapping
public ResponseEntity<PageResponse> createPage(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId,
@Valid @RequestBody PageRequest request) {
Site site = findSiteForUser(siteId, principal.id());
if (pageRepository.existsBySiteIdAndSlug(site.getId(), request.getSlug())) {
throw new IllegalArgumentException("A page with this slug already exists");
}
Page page = Page.builder()
.siteId(site.getId())
.title(request.getTitle())
.slug(request.getSlug())
.seoTitle(request.getSeoTitle())
.seoDescription(request.getSeoDescription())
.orderIndex(request.getOrderIndex())
.build();
page = pageRepository.save(page);
return ResponseEntity.status(HttpStatus.CREATED).body(PageResponse.from(page));
}
@GetMapping("/{pageId}")
public ResponseEntity<PageResponse> getPage(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId,
@PathVariable UUID pageId) {
Site site = findSiteForUser(siteId, principal.id());
Page page = findPageForSite(pageId, site.getId());
return ResponseEntity.ok(PageResponse.from(page));
}
@PutMapping("/{pageId}")
public ResponseEntity<PageResponse> updatePage(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId,
@PathVariable UUID pageId,
@Valid @RequestBody PageRequest request) {
Site site = findSiteForUser(siteId, principal.id());
Page page = findPageForSite(pageId, site.getId());
if (!page.getSlug().equals(request.getSlug())
&& pageRepository.existsBySiteIdAndSlug(site.getId(), request.getSlug())) {
throw new IllegalArgumentException("A page with this slug already exists");
}
page.setTitle(request.getTitle());
page.setSlug(request.getSlug());
page.setSeoTitle(request.getSeoTitle());
page.setSeoDescription(request.getSeoDescription());
page.setOrderIndex(request.getOrderIndex());
page = pageRepository.save(page);
return ResponseEntity.ok(PageResponse.from(page));
}
@DeleteMapping("/{pageId}")
public ResponseEntity<Void> deletePage(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId,
@PathVariable UUID pageId) {
Site site = findSiteForUser(siteId, principal.id());
Page page = findPageForSite(pageId, site.getId());
pageRepository.delete(page);
return ResponseEntity.noContent().build();
}
private Site findSiteForUser(UUID siteId, UUID userId) {
Site site = siteRepository.findById(siteId)
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
if (!site.getUserId().equals(userId)) {
throw new IllegalArgumentException("Site not found");
}
return site;
}
private Page findPageForSite(UUID pageId, UUID siteId) {
Page page = pageRepository.findById(pageId)
.orElseThrow(() -> new IllegalArgumentException("Page not found"));
if (!page.getSiteId().equals(siteId)) {
throw new IllegalArgumentException("Page not found");
}
return page;
}
}

View File

@@ -46,7 +46,6 @@ public class SiteController {
.name(request.getName())
.description(request.getDescription())
.subdomain(subdomain)
.themeConfig(request.getThemeConfig())
.build();
site = siteRepository.save(site);
@@ -76,9 +75,6 @@ public class SiteController {
site.setName(request.getName());
site.setDescription(request.getDescription());
site.setSubdomain(newSubdomain);
if (request.getThemeConfig() != null) {
site.setThemeConfig(request.getThemeConfig());
}
site = siteRepository.save(site);
return ResponseEntity.ok(SiteResponse.from(site));

View File

@@ -1,34 +0,0 @@
package com.indie.dto;
import com.indie.model.Component;
import com.indie.model.ComponentType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.util.Map;
import java.util.UUID;
@Data
@AllArgsConstructor
@Builder
public class ComponentResponse {
private UUID id;
private UUID pageId;
private ComponentType type;
private Map<String, Object> config;
private Map<String, Object> styles;
private int orderIndex;
public static ComponentResponse from(Component component) {
return ComponentResponse.builder()
.id(component.getId())
.pageId(component.getPageId())
.type(component.getType())
.config(component.getConfig())
.styles(component.getStyles())
.orderIndex(component.getOrderIndex())
.build();
}
}

View File

@@ -0,0 +1,20 @@
package com.indie.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@Data
@AllArgsConstructor
@Builder
public class LLMOptions {
@Builder.Default
private double temperature = 0.7;
@Builder.Default
private int maxTokens = 4096;
@Builder.Default
private int timeoutSeconds = 60;
}

View File

@@ -0,0 +1,17 @@
package com.indie.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@Data
@AllArgsConstructor
@Builder
public class LLMResult {
private String content;
private int promptTokens;
private int completionTokens;
private String model;
private long latencyMs;
}

View File

@@ -1,22 +0,0 @@
package com.indie.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
@Data
public class PageRequest {
@NotBlank(message = "Page title is required")
private String title;
@NotBlank(message = "Page slug is required")
@Pattern(regexp = "^[a-z0-9-]+$", message = "Slug must be lowercase alphanumeric with hyphens")
private String slug;
private String seoTitle;
private String seoDescription;
private int orderIndex;
}

View File

@@ -1,36 +0,0 @@
package com.indie.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.UUID;
@Data
@AllArgsConstructor
@Builder
public class PageResponse {
private UUID id;
private UUID siteId;
private String title;
private String slug;
private String seoTitle;
private String seoDescription;
private int orderIndex;
private LocalDateTime createdAt;
public static PageResponse from(com.indie.model.Page page) {
return PageResponse.builder()
.id(page.getId())
.siteId(page.getSiteId())
.title(page.getTitle())
.slug(page.getSlug())
.seoTitle(page.getSeoTitle())
.seoDescription(page.getSeoDescription())
.orderIndex(page.getOrderIndex())
.createdAt(page.getCreatedAt())
.build();
}
}

View File

@@ -1,40 +0,0 @@
package com.indie.dto;
import com.indie.model.Page;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Data
@AllArgsConstructor
@Builder
public class PageWithComponentsResponse {
private UUID id;
private UUID siteId;
private String title;
private String slug;
private String seoTitle;
private String seoDescription;
private int orderIndex;
private LocalDateTime createdAt;
private List<ComponentResponse> components;
public static PageWithComponentsResponse from(Page page, List<ComponentResponse> components) {
return PageWithComponentsResponse.builder()
.id(page.getId())
.siteId(page.getSiteId())
.title(page.getTitle())
.slug(page.getSlug())
.seoTitle(page.getSeoTitle())
.seoDescription(page.getSeoDescription())
.orderIndex(page.getOrderIndex())
.createdAt(page.getCreatedAt())
.components(components)
.build();
}
}

View File

@@ -0,0 +1,19 @@
package com.indie.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@Data
@AllArgsConstructor
@Builder
public class Reference {
private ReferenceType type;
private String url;
private String altText;
public enum ReferenceType {
URL, IMAGE
}
}

View File

@@ -1,28 +0,0 @@
package com.indie.dto;
import com.indie.model.ComponentType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Data
public class SaveComponentsRequest {
@Valid
@NotNull
private List<ComponentDTO> components;
@Data
public static class ComponentDTO {
private UUID id;
@NotNull
private ComponentType type;
private Map<String, Object> config;
private Map<String, Object> styles;
private int orderIndex;
}
}

View File

@@ -3,8 +3,6 @@ package com.indie.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.Map;
@Data
public class SiteRequest {
@@ -14,6 +12,4 @@ public class SiteRequest {
private String description;
private String subdomain;
private Map<String, Object> themeConfig;
}

View File

@@ -1,12 +1,12 @@
package com.indie.dto;
import com.indie.model.Site;
import com.indie.model.SiteStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
@Data
@@ -19,20 +19,18 @@ public class SiteResponse {
private String name;
private String description;
private String subdomain;
private Map<String, Object> themeConfig;
private SiteStatus status;
private LocalDateTime publishedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public static SiteResponse from(com.indie.model.Site site) {
public static SiteResponse from(Site site) {
return SiteResponse.builder()
.id(site.getId())
.userId(site.getUserId())
.name(site.getName())
.description(site.getDescription())
.subdomain(site.getSubdomain())
.themeConfig(site.getThemeConfig())
.status(site.getStatus())
.publishedAt(site.getPublishedAt())
.createdAt(site.getCreatedAt())

View File

@@ -0,0 +1,50 @@
package com.indie.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@AllArgsConstructor
@Builder
public class SiteStructure {
private ThemeConfig theme;
private List<PageStructure> pages;
@Data
@AllArgsConstructor
@Builder
public static class ThemeConfig {
private Map<String, String> colors;
private Map<String, String> fonts;
private Map<String, String> spacing;
}
@Data
@AllArgsConstructor
@Builder
public static class PageStructure {
private String id;
private String title;
private String slug;
private String seoTitle;
private String seoDescription;
private int orderIndex;
private List<ComponentStructure> components;
}
@Data
@AllArgsConstructor
@Builder
public static class ComponentStructure {
private String id;
private String type;
private int orderIndex;
private Map<String, Object> config;
private Map<String, Object> styles;
}
}

View File

@@ -1,47 +0,0 @@
package com.indie.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "components")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Component {
@Id
private UUID id;
@Column(nullable = false)
private UUID pageId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ComponentType type;
@JdbcTypeCode(SqlTypes.JSON)
private Map<String, Object> config;
@JdbcTypeCode(SqlTypes.JSON)
private Map<String, Object> styles;
@Column(nullable = false)
private int orderIndex;
@PrePersist
void onCreate() {
if (id == null) {
id = UUID.randomUUID();
}
}
}

View File

@@ -1,5 +0,0 @@
package com.indie.model;
public enum ComponentType {
HEADING, TEXT, IMAGE, BUTTON, DIVIDER, GALLERY, VIDEO, CONTACT_FORM, MAP, CUSTOM_HTML
}

View File

@@ -0,0 +1,41 @@
package com.indie.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Document(collection = "generation_versions")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class GenerationVersion {
@Id
private String id;
private UUID siteId;
private UUID userId;
private int versionNumber;
private String prompt;
private List<ReferenceEntry> references;
private String fullStructure;
private LocalDateTime createdAt;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class ReferenceEntry {
private String type;
private String url;
private String altText;
}
}

View File

@@ -1,50 +0,0 @@
package com.indie.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "pages")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Page {
@Id
private UUID id;
@Column(nullable = false)
private UUID siteId;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String slug;
private String seoTitle;
@Column(length = 500)
private String seoDescription;
@Column(nullable = false)
private int orderIndex;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
void onCreate() {
if (id == null) {
id = UUID.randomUUID();
}
createdAt = LocalDateTime.now();
}
}

View File

@@ -5,11 +5,8 @@ import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
@Entity
@@ -35,9 +32,6 @@ public class Site {
@Column(unique = true)
private String subdomain;
@JdbcTypeCode(SqlTypes.JSON)
private Map<String, Object> themeConfig;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Builder.Default

View File

@@ -1,14 +0,0 @@
package com.indie.repository;
import com.indie.model.Component;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface ComponentRepository extends JpaRepository<Component, UUID> {
List<Component> findByPageIdOrderByOrderIndexAsc(UUID pageId);
void deleteByPageId(UUID pageId);
}

View File

@@ -1,17 +0,0 @@
package com.indie.repository;
import com.indie.model.Page;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface PageRepository extends JpaRepository<Page, UUID> {
List<Page> findBySiteIdOrderByOrderIndexAsc(UUID siteId);
Optional<Page> findBySiteIdAndSlug(UUID siteId, String slug);
boolean existsBySiteIdAndSlug(UUID siteId, String slug);
}

View File

@@ -1,55 +0,0 @@
package com.indie.service;
import com.indie.dto.SaveComponentsRequest;
import com.indie.model.Component;
import com.indie.repository.ComponentRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class BuilderService {
private final ComponentRepository componentRepository;
static final int MAX_COMPONENTS_PER_PAGE = 50;
public void validateComponents(SaveComponentsRequest request) {
if (request.getComponents() == null || request.getComponents().isEmpty()) {
throw new IllegalArgumentException("Component list must not be empty");
}
if (request.getComponents().size() > MAX_COMPONENTS_PER_PAGE) {
throw new IllegalArgumentException("Maximum " + MAX_COMPONENTS_PER_PAGE + " components allowed per page");
}
for (SaveComponentsRequest.ComponentDTO dto : request.getComponents()) {
if (dto.getType() == null) {
throw new IllegalArgumentException("Component type is required");
}
}
}
@Transactional
public List<Component> saveComponents(UUID pageId, SaveComponentsRequest request) {
validateComponents(request);
componentRepository.deleteByPageId(pageId);
List<Component> components = request.getComponents().stream()
.map(dto -> Component.builder()
.id(dto.getId() != null ? dto.getId() : UUID.randomUUID())
.pageId(pageId)
.type(dto.getType())
.config(dto.getConfig())
.styles(dto.getStyles())
.orderIndex(dto.getOrderIndex())
.build())
.toList();
return componentRepository.saveAll(components);
}
}

View File

@@ -0,0 +1,191 @@
package com.indie.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.indie.dto.SiteStructure;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Component
public class LLMResponseParser {
private static final Logger log = LoggerFactory.getLogger(LLMResponseParser.class);
private final ObjectMapper objectMapper;
public LLMResponseParser(ObjectMapper objectMapper) {
this.objectMapper = objectMapper.copy()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
public SiteStructure parse(String llmOutput) {
String json = extractJson(llmOutput);
try {
JsonNode root = objectMapper.readTree(json);
return parseSiteStructure(root);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to parse LLM output as JSON: " + e.getMessage());
}
}
String extractJson(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 brace = trimmed.indexOf('{');
if (brace >= 0) {
trimmed = trimmed.substring(brace);
}
}
if (!trimmed.endsWith("}")) {
int brace = trimmed.lastIndexOf('}');
if (brace >= 0) {
trimmed = trimmed.substring(0, brace + 1);
}
}
return trimmed;
}
private SiteStructure parseSiteStructure(JsonNode root) {
SiteStructure.SiteStructureBuilder builder = SiteStructure.builder();
if (root.has("theme")) {
builder.theme(parseTheme(root.get("theme")));
}
List<SiteStructure.PageStructure> pages = new ArrayList<>();
if (root.has("pages") && root.get("pages").isArray()) {
for (JsonNode pageNode : root.get("pages")) {
pages.add(parsePage(pageNode));
}
}
builder.pages(pages);
SiteStructure structure = builder.build();
if (structure.getPages() == null || structure.getPages().isEmpty()) {
throw new IllegalArgumentException("LLM output must contain at least one page");
}
return structure;
}
private SiteStructure.ThemeConfig parseTheme(JsonNode themeNode) {
SiteStructure.ThemeConfig.ThemeConfigBuilder builder = SiteStructure.ThemeConfig.builder();
if (themeNode.has("colors")) {
builder.colors(parseStringMap(themeNode.get("colors")));
}
if (themeNode.has("fonts")) {
builder.fonts(parseStringMap(themeNode.get("fonts")));
}
if (themeNode.has("spacing")) {
builder.spacing(parseStringMap(themeNode.get("spacing")));
}
return builder.build();
}
private Map<String, String> parseStringMap(JsonNode node) {
Map<String, String> map = new java.util.LinkedHashMap<>();
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
map.put(field.getKey(), field.getValue().asText());
}
return map;
}
private SiteStructure.PageStructure parsePage(JsonNode pageNode) {
SiteStructure.PageStructure.PageStructureBuilder builder = SiteStructure.PageStructure.builder();
if (pageNode.has("id")) {
builder.id(pageNode.get("id").asText());
} else {
builder.id(UUID.randomUUID().toString());
}
if (pageNode.has("title")) {
builder.title(pageNode.get("title").asText());
}
if (pageNode.has("slug")) {
builder.slug(pageNode.get("slug").asText());
}
if (pageNode.has("seoTitle")) {
builder.seoTitle(pageNode.get("seoTitle").asText());
}
if (pageNode.has("seoDescription")) {
builder.seoDescription(pageNode.get("seoDescription").asText());
}
if (pageNode.has("orderIndex")) {
builder.orderIndex(pageNode.get("orderIndex").asInt());
}
List<SiteStructure.ComponentStructure> components = new ArrayList<>();
if (pageNode.has("components") && pageNode.get("components").isArray()) {
for (JsonNode compNode : pageNode.get("components")) {
components.add(parseComponent(compNode));
}
}
builder.components(components);
SiteStructure.PageStructure page = builder.build();
if (page.getTitle() == null || page.getSlug() == null) {
throw new IllegalArgumentException("Page must have title and slug");
}
return page;
}
private SiteStructure.ComponentStructure parseComponent(JsonNode compNode) {
SiteStructure.ComponentStructure.ComponentStructureBuilder builder = SiteStructure.ComponentStructure.builder();
if (compNode.has("id")) {
builder.id(compNode.get("id").asText());
} else {
builder.id(UUID.randomUUID().toString());
}
if (compNode.has("type")) {
builder.type(compNode.get("type").asText());
}
if (compNode.has("orderIndex")) {
builder.orderIndex(compNode.get("orderIndex").asInt());
}
if (compNode.has("config")) {
builder.config(objectMapper.convertValue(compNode.get("config"), Map.class));
}
if (compNode.has("styles")) {
builder.styles(objectMapper.convertValue(compNode.get("styles"), Map.class));
}
SiteStructure.ComponentStructure component = builder.build();
if (component.getType() == null) {
throw new IllegalArgumentException("Component must have a type");
}
return component;
}
}

View File

@@ -0,0 +1,191 @@
package com.indie.service;
import com.indie.dto.LLMOptions;
import com.indie.dto.LLMResult;
import com.indie.dto.Reference;
import com.indie.dto.SiteStructure;
import com.indie.model.GenerationVersion;
import com.indie.service.llm.LLMFactory;
import com.indie.service.llm.LLMProvider;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
public class LLMService {
private static final Logger log = LoggerFactory.getLogger(LLMService.class);
private static final int MAX_VERSIONS_PER_SITE = 100;
private final LLMFactory llmFactory;
private final LLMResponseParser responseParser;
private final ReferenceService referenceService;
private final MongoTemplate mongoTemplate;
private final String systemPrompt;
public LLMService(LLMFactory llmFactory,
LLMResponseParser responseParser,
ReferenceService referenceService,
MongoTemplate mongoTemplate,
ResourceLoader resourceLoader,
@Value("${indie.llm.system-prompt-path:classpath:prompts/site-generator.txt}") String promptPath) {
this.llmFactory = llmFactory;
this.responseParser = responseParser;
this.referenceService = referenceService;
this.mongoTemplate = mongoTemplate;
this.systemPrompt = loadSystemPrompt(resourceLoader, promptPath);
}
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt, List<Reference> references) {
String referenceContext = referenceService.buildReferenceContext(references);
String fullPrompt = prompt + referenceContext;
LLMOptions options = LLMOptions.builder().build();
LLMResult result = callLLM(systemPrompt, fullPrompt, references, options);
log.info("LLM generation completed: model={}, latency={}ms, tokens={}+{}",
result.getModel(), result.getLatencyMs(),
result.getPromptTokens(), result.getCompletionTokens());
SiteStructure structure = responseParser.parse(result.getContent());
saveVersion(userId, siteId, prompt, references, result.getContent());
return structure;
}
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
String refinementPrompt, List<Reference> references) {
String currentJson = serializeStructure(currentStructure);
String referenceContext = referenceService.buildReferenceContext(references);
String combinedPrompt = "Current site structure:\n" + currentJson
+ "\n\nRefinement request: " + refinementPrompt
+ referenceContext
+ "\n\nOutput the complete updated JSON structure with the requested changes applied.";
LLMOptions options = LLMOptions.builder()
.temperature(0.5)
.build();
LLMResult result = callLLM(systemPrompt, combinedPrompt, references, options);
log.info("LLM refinement completed: model={}, latency={}ms, tokens={}+{}",
result.getModel(), result.getLatencyMs(),
result.getPromptTokens(), result.getCompletionTokens());
SiteStructure structure = responseParser.parse(result.getContent());
saveVersion(userId, siteId, refinementPrompt, references, result.getContent());
return structure;
}
public List<GenerationVersion> getVersions(UUID siteId) {
Query query = new Query(Criteria.where("siteId").is(siteId))
.with(Sort.by(Sort.Direction.DESC, "versionNumber"))
.limit(MAX_VERSIONS_PER_SITE);
return mongoTemplate.find(query, GenerationVersion.class);
}
public SiteStructure restoreVersion(String versionId) {
GenerationVersion version = mongoTemplate.findById(versionId, GenerationVersion.class);
if (version == null) {
throw new IllegalArgumentException("Version not found: " + versionId);
}
return responseParser.parse(version.getFullStructure());
}
public void deleteVersion(String versionId) {
GenerationVersion version = mongoTemplate.findById(versionId, GenerationVersion.class);
if (version == null) {
throw new IllegalArgumentException("Version not found: " + versionId);
}
mongoTemplate.remove(version);
}
private LLMResult callLLM(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options) {
LLMProvider provider = llmFactory.getActiveProvider();
try {
return provider.generate(systemPrompt, userPrompt, references, options);
} catch (Exception e) {
log.warn("Active provider {} failed: {}. Trying fallback...",
provider.getName(), e.getMessage());
return llmFactory.getFallbackProvider()
.orElseThrow(() -> new RuntimeException("All LLM providers failed", e))
.generate(systemPrompt, userPrompt, references, options);
}
}
private void saveVersion(UUID userId, UUID siteId, String prompt,
List<Reference> references, String generatedJson) {
int nextVersion = getNextVersionNumber(siteId);
List<GenerationVersion.ReferenceEntry> refEntries = null;
if (references != null) {
refEntries = references.stream()
.map(r -> GenerationVersion.ReferenceEntry.builder()
.type(r.getType().name())
.url(r.getUrl())
.altText(r.getAltText())
.build())
.toList();
}
GenerationVersion version = GenerationVersion.builder()
.siteId(siteId)
.userId(userId)
.versionNumber(nextVersion)
.prompt(prompt)
.references(refEntries)
.fullStructure(generatedJson)
.createdAt(LocalDateTime.now())
.build();
mongoTemplate.save(version);
}
private int getNextVersionNumber(UUID siteId) {
Query query = new Query(Criteria.where("siteId").is(siteId))
.with(Sort.by(Sort.Direction.DESC, "versionNumber"))
.limit(1);
GenerationVersion latest = mongoTemplate.findOne(query, GenerationVersion.class);
return latest != null ? latest.getVersionNumber() + 1 : 1;
}
private String loadSystemPrompt(ResourceLoader resourceLoader, String path) {
try {
Resource resource = resourceLoader.getResource(path);
if (!resource.exists()) {
log.warn("System prompt file not found at {}, using default", path);
return "You are a website generator. Given a user's description, output a valid JSON site structure.";
}
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
return reader.lines().collect(Collectors.joining("\n"));
}
} catch (IOException e) {
log.warn("Failed to load system prompt from {}: {}", path, e.getMessage());
return "You are a website generator. Given a user's description, output a valid JSON site structure.";
}
}
private String serializeStructure(SiteStructure structure) {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(structure);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize site structure", e);
}
}
}

View File

@@ -0,0 +1,62 @@
package com.indie.service;
import com.indie.dto.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.List;
@Service
public class ReferenceService {
private static final Logger log = LoggerFactory.getLogger(ReferenceService.class);
private final RestTemplate restTemplate;
public ReferenceService() {
this.restTemplate = new RestTemplate();
}
public String buildReferenceContext(List<Reference> references) {
if (references == null || references.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder("\n\nReference materials provided by the user:\n");
for (int i = 0; i < references.size(); i++) {
Reference ref = references.get(i);
sb.append("[").append(i + 1).append("] ");
sb.append(ref.getType()).append(": ").append(ref.getUrl());
if (ref.getAltText() != null && !ref.getAltText().isBlank()) {
sb.append(" (").append(ref.getAltText()).append(")");
}
sb.append("\n");
if (ref.getType() == Reference.ReferenceType.URL) {
try {
String content = fetchUrlContent(ref.getUrl());
sb.append(" Content: ").append(truncate(content, 2000)).append("\n");
} catch (Exception e) {
log.warn("Failed to fetch reference URL {}: {}", ref.getUrl(), e.getMessage());
sb.append(" Content: [unable to fetch]\n");
}
}
}
return sb.toString();
}
String fetchUrlContent(String url) {
String html = restTemplate.getForObject(url, String.class);
if (html == null) return "";
return html.replaceAll("<[^>]+>", " ")
.replaceAll("\\s+", " ")
.trim();
}
private String truncate(String text, int maxLen) {
if (text.length() <= maxLen) return text;
return text.substring(0, maxLen) + "...";
}
}

View File

@@ -0,0 +1,133 @@
package com.indie.service.llm;
import com.indie.dto.LLMResult;
import com.indie.dto.LLMOptions;
import com.indie.dto.Reference;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Component
public class GeminiProvider implements LLMProvider {
private final RestTemplate restTemplate;
private final String apiKey;
private final String model;
private final String baseUrl;
public GeminiProvider(
@Value("${indie.llm.providers.gemini.api-key:}") String apiKey,
@Value("${indie.llm.providers.gemini.model:gemini-2.0-flash}") String model,
@Value("${indie.llm.providers.gemini.url:https://generativelanguage.googleapis.com/v1beta/models}") String baseUrl,
RestTemplateBuilder builder) {
this.apiKey = apiKey;
this.model = model;
this.baseUrl = baseUrl;
this.restTemplate = builder
.setConnectTimeout(Duration.ofSeconds(30))
.setReadTimeout(Duration.ofSeconds(60))
.build();
}
@Override
public LLMResult generate(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options) {
long start = System.currentTimeMillis();
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()
)
));
}
}
}
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();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
String url = baseUrl + "/" + model + ":generateContent?key=" + apiKey;
ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.POST, request, Map.class);
long latencyMs = System.currentTimeMillis() - start;
Map<String, Object> responseBody = response.getBody();
String content = extractContent(responseBody);
int promptTokens = 0;
int completionTokens = 0;
if (responseBody != null && responseBody.containsKey("usageMetadata")) {
Map<String, Object> usage = (Map<String, Object>) responseBody.get("usageMetadata");
promptTokens = ((Number) usage.getOrDefault("promptTokenCount", 0)).intValue();
completionTokens = ((Number) usage.getOrDefault("candidatesTokenCount", 0)).intValue();
}
return LLMResult.builder()
.content(content)
.promptTokens(promptTokens)
.completionTokens(completionTokens)
.model(model)
.latencyMs(latencyMs)
.build();
}
@SuppressWarnings("unchecked")
private String extractContent(Map<String, Object> responseBody) {
try {
List<Map<String, Object>> candidates = (List<Map<String, Object>>) responseBody.get("candidates");
if (candidates != null && !candidates.isEmpty()) {
Map<String, Object> first = candidates.get(0);
Map<String, Object> content = (Map<String, Object>) first.get("content");
if (content != null) {
List<Map<String, Object>> parts = (List<Map<String, Object>>) content.get("parts");
if (parts != null && !parts.isEmpty()) {
return (String) parts.get(0).get("text");
}
}
}
} catch (Exception e) {
throw new RuntimeException("Failed to parse Gemini response: " + e.getMessage());
}
throw new RuntimeException("Empty response from Gemini API");
}
@Override
public String getName() {
return "gemini";
}
}

View File

@@ -0,0 +1,55 @@
package com.indie.service.llm;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
@Component
public class LLMFactory {
private static final Logger log = LoggerFactory.getLogger(LLMFactory.class);
private final List<LLMProvider> providers;
private final String activeProvider;
private final String fallbackProvider;
private Map<String, LLMProvider> providerMap;
public LLMFactory(List<LLMProvider> providers,
@Value("${indie.llm.active-provider:gemini}") String activeProvider,
@Value("${indie.llm.fallback-provider:openai}") String fallbackProvider) {
this.providers = providers;
this.activeProvider = activeProvider;
this.fallbackProvider = fallbackProvider;
}
@PostConstruct
void init() {
providerMap = providers.stream()
.collect(Collectors.toMap(LLMProvider::getName, Function.identity()));
log.info("Available LLM providers: {}", providerMap.keySet());
}
public LLMProvider getActiveProvider() {
return getProvider(activeProvider)
.orElseThrow(() -> new IllegalStateException(
"Active LLM provider '" + activeProvider + "' not found. Available: " + providerMap.keySet()));
}
public Optional<LLMProvider> getFallbackProvider() {
return getProvider(fallbackProvider);
}
private Optional<LLMProvider> getProvider(String name) {
return Optional.ofNullable(providerMap.get(name));
}
}

View File

@@ -0,0 +1,14 @@
package com.indie.service.llm;
import com.indie.dto.LLMResult;
import com.indie.dto.LLMOptions;
import com.indie.dto.Reference;
import java.util.List;
public interface LLMProvider {
LLMResult generate(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options);
String getName();
}

View File

@@ -0,0 +1,103 @@
package com.indie.service.llm;
import com.indie.dto.LLMResult;
import com.indie.dto.LLMOptions;
import com.indie.dto.Reference;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.List;
import java.util.Map;
@Component
public class OpenAIProvider implements LLMProvider {
private final RestTemplate restTemplate;
private final String apiKey;
private final String model;
private final String url;
public OpenAIProvider(
@Value("${indie.llm.providers.openai.api-key:}") String apiKey,
@Value("${indie.llm.providers.openai.model:gpt-4o-mini}") String model,
@Value("${indie.llm.providers.openai.url:https://api.openai.com/v1/chat/completions}") String url,
RestTemplateBuilder builder) {
this.apiKey = apiKey;
this.model = model;
this.url = url;
this.restTemplate = builder
.setConnectTimeout(Duration.ofSeconds(30))
.setReadTimeout(Duration.ofSeconds(60))
.build();
}
@Override
public LLMResult generate(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options) {
long start = System.currentTimeMillis();
Map<String, Object> requestBody = 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()
);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(apiKey);
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.POST, request, Map.class);
long latencyMs = System.currentTimeMillis() - start;
Map<String, Object> responseBody = response.getBody();
String content = extractContent(responseBody);
int promptTokens = 0;
int completionTokens = 0;
if (responseBody != null && responseBody.containsKey("usage")) {
Map<String, Object> usage = (Map<String, Object>) responseBody.get("usage");
promptTokens = ((Number) usage.getOrDefault("prompt_tokens", 0)).intValue();
completionTokens = ((Number) usage.getOrDefault("completion_tokens", 0)).intValue();
}
return LLMResult.builder()
.content(content)
.promptTokens(promptTokens)
.completionTokens(completionTokens)
.model(model)
.latencyMs(latencyMs)
.build();
}
@SuppressWarnings("unchecked")
private String extractContent(Map<String, Object> responseBody) {
try {
List<Map<String, Object>> choices = (List<Map<String, Object>>) responseBody.get("choices");
if (choices != null && !choices.isEmpty()) {
Map<String, Object> first = choices.get(0);
Map<String, Object> message = (Map<String, Object>) first.get("message");
if (message != null) {
return (String) message.get("content");
}
}
} catch (Exception e) {
throw new RuntimeException("Failed to parse OpenAI response: " + e.getMessage());
}
throw new RuntimeException("Empty response from OpenAI API");
}
@Override
public String getName() {
return "openai";
}
}