prune old sites
This commit is contained in:
@@ -162,4 +162,17 @@ public class LLMController {
|
|||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/versions/{siteId}/prune")
|
||||||
|
public ResponseEntity<?> pruneVersions(
|
||||||
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable UUID siteId,
|
||||||
|
@RequestBody(required = false) Map<String, Object> request) {
|
||||||
|
int keep = 10;
|
||||||
|
if (request != null && request.containsKey("keep")) {
|
||||||
|
keep = ((Number) request.get("keep")).intValue();
|
||||||
|
}
|
||||||
|
int deleted = llmService.pruneVersions(siteId, keep);
|
||||||
|
return ResponseEntity.ok(Map.of("deleted", deleted));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,6 @@ public class SiteStructure {
|
|||||||
private String seoDescription;
|
private String seoDescription;
|
||||||
private int orderIndex;
|
private int orderIndex;
|
||||||
private String rawHtml;
|
private String rawHtml;
|
||||||
|
private boolean deleted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,17 @@ public class LLMResponseParser {
|
|||||||
String json = extractJson(llmOutput);
|
String json = extractJson(llmOutput);
|
||||||
try {
|
try {
|
||||||
JsonNode root = objectMapper.readTree(json);
|
JsonNode root = objectMapper.readTree(json);
|
||||||
return parseSiteStructure(root);
|
return parseSiteStructure(root, true);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalArgumentException("Failed to parse LLM output as JSON: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public SiteStructure parseLenient(String llmOutput) {
|
||||||
|
String json = extractJson(llmOutput);
|
||||||
|
try {
|
||||||
|
JsonNode root = objectMapper.readTree(json);
|
||||||
|
return parseSiteStructure(root, false);
|
||||||
} catch (JsonProcessingException e) {
|
} catch (JsonProcessingException e) {
|
||||||
throw new IllegalArgumentException("Failed to parse LLM output as JSON: " + e.getMessage());
|
throw new IllegalArgumentException("Failed to parse LLM output as JSON: " + e.getMessage());
|
||||||
}
|
}
|
||||||
@@ -69,7 +79,7 @@ public class LLMResponseParser {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private SiteStructure parseSiteStructure(JsonNode root) {
|
private SiteStructure parseSiteStructure(JsonNode root, boolean requirePages) {
|
||||||
SiteStructure.SiteStructureBuilder builder = SiteStructure.builder();
|
SiteStructure.SiteStructureBuilder builder = SiteStructure.builder();
|
||||||
|
|
||||||
if (root.has("theme")) {
|
if (root.has("theme")) {
|
||||||
@@ -79,13 +89,13 @@ public class LLMResponseParser {
|
|||||||
List<SiteStructure.PageStructure> pages = new ArrayList<>();
|
List<SiteStructure.PageStructure> pages = new ArrayList<>();
|
||||||
if (root.has("pages") && root.get("pages").isArray()) {
|
if (root.has("pages") && root.get("pages").isArray()) {
|
||||||
for (JsonNode pageNode : root.get("pages")) {
|
for (JsonNode pageNode : root.get("pages")) {
|
||||||
pages.add(parsePage(pageNode));
|
pages.add(parsePage(pageNode, !requirePages));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
builder.pages(pages);
|
builder.pages(pages);
|
||||||
|
|
||||||
SiteStructure structure = builder.build();
|
SiteStructure structure = builder.build();
|
||||||
if (structure.getPages() == null || structure.getPages().isEmpty()) {
|
if (requirePages && (structure.getPages() == null || structure.getPages().isEmpty())) {
|
||||||
throw new IllegalArgumentException("LLM output must contain at least one page");
|
throw new IllegalArgumentException("LLM output must contain at least one page");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +128,7 @@ public class LLMResponseParser {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private SiteStructure.PageStructure parsePage(JsonNode pageNode) {
|
private SiteStructure.PageStructure parsePage(JsonNode pageNode, boolean lenient) {
|
||||||
SiteStructure.PageStructure.PageStructureBuilder builder = SiteStructure.PageStructure.builder();
|
SiteStructure.PageStructure.PageStructureBuilder builder = SiteStructure.PageStructure.builder();
|
||||||
|
|
||||||
if (pageNode.has("id")) {
|
if (pageNode.has("id")) {
|
||||||
@@ -145,9 +155,12 @@ public class LLMResponseParser {
|
|||||||
if (pageNode.has("rawHtml")) {
|
if (pageNode.has("rawHtml")) {
|
||||||
builder.rawHtml(pageNode.get("rawHtml").asText());
|
builder.rawHtml(pageNode.get("rawHtml").asText());
|
||||||
}
|
}
|
||||||
|
if (pageNode.has("deleted")) {
|
||||||
|
builder.deleted(pageNode.get("deleted").asBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
SiteStructure.PageStructure page = builder.build();
|
SiteStructure.PageStructure page = builder.build();
|
||||||
if (page.getTitle() == null || page.getSlug() == null) {
|
if (!lenient && !page.isDeleted() && (page.getTitle() == null || page.getSlug() == null)) {
|
||||||
throw new IllegalArgumentException("Page must have title and slug");
|
throw new IllegalArgumentException("Page must have title and slug");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,12 @@ import java.io.IOException;
|
|||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -38,7 +42,6 @@ import java.util.stream.Collectors;
|
|||||||
public class LLMService {
|
public class LLMService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(LLMService.class);
|
private static final Logger log = LoggerFactory.getLogger(LLMService.class);
|
||||||
private static final int MAX_VERSIONS_PER_SITE = 100;
|
|
||||||
|
|
||||||
private final ProviderFactory providerFactory;
|
private final ProviderFactory providerFactory;
|
||||||
private final LLMResponseParser responseParser;
|
private final LLMResponseParser responseParser;
|
||||||
@@ -48,6 +51,8 @@ public class LLMService {
|
|||||||
private final EncryptionService encryptionService;
|
private final EncryptionService encryptionService;
|
||||||
private final String systemPrompt;
|
private final String systemPrompt;
|
||||||
private final String refinerPrompt;
|
private final String refinerPrompt;
|
||||||
|
private final int maxVersionsPerSite;
|
||||||
|
private final double refineTemperature;
|
||||||
|
|
||||||
public LLMService(ProviderFactory providerFactory,
|
public LLMService(ProviderFactory providerFactory,
|
||||||
LLMResponseParser responseParser,
|
LLMResponseParser responseParser,
|
||||||
@@ -57,7 +62,9 @@ public class LLMService {
|
|||||||
EncryptionService encryptionService,
|
EncryptionService encryptionService,
|
||||||
ResourceLoader resourceLoader,
|
ResourceLoader resourceLoader,
|
||||||
@Value("${indie.llm.system-prompt-path:classpath:prompts/site-generator.txt}") String promptPath,
|
@Value("${indie.llm.system-prompt-path:classpath:prompts/site-generator.txt}") String promptPath,
|
||||||
@Value("${indie.llm.refiner-prompt-path:classpath:prompts/site-refiner.txt}") String refinerPath) {
|
@Value("${indie.llm.refiner-prompt-path:classpath:prompts/site-refiner.txt}") String refinerPath,
|
||||||
|
@Value("${indie.llm.max-versions-per-site:10}") int maxVersionsPerSite,
|
||||||
|
@Value("${indie.llm.refine.temperature:0.3}") double refineTemperature) {
|
||||||
this.providerFactory = providerFactory;
|
this.providerFactory = providerFactory;
|
||||||
this.responseParser = responseParser;
|
this.responseParser = responseParser;
|
||||||
this.referenceService = referenceService;
|
this.referenceService = referenceService;
|
||||||
@@ -66,6 +73,8 @@ public class LLMService {
|
|||||||
this.encryptionService = encryptionService;
|
this.encryptionService = encryptionService;
|
||||||
this.systemPrompt = loadSystemPrompt(resourceLoader, promptPath);
|
this.systemPrompt = loadSystemPrompt(resourceLoader, promptPath);
|
||||||
this.refinerPrompt = loadSystemPrompt(resourceLoader, refinerPath);
|
this.refinerPrompt = loadSystemPrompt(resourceLoader, refinerPath);
|
||||||
|
this.maxVersionsPerSite = maxVersionsPerSite;
|
||||||
|
this.refineTemperature = refineTemperature;
|
||||||
}
|
}
|
||||||
|
|
||||||
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
|
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
|
||||||
@@ -121,29 +130,54 @@ public class LLMService {
|
|||||||
String combinedPrompt = "Current site structure:\n" + currentJson
|
String combinedPrompt = "Current site structure:\n" + currentJson
|
||||||
+ "\n\nRefinement request: " + refinementPrompt
|
+ "\n\nRefinement request: " + refinementPrompt
|
||||||
+ referenceContext
|
+ referenceContext
|
||||||
+ "\n\nOutput the complete updated JSON structure with the requested changes applied.";
|
+ "\n\nOutput only the fields that changed. Fields you omit will remain unchanged."
|
||||||
|
+ " Include the \"id\" field for any page you modify or add."
|
||||||
|
+ " To delete a page, include it with \"id\" and \"deleted\": true."
|
||||||
|
+ " Pages not included in your output will stay as-is.";
|
||||||
|
|
||||||
LLMOptions options = LLMOptions.builder()
|
LLMOptions options = LLMOptions.builder()
|
||||||
.temperature(0.5)
|
.temperature(refineTemperature)
|
||||||
.build();
|
.build();
|
||||||
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, refinerPrompt, combinedPrompt, references, options);
|
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, refinerPrompt, combinedPrompt, references, options);
|
||||||
log.info("LLM refinement completed: model={}, latency={}ms, tokens={}+{}",
|
log.info("LLM refinement completed: model={}, latency={}ms, tokens={}+{}",
|
||||||
result.getModel(), result.getLatencyMs(),
|
result.getModel(), result.getLatencyMs(),
|
||||||
result.getPromptTokens(), result.getCompletionTokens());
|
result.getPromptTokens(), result.getCompletionTokens());
|
||||||
|
|
||||||
SiteStructure structure = responseParser.parse(result.getContent());
|
SiteStructure patch = responseParser.parseLenient(result.getContent());
|
||||||
saveVersion(userId, siteId, refinementPrompt, references, result.getContent());
|
SiteStructure merged = mergePatches(currentStructure, patch);
|
||||||
|
saveVersion(userId, siteId, refinementPrompt, references, serializeStructure(merged));
|
||||||
|
|
||||||
return structure;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
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"))
|
||||||
.limit(MAX_VERSIONS_PER_SITE);
|
.limit(maxVersionsPerSite);
|
||||||
return mongoTemplate.find(query, GenerationVersion.class);
|
return mongoTemplate.find(query, GenerationVersion.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int pruneVersions(UUID siteId) {
|
||||||
|
return pruneVersions(siteId, maxVersionsPerSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int pruneVersions(UUID siteId, int keep) {
|
||||||
|
Query countQuery = new Query(Criteria.where("siteId").is(siteId));
|
||||||
|
long total = mongoTemplate.count(countQuery, GenerationVersion.class);
|
||||||
|
if (total <= keep) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
long toDelete = total - keep;
|
||||||
|
Query findQuery = new Query(Criteria.where("siteId").is(siteId))
|
||||||
|
.with(Sort.by(Sort.Direction.ASC, "versionNumber"))
|
||||||
|
.limit((int) toDelete);
|
||||||
|
List<GenerationVersion> oldVersions = mongoTemplate.find(findQuery, GenerationVersion.class);
|
||||||
|
for (GenerationVersion v : oldVersions) {
|
||||||
|
mongoTemplate.remove(v);
|
||||||
|
}
|
||||||
|
return oldVersions.size();
|
||||||
|
}
|
||||||
|
|
||||||
public SiteStructure restoreVersion(String versionId) {
|
public SiteStructure restoreVersion(String versionId) {
|
||||||
GenerationVersion version = mongoTemplate.findById(versionId, GenerationVersion.class);
|
GenerationVersion version = mongoTemplate.findById(versionId, GenerationVersion.class);
|
||||||
if (version == null) {
|
if (version == null) {
|
||||||
@@ -160,6 +194,110 @@ public class LLMService {
|
|||||||
mongoTemplate.remove(version);
|
mongoTemplate.remove(version);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private SiteStructure mergePatches(SiteStructure current, SiteStructure patch) {
|
||||||
|
SiteStructure.SiteStructureBuilder builder = SiteStructure.builder();
|
||||||
|
|
||||||
|
if (patch.getTheme() != null) {
|
||||||
|
builder.theme(mergeTheme(current.getTheme(), patch.getTheme()));
|
||||||
|
} else {
|
||||||
|
builder.theme(current.getTheme());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.getPages() != null && !patch.getPages().isEmpty()) {
|
||||||
|
Map<String, SiteStructure.PageStructure> currentPageMap = new HashMap<>();
|
||||||
|
for (SiteStructure.PageStructure page : current.getPages()) {
|
||||||
|
if (page.getId() != null) {
|
||||||
|
currentPageMap.put(page.getId(), page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> deletedIds = new HashSet<>();
|
||||||
|
for (SiteStructure.PageStructure patchPage : patch.getPages()) {
|
||||||
|
if (patchPage.isDeleted() && patchPage.getId() != null) {
|
||||||
|
deletedIds.add(patchPage.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SiteStructure.PageStructure> mergedPages = new ArrayList<>();
|
||||||
|
for (SiteStructure.PageStructure currentPage : current.getPages()) {
|
||||||
|
if (currentPage.getId() != null && deletedIds.contains(currentPage.getId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
SiteStructure.PageStructure matchingPatch = null;
|
||||||
|
if (currentPage.getId() != null) {
|
||||||
|
for (SiteStructure.PageStructure pp : patch.getPages()) {
|
||||||
|
if (currentPage.getId().equals(pp.getId())) {
|
||||||
|
matchingPatch = pp;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchingPatch != null && !matchingPatch.isDeleted()) {
|
||||||
|
mergedPages.add(mergePage(currentPage, matchingPatch));
|
||||||
|
} else {
|
||||||
|
mergedPages.add(currentPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (SiteStructure.PageStructure patchPage : patch.getPages()) {
|
||||||
|
if (patchPage.isDeleted()) continue;
|
||||||
|
String pid = patchPage.getId();
|
||||||
|
if (pid == null || !currentPageMap.containsKey(pid)) {
|
||||||
|
mergedPages.add(patchPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.pages(mergedPages);
|
||||||
|
} else {
|
||||||
|
builder.pages(current.getPages());
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SiteStructure.ThemeConfig mergeTheme(SiteStructure.ThemeConfig current, SiteStructure.ThemeConfig patch) {
|
||||||
|
SiteStructure.ThemeConfig.ThemeConfigBuilder builder = SiteStructure.ThemeConfig.builder();
|
||||||
|
|
||||||
|
if (patch.getColors() != null) {
|
||||||
|
Map<String, String> mergedColors = new HashMap<>(current.getColors() != null ? current.getColors() : Map.of());
|
||||||
|
mergedColors.putAll(patch.getColors());
|
||||||
|
builder.colors(mergedColors);
|
||||||
|
} else {
|
||||||
|
builder.colors(current.getColors());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.getFonts() != null) {
|
||||||
|
Map<String, String> mergedFonts = new HashMap<>(current.getFonts() != null ? current.getFonts() : Map.of());
|
||||||
|
mergedFonts.putAll(patch.getFonts());
|
||||||
|
builder.fonts(mergedFonts);
|
||||||
|
} else {
|
||||||
|
builder.fonts(current.getFonts());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.getSpacing() != null) {
|
||||||
|
Map<String, String> mergedSpacing = new HashMap<>(current.getSpacing() != null ? current.getSpacing() : Map.of());
|
||||||
|
mergedSpacing.putAll(patch.getSpacing());
|
||||||
|
builder.spacing(mergedSpacing);
|
||||||
|
} else {
|
||||||
|
builder.spacing(current.getSpacing());
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SiteStructure.PageStructure mergePage(SiteStructure.PageStructure current, SiteStructure.PageStructure patch) {
|
||||||
|
SiteStructure.PageStructure.PageStructureBuilder builder = SiteStructure.PageStructure.builder();
|
||||||
|
|
||||||
|
builder.id(patch.getId() != null ? patch.getId() : current.getId());
|
||||||
|
builder.title(patch.getTitle() != null ? patch.getTitle() : current.getTitle());
|
||||||
|
builder.slug(patch.getSlug() != null ? patch.getSlug() : current.getSlug());
|
||||||
|
builder.seoTitle(patch.getSeoTitle() != null ? patch.getSeoTitle() : current.getSeoTitle());
|
||||||
|
builder.seoDescription(patch.getSeoDescription() != null ? patch.getSeoDescription() : current.getSeoDescription());
|
||||||
|
builder.orderIndex(patch.getOrderIndex() != 0 ? patch.getOrderIndex() : current.getOrderIndex());
|
||||||
|
builder.rawHtml(patch.getRawHtml() != null ? patch.getRawHtml() : current.getRawHtml());
|
||||||
|
builder.deleted(patch.isDeleted());
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
private String resolveApiKey(UUID userId, String provider, String apiKey) {
|
private String resolveApiKey(UUID userId, String provider, String apiKey) {
|
||||||
if (apiKey != null && !apiKey.isBlank()) {
|
if (apiKey != null && !apiKey.isBlank()) {
|
||||||
return apiKey;
|
return apiKey;
|
||||||
@@ -247,6 +385,7 @@ public class LLMService {
|
|||||||
.createdAt(LocalDateTime.now())
|
.createdAt(LocalDateTime.now())
|
||||||
.build();
|
.build();
|
||||||
mongoTemplate.save(version);
|
mongoTemplate.save(version);
|
||||||
|
pruneVersions(siteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private int getNextVersionNumber(UUID siteId) {
|
private int getNextVersionNumber(UUID siteId) {
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ indie:
|
|||||||
|
|
||||||
llm:
|
llm:
|
||||||
read-timeout: ${INDIE_LLM_READ_TIMEOUT:180s}
|
read-timeout: ${INDIE_LLM_READ_TIMEOUT:180s}
|
||||||
|
max-versions-per-site: ${INDIE_LLM_MAX_VERSIONS:10}
|
||||||
|
refine:
|
||||||
|
temperature: ${INDIE_LLM_REFINE_TEMPERATURE:0.3}
|
||||||
|
read-timeout: ${INDIE_LLM_REFINE_READ_TIMEOUT:300s}
|
||||||
rate-limit:
|
rate-limit:
|
||||||
max-requests-per-hour: ${INDIE_LLM_RATE_LIMIT:10}
|
max-requests-per-hour: ${INDIE_LLM_RATE_LIMIT:10}
|
||||||
providers:
|
providers:
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ class LLMServiceTest {
|
|||||||
llmService = new LLMService(providerFactory, responseParser, referenceService,
|
llmService = new LLMService(providerFactory, responseParser, referenceService,
|
||||||
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",
|
||||||
|
10, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -210,9 +211,11 @@ class LLMServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void refineSiteReturnsRefinedStructure() {
|
void refineSiteReturnsRefinedStructure() {
|
||||||
|
String pageId = UUID.randomUUID().toString();
|
||||||
SiteStructure current = SiteStructure.builder()
|
SiteStructure current = SiteStructure.builder()
|
||||||
.pages(List.of(
|
.pages(List.of(
|
||||||
SiteStructure.PageStructure.builder()
|
SiteStructure.PageStructure.builder()
|
||||||
|
.id(pageId)
|
||||||
.title("Old")
|
.title("Old")
|
||||||
.slug("old")
|
.slug("old")
|
||||||
.rawHtml("<p>Old content</p>")
|
.rawHtml("<p>Old content</p>")
|
||||||
@@ -223,16 +226,202 @@ class LLMServiceTest {
|
|||||||
geminiProvider.withContent("""
|
geminiProvider.withContent("""
|
||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{"title": "Refined", "slug": "refined", "rawHtml": "<p>Hero</p>"}
|
{"id": "__PAGE_ID__", "title": "Refined", "slug": "refined", "rawHtml": "<p>Updated</p>"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
""");
|
""".replace("__PAGE_ID__", pageId));
|
||||||
|
|
||||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||||
|
|
||||||
SiteStructure result = llmService.refineSite(userId, siteId, current, "Make it better", null, "gemini", null);
|
SiteStructure result = llmService.refineSite(userId, siteId, current, "Make it better", null, "gemini", null);
|
||||||
|
|
||||||
assertEquals("Refined", result.getPages().get(0).getTitle());
|
assertEquals("Refined", result.getPages().get(0).getTitle());
|
||||||
|
assertEquals("<p>Updated</p>", result.getPages().get(0).getRawHtml());
|
||||||
|
verify(mongoTemplate).save(any(GenerationVersion.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refineSitePreservesUnchangedPages() {
|
||||||
|
String pageId1 = UUID.randomUUID().toString();
|
||||||
|
String pageId2 = UUID.randomUUID().toString();
|
||||||
|
SiteStructure current = SiteStructure.builder()
|
||||||
|
.theme(SiteStructure.ThemeConfig.builder()
|
||||||
|
.colors(Map.of("primary", "#000", "background", "#fff"))
|
||||||
|
.build())
|
||||||
|
.pages(List.of(
|
||||||
|
SiteStructure.PageStructure.builder()
|
||||||
|
.id(pageId1)
|
||||||
|
.title("Home")
|
||||||
|
.slug("home")
|
||||||
|
.rawHtml("<p>Home content</p>")
|
||||||
|
.build(),
|
||||||
|
SiteStructure.PageStructure.builder()
|
||||||
|
.id(pageId2)
|
||||||
|
.title("About")
|
||||||
|
.slug("about")
|
||||||
|
.rawHtml("<p>About content</p>")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
geminiProvider.withContent("""
|
||||||
|
{
|
||||||
|
"theme": {
|
||||||
|
"colors": {
|
||||||
|
"primary": "#1a1a2e",
|
||||||
|
"accent": "#e94560"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pages": [
|
||||||
|
{"id": "__PAGE_ID__", "rawHtml": "<p>Updated home</p>"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".replace("__PAGE_ID__", pageId1));
|
||||||
|
|
||||||
|
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||||
|
|
||||||
|
SiteStructure result = llmService.refineSite(userId, siteId, current, "Change primary color", null, "gemini", null);
|
||||||
|
|
||||||
|
assertEquals("#1a1a2e", result.getTheme().getColors().get("primary"));
|
||||||
|
assertEquals("#fff", result.getTheme().getColors().get("background"));
|
||||||
|
assertNotNull(result.getTheme().getColors().get("accent"));
|
||||||
|
|
||||||
|
assertEquals(2, result.getPages().size());
|
||||||
|
assertEquals("<p>Updated home</p>", result.getPages().get(0).getRawHtml());
|
||||||
|
assertEquals("About", result.getPages().get(1).getTitle());
|
||||||
|
assertEquals("<p>About content</p>", result.getPages().get(1).getRawHtml());
|
||||||
|
verify(mongoTemplate).save(any(GenerationVersion.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refineSiteDeletesPage() {
|
||||||
|
String pageId1 = UUID.randomUUID().toString();
|
||||||
|
String pageId2 = UUID.randomUUID().toString();
|
||||||
|
SiteStructure current = SiteStructure.builder()
|
||||||
|
.pages(List.of(
|
||||||
|
SiteStructure.PageStructure.builder()
|
||||||
|
.id(pageId1)
|
||||||
|
.title("Home")
|
||||||
|
.slug("home")
|
||||||
|
.rawHtml("<p>Home</p>")
|
||||||
|
.build(),
|
||||||
|
SiteStructure.PageStructure.builder()
|
||||||
|
.id(pageId2)
|
||||||
|
.title("About")
|
||||||
|
.slug("about")
|
||||||
|
.rawHtml("<p>About</p>")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
geminiProvider.withContent("""
|
||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
{"id": "__PAGE_ID__", "deleted": true}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".replace("__PAGE_ID__", pageId1));
|
||||||
|
|
||||||
|
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||||
|
|
||||||
|
SiteStructure result = llmService.refineSite(userId, siteId, current, "Remove Home page", null, "gemini", null);
|
||||||
|
|
||||||
|
assertEquals(1, result.getPages().size());
|
||||||
|
assertEquals("About", result.getPages().get(0).getTitle());
|
||||||
|
verify(mongoTemplate).save(any(GenerationVersion.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refineSiteAddsNewPage() {
|
||||||
|
String existingId = UUID.randomUUID().toString();
|
||||||
|
SiteStructure current = SiteStructure.builder()
|
||||||
|
.pages(List.of(
|
||||||
|
SiteStructure.PageStructure.builder()
|
||||||
|
.id(existingId)
|
||||||
|
.title("Home")
|
||||||
|
.slug("home")
|
||||||
|
.rawHtml("<p>Home</p>")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
geminiProvider.withContent("""
|
||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
{"title": "Contact", "slug": "contact", "rawHtml": "<p>Contact form</p>"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||||
|
|
||||||
|
SiteStructure result = llmService.refineSite(userId, siteId, current, "Add Contact page", null, "gemini", null);
|
||||||
|
|
||||||
|
assertEquals(2, result.getPages().size());
|
||||||
|
assertEquals("Contact", result.getPages().get(1).getTitle());
|
||||||
|
verify(mongoTemplate).save(any(GenerationVersion.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pruneVersionsDeletesOldestVersions() {
|
||||||
|
lenient().when(mongoTemplate.count(any(Query.class), eq(GenerationVersion.class))).thenReturn(15L);
|
||||||
|
List<GenerationVersion> oldVersions = List.of(
|
||||||
|
GenerationVersion.builder().id("v1").siteId(siteId).versionNumber(1).build(),
|
||||||
|
GenerationVersion.builder().id("v2").siteId(siteId).versionNumber(2).build(),
|
||||||
|
GenerationVersion.builder().id("v3").siteId(siteId).versionNumber(3).build(),
|
||||||
|
GenerationVersion.builder().id("v4").siteId(siteId).versionNumber(4).build(),
|
||||||
|
GenerationVersion.builder().id("v5").siteId(siteId).versionNumber(5).build()
|
||||||
|
);
|
||||||
|
lenient().when(mongoTemplate.find(any(Query.class), eq(GenerationVersion.class))).thenReturn(oldVersions);
|
||||||
|
|
||||||
|
int deleted = llmService.pruneVersions(siteId, 10);
|
||||||
|
|
||||||
|
assertEquals(5, deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pruneVersionsDoesNothingWhenUnderLimit() {
|
||||||
|
lenient().when(mongoTemplate.count(any(Query.class), eq(GenerationVersion.class))).thenReturn(8L);
|
||||||
|
|
||||||
|
int deleted = llmService.pruneVersions(siteId, 10);
|
||||||
|
|
||||||
|
assertEquals(0, deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveVersionTriggersAutoCleanup() {
|
||||||
|
String pageId = UUID.randomUUID().toString();
|
||||||
|
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||||
|
lenient().when(mongoTemplate.count(any(Query.class), eq(GenerationVersion.class))).thenReturn(15L);
|
||||||
|
lenient().when(mongoTemplate.find(any(Query.class), eq(GenerationVersion.class))).thenReturn(List.of(
|
||||||
|
GenerationVersion.builder().id("old").siteId(siteId).versionNumber(1).build(),
|
||||||
|
GenerationVersion.builder().id("older").siteId(siteId).versionNumber(2).build(),
|
||||||
|
GenerationVersion.builder().id("oldest").siteId(siteId).versionNumber(3).build(),
|
||||||
|
GenerationVersion.builder().id("ancient").siteId(siteId).versionNumber(4).build(),
|
||||||
|
GenerationVersion.builder().id("prehistoric").siteId(siteId).versionNumber(5).build()
|
||||||
|
));
|
||||||
|
|
||||||
|
geminiProvider.withContent("""
|
||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
{"id": "__PAGE_ID__", "title": "Home", "slug": "home", "rawHtml": "<p>Test</p>"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".replace("__PAGE_ID__", pageId));
|
||||||
|
|
||||||
|
SiteStructure current = SiteStructure.builder()
|
||||||
|
.pages(List.of(
|
||||||
|
SiteStructure.PageStructure.builder()
|
||||||
|
.id(pageId)
|
||||||
|
.title("Home")
|
||||||
|
.slug("home")
|
||||||
|
.rawHtml("<p>Old</p>")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
llmService.refineSite(userId, siteId, current, "Update", null, "gemini", null);
|
||||||
|
|
||||||
verify(mongoTemplate).save(any(GenerationVersion.class));
|
verify(mongoTemplate).save(any(GenerationVersion.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,4 +26,8 @@ export class LlmApiService {
|
|||||||
deleteVersion(versionId: string): Observable<void> {
|
deleteVersion(versionId: string): Observable<void> {
|
||||||
return this.http.delete<void>(`/api/llm/versions/${versionId}`);
|
return this.http.delete<void>(`/api/llm/versions/${versionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pruneVersions(siteId: string, keep: number = 10): Observable<{deleted: number}> {
|
||||||
|
return this.http.post<{deleted: number}>(`/api/llm/versions/${siteId}/prune`, { keep });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,10 +91,48 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
|||||||
Generate
|
Generate
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="configuredProviders.length === 0 && !(session.generating$ | async)" class="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
||||||
<p class="text-sm text-yellow-800">
|
</div>
|
||||||
No API keys configured. Go to <a class="underline font-medium cursor-pointer" (click)="goToSettings()">Settings</a> to add an API key for an AI provider.
|
</mat-card-content>
|
||||||
</p>
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card *ngIf="configuredProviders.length === 0 && !(session.generating$ | async)" class="p-4" appearance="outlined">
|
||||||
|
<mat-card-content class="!p-0">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Configure AI Provider</h4>
|
||||||
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
|
Add an API key to enable AI site generation. Keys are encrypted at rest.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<mat-form-field appearance="outline" class="w-full">
|
||||||
|
<mat-label>Provider</mat-label>
|
||||||
|
<mat-select [(ngModel)]="setupProvider">
|
||||||
|
<mat-option value="gemini">Gemini</mat-option>
|
||||||
|
<mat-option value="openai">OpenAI / Compatible</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="w-full">
|
||||||
|
<mat-label>API Key</mat-label>
|
||||||
|
<input matInput [type]="setupKeyVisible ? 'text' : 'password'" [(ngModel)]="setupApiKey" />
|
||||||
|
<button mat-icon-button matSuffix (click)="setupKeyVisible = !setupKeyVisible" type="button"
|
||||||
|
[attr.aria-label]="setupKeyVisible ? 'Hide key' : 'Show key'">
|
||||||
|
<mat-icon>{{ setupKeyVisible ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="w-full" *ngIf="setupProvider === 'openai'">
|
||||||
|
<mat-label>Base URL (optional)</mat-label>
|
||||||
|
<input matInput placeholder="https://api.deepseek.com/v1" [(ngModel)]="setupBaseUrl" />
|
||||||
|
<mat-hint>For OpenAI-compatible providers like DeepSeek</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="w-full">
|
||||||
|
<mat-label>Model (optional)</mat-label>
|
||||||
|
<input matInput placeholder="{{ setupProvider === 'gemini' ? 'gemini-2.0-flash-exp' : 'gpt-4o-mini' }}" [(ngModel)]="setupModel" />
|
||||||
|
<mat-hint>Override the default model name</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button mat-flat-button color="primary" [disabled]="!setupApiKey.trim() || setupSaving" (click)="saveSetupKey()">
|
||||||
|
<mat-icon>save</mat-icon>
|
||||||
|
{{ setupSaving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
@@ -123,7 +161,19 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
|||||||
<mat-card-content class="!p-0">
|
<mat-card-content class="!p-0">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">History</h4>
|
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">History</h4>
|
||||||
<span class="text-xs text-gray-400">{{ (session.versions$ | async)?.length || 0 }} version{{ ((session.versions$ | async)?.length || 0) !== 1 ? 's' : '' }}</span>
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
*ngIf="((session.versions$ | async)?.length || 0) > 10"
|
||||||
|
mat-stroked-button
|
||||||
|
class="!text-[10px] !leading-none !min-w-0 !px-2 !py-0.5 !h-auto"
|
||||||
|
color="warn"
|
||||||
|
(click)="pruneOldVersions()"
|
||||||
|
title="Keep only the 10 most recent versions"
|
||||||
|
>
|
||||||
|
Clear old
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-gray-400">{{ (session.versions$ | async)?.length || 0 }} version{{ ((session.versions$ | async)?.length || 0) !== 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="session.loadingVersions$ | async" class="flex justify-center py-4">
|
<div *ngIf="session.loadingVersions$ | async" class="flex justify-center py-4">
|
||||||
<mat-spinner diameter="20"></mat-spinner>
|
<mat-spinner diameter="20"></mat-spinner>
|
||||||
@@ -275,7 +325,7 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="!(session.generating$ | async) && !(session.currentResult$ | async) && !(session.error$ | async)" class="flex flex-col items-center justify-center py-16 text-gray-400">
|
<div *ngIf="configuredProviders.length > 0 && !(session.generating$ | async) && !(session.currentResult$ | async) && !(session.error$ | async)" class="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||||
<mat-icon class="text-6xl mb-4" style="width: auto; height: auto; font-size: 4rem;">auto_awesome</mat-icon>
|
<mat-icon class="text-6xl mb-4" style="width: auto; height: auto; font-size: 4rem;">auto_awesome</mat-icon>
|
||||||
<h3 class="text-xl font-medium text-gray-500 mb-2">Describe your dream site</h3>
|
<h3 class="text-xl font-medium text-gray-500 mb-2">Describe your dream site</h3>
|
||||||
<p class="text-sm text-gray-400 text-center max-w-md">
|
<p class="text-sm text-gray-400 text-center max-w-md">
|
||||||
@@ -306,6 +356,14 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
llmModels: Record<string, string> = {};
|
llmModels: Record<string, string> = {};
|
||||||
configuredProviders: string[] = [];
|
configuredProviders: string[] = [];
|
||||||
selectedProvider = '';
|
selectedProvider = '';
|
||||||
|
|
||||||
|
setupProvider = 'gemini';
|
||||||
|
setupApiKey = '';
|
||||||
|
setupBaseUrl = '';
|
||||||
|
setupModel = '';
|
||||||
|
setupSaving = false;
|
||||||
|
setupKeyVisible = false;
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -446,6 +504,23 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pruneOldVersions(): void {
|
||||||
|
if (!this.siteId) return;
|
||||||
|
this.api.pruneVersions(this.siteId)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
this.loadVersions();
|
||||||
|
if (result.deleted > 0) {
|
||||||
|
this.snackBar.open(`Cleaned up ${result.deleted} old versions`, 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.snackBar.open('Failed to clean up versions', 'Close', { duration: 3000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
switchToVersion(version: GenerationVersion): void {
|
switchToVersion(version: GenerationVersion): void {
|
||||||
if (version.id === this.session.currentVersionId) return;
|
if (version.id === this.session.currentVersionId) return;
|
||||||
this.session.setGenerating(true);
|
this.session.setGenerating(true);
|
||||||
@@ -502,6 +577,30 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
return prompt.length > maxLen ? prompt.slice(0, maxLen) + '...' : prompt;
|
return prompt.length > maxLen ? prompt.slice(0, maxLen) + '...' : prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveSetupKey(): void {
|
||||||
|
const key = this.setupApiKey?.trim();
|
||||||
|
const baseUrl = this.setupBaseUrl?.trim() || undefined;
|
||||||
|
const model = this.setupModel?.trim() || undefined;
|
||||||
|
if (!key) return;
|
||||||
|
this.setupSaving = true;
|
||||||
|
this.llmConfigService.saveKey(this.setupProvider, key, baseUrl, model)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.setupSaving = false;
|
||||||
|
this.setupApiKey = '';
|
||||||
|
this.setupBaseUrl = '';
|
||||||
|
this.setupModel = '';
|
||||||
|
this.snackBar.open(`${this.setupProvider} API key saved`, 'Close', { duration: 2000 });
|
||||||
|
this.loadLlmConfig();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.setupSaving = false;
|
||||||
|
this.snackBar.open('Failed to save API key', 'Close', { duration: 3000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
|
|||||||
Reference in New Issue
Block a user