add bring your own key support
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
@Component
|
||||
public class EncryptionService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(EncryptionService.class);
|
||||
private static final String ALGORITHM = "AES/GCM/NoPadding";
|
||||
private static final int GCM_IV_LENGTH = 12;
|
||||
private static final int GCM_TAG_LENGTH = 128;
|
||||
|
||||
private final String masterKeyPath;
|
||||
private SecretKey key;
|
||||
|
||||
public EncryptionService(
|
||||
@Value("${indie.encryption.master-key-path:config/encryption.key}") String masterKeyPath) {
|
||||
this.masterKeyPath = masterKeyPath;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
Path path = Path.of(masterKeyPath);
|
||||
if (Files.exists(path)) {
|
||||
try {
|
||||
byte[] encoded = Files.readAllBytes(path);
|
||||
this.key = new SecretKeySpec(Base64.getDecoder().decode(encoded), "AES");
|
||||
log.info("Loaded encryption key from {}", masterKeyPath);
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to load encryption key from {}, generating new one: {}", masterKeyPath, e.getMessage());
|
||||
}
|
||||
}
|
||||
try {
|
||||
KeyGenerator kg = KeyGenerator.getInstance("AES");
|
||||
kg.init(256);
|
||||
this.key = kg.generateKey();
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.write(path, Base64.getEncoder().encode(key.getEncoded()));
|
||||
log.info("Generated and saved encryption key to {}", masterKeyPath);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to persist encryption key, using ephemeral key (data lost on restart): {}", e.getMessage());
|
||||
try {
|
||||
KeyGenerator kg = KeyGenerator.getInstance("AES");
|
||||
kg.init(256);
|
||||
this.key = kg.generateKey();
|
||||
} catch (Exception ex) {
|
||||
throw new RuntimeException("Failed to generate encryption key", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String encrypt(String plaintext) {
|
||||
try {
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
SecureRandom.getInstanceStrong().nextBytes(iv);
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
|
||||
byte[] ciphertext = cipher.doFinal(plaintext.getBytes());
|
||||
ByteBuffer buffer = ByteBuffer.allocate(iv.length + ciphertext.length);
|
||||
buffer.put(iv);
|
||||
buffer.put(ciphertext);
|
||||
return Base64.getEncoder().encodeToString(buffer.array());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Encryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String decrypt(String encrypted) {
|
||||
try {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(Base64.getDecoder().decode(encrypted));
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
buffer.get(iv);
|
||||
byte[] ciphertext = new byte[buffer.remaining()];
|
||||
buffer.get(ciphertext);
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
|
||||
return new String(cipher.doFinal(ciphertext));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Decryption failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Converter
|
||||
public class MapToJsonConverter implements AttributeConverter<Map<String, String>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(Map<String, String> attribute) {
|
||||
if (attribute == null || attribute.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return MAPPER.writeValueAsString(attribute);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to serialize map to JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
return MAPPER.readValue(dbData, new TypeReference<Map<String, String>>() {});
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to deserialize JSON to map", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,14 +53,28 @@ public class LLMController {
|
||||
}
|
||||
UUID siteId = UUID.fromString(siteIdStr);
|
||||
|
||||
String provider = (String) request.get("provider");
|
||||
if (provider == null || provider.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "provider is required"));
|
||||
}
|
||||
|
||||
String apiKey = (String) request.get("apiKey");
|
||||
String baseUrl = (String) request.get("baseUrl");
|
||||
String model = (String) request.get("model");
|
||||
|
||||
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);
|
||||
try {
|
||||
SiteStructure structure = llmService.generateSite(
|
||||
principal.id(), siteId, prompt, references, provider, apiKey, baseUrl, model);
|
||||
return ResponseEntity.ok(structure);
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/refine")
|
||||
@@ -85,6 +99,15 @@ public class LLMController {
|
||||
}
|
||||
UUID siteId = UUID.fromString(siteIdStr);
|
||||
|
||||
String provider = (String) request.get("provider");
|
||||
if (provider == null || provider.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "provider is required"));
|
||||
}
|
||||
|
||||
String apiKey = (String) request.get("apiKey");
|
||||
String baseUrl = (String) request.get("baseUrl");
|
||||
String model = (String) request.get("model");
|
||||
|
||||
SiteStructure currentStructure;
|
||||
try {
|
||||
currentStructure = objectMapper.convertValue(
|
||||
@@ -103,9 +126,13 @@ public class LLMController {
|
||||
request.get("references"), new TypeReference<List<Reference>>() {});
|
||||
}
|
||||
|
||||
try {
|
||||
SiteStructure structure = llmService.refineSite(principal.id(), siteId,
|
||||
currentStructure, prompt, references);
|
||||
currentStructure, prompt, references, provider, apiKey, baseUrl, model);
|
||||
return ResponseEntity.ok(structure);
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/versions/{siteId}")
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.EncryptionService;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.LLMConfigRequest;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/user")
|
||||
public class UserSettingsController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final EncryptionService encryptionService;
|
||||
|
||||
public UserSettingsController(UserRepository userRepository, EncryptionService encryptionService) {
|
||||
this.userRepository = userRepository;
|
||||
this.encryptionService = encryptionService;
|
||||
}
|
||||
|
||||
@GetMapping("/llm-config")
|
||||
public ResponseEntity<Map<String, Object>> getLlmConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
User user = userRepository.findById(principal.id())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
Map<String, String> keys = user.getLlmApiKeys();
|
||||
Map<String, String> urls = user.getLlmBaseUrls();
|
||||
Map<String, String> models = user.getLlmModels();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (keys != null) {
|
||||
for (String provider : keys.keySet()) {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put("configured", true);
|
||||
if (urls != null && urls.containsKey(provider)) {
|
||||
config.put("baseUrl", urls.get(provider));
|
||||
}
|
||||
if (models != null && models.containsKey(provider)) {
|
||||
config.put("model", models.get(provider));
|
||||
}
|
||||
result.put(provider, config);
|
||||
}
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@PutMapping("/llm-config")
|
||||
public ResponseEntity<Void> saveLlmConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestBody LLMConfigRequest request) {
|
||||
if (request.provider() == null || request.provider().isBlank()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
User user = userRepository.findById(principal.id())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
if (request.apiKey() != null && !request.apiKey().isBlank()) {
|
||||
Map<String, String> keys = new HashMap<>();
|
||||
if (user.getLlmApiKeys() != null) {
|
||||
keys.putAll(user.getLlmApiKeys());
|
||||
}
|
||||
keys.put(request.provider(), encryptionService.encrypt(request.apiKey()));
|
||||
user.setLlmApiKeys(keys);
|
||||
}
|
||||
|
||||
if (request.baseUrl() != null && !request.baseUrl().isBlank()) {
|
||||
Map<String, String> urls = new HashMap<>();
|
||||
if (user.getLlmBaseUrls() != null) {
|
||||
urls.putAll(user.getLlmBaseUrls());
|
||||
}
|
||||
urls.put(request.provider(), request.baseUrl().trim());
|
||||
user.setLlmBaseUrls(urls);
|
||||
}
|
||||
|
||||
if (request.model() != null && !request.model().isBlank()) {
|
||||
Map<String, String> models = new HashMap<>();
|
||||
if (user.getLlmModels() != null) {
|
||||
models.putAll(user.getLlmModels());
|
||||
}
|
||||
models.put(request.provider(), request.model().trim());
|
||||
user.setLlmModels(models);
|
||||
}
|
||||
|
||||
userRepository.save(user);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/llm-config/{provider}")
|
||||
public ResponseEntity<Void> deleteLlmConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String provider) {
|
||||
User user = userRepository.findById(principal.id())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
Map<String, String> keys = new HashMap<>();
|
||||
if (user.getLlmApiKeys() != null) {
|
||||
keys.putAll(user.getLlmApiKeys());
|
||||
}
|
||||
keys.remove(provider);
|
||||
user.setLlmApiKeys(keys);
|
||||
|
||||
Map<String, String> urls = new HashMap<>();
|
||||
if (user.getLlmBaseUrls() != null) {
|
||||
urls.putAll(user.getLlmBaseUrls());
|
||||
}
|
||||
urls.remove(provider);
|
||||
user.setLlmBaseUrls(urls);
|
||||
|
||||
Map<String, String> models = new HashMap<>();
|
||||
if (user.getLlmModels() != null) {
|
||||
models.putAll(user.getLlmModels());
|
||||
}
|
||||
models.remove(provider);
|
||||
user.setLlmModels(models);
|
||||
|
||||
userRepository.save(user);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
12
backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java
Normal file
12
backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
public record LLMConfigRequest(String provider, String apiKey, String baseUrl, String model) {
|
||||
|
||||
public LLMConfigRequest(String provider, String apiKey) {
|
||||
this(provider, apiKey, null, null);
|
||||
}
|
||||
|
||||
public LLMConfigRequest(String provider, String apiKey, String baseUrl) {
|
||||
this(provider, apiKey, baseUrl, null);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import com.krrishg.config.MapToJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
@@ -7,6 +8,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@@ -44,6 +46,18 @@ public class User {
|
||||
@Builder.Default
|
||||
private boolean emailVerified = false;
|
||||
|
||||
@Convert(converter = MapToJsonConverter.class)
|
||||
@Column(name = "llm_api_keys", columnDefinition = "TEXT")
|
||||
private Map<String, String> llmApiKeys;
|
||||
|
||||
@Convert(converter = MapToJsonConverter.class)
|
||||
@Column(name = "llm_base_urls", columnDefinition = "TEXT")
|
||||
private Map<String, String> llmBaseUrls;
|
||||
|
||||
@Convert(converter = MapToJsonConverter.class)
|
||||
@Column(name = "llm_models", columnDefinition = "TEXT")
|
||||
private Map<String, String> llmModels;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.config.EncryptionService;
|
||||
import com.krrishg.dto.LLMOptions;
|
||||
import com.krrishg.dto.LLMResult;
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.service.llm.LLMFactory;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
import com.krrishg.service.llm.LLMProvider;
|
||||
import com.krrishg.service.llm.ProviderFactory;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -25,6 +29,7 @@ import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -34,34 +39,54 @@ 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 ProviderFactory providerFactory;
|
||||
private final LLMResponseParser responseParser;
|
||||
private final ReferenceService referenceService;
|
||||
private final MongoTemplate mongoTemplate;
|
||||
private final UserRepository userRepository;
|
||||
private final EncryptionService encryptionService;
|
||||
private final String systemPrompt;
|
||||
private final String refinerPrompt;
|
||||
|
||||
public LLMService(LLMFactory llmFactory,
|
||||
public LLMService(ProviderFactory providerFactory,
|
||||
LLMResponseParser responseParser,
|
||||
ReferenceService referenceService,
|
||||
MongoTemplate mongoTemplate,
|
||||
UserRepository userRepository,
|
||||
EncryptionService encryptionService,
|
||||
ResourceLoader resourceLoader,
|
||||
@Value("${indie.llm.system-prompt-path:classpath:prompts/site-generator.txt}") String promptPath,
|
||||
@Value("${indie.llm.refiner-prompt-path:classpath:prompts/site-refiner.txt}") String refinerPath) {
|
||||
this.llmFactory = llmFactory;
|
||||
this.providerFactory = providerFactory;
|
||||
this.responseParser = responseParser;
|
||||
this.referenceService = referenceService;
|
||||
this.mongoTemplate = mongoTemplate;
|
||||
this.userRepository = userRepository;
|
||||
this.encryptionService = encryptionService;
|
||||
this.systemPrompt = loadSystemPrompt(resourceLoader, promptPath);
|
||||
this.refinerPrompt = loadSystemPrompt(resourceLoader, refinerPath);
|
||||
}
|
||||
|
||||
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt, List<Reference> references) {
|
||||
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
|
||||
List<Reference> references, String provider, String apiKey) {
|
||||
return generateSite(userId, siteId, prompt, references, provider, apiKey, null, null);
|
||||
}
|
||||
|
||||
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
|
||||
List<Reference> references, String provider, String apiKey, String baseUrl) {
|
||||
return generateSite(userId, siteId, prompt, references, provider, apiKey, baseUrl, null);
|
||||
}
|
||||
|
||||
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
|
||||
List<Reference> references, String provider, String apiKey, String baseUrl, String model) {
|
||||
String resolvedKey = resolveApiKey(userId, provider, apiKey);
|
||||
String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl);
|
||||
String resolvedModel = resolveModel(userId, provider, model);
|
||||
String referenceContext = referenceService.buildReferenceContext(references);
|
||||
String fullPrompt = prompt + referenceContext;
|
||||
|
||||
LLMOptions options = LLMOptions.builder().build();
|
||||
LLMResult result = callLLM(systemPrompt, fullPrompt, references, options);
|
||||
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, systemPrompt, fullPrompt, references, options);
|
||||
log.info("LLM generation completed: model={}, latency={}ms, tokens={}+{}",
|
||||
result.getModel(), result.getLatencyMs(),
|
||||
result.getPromptTokens(), result.getCompletionTokens());
|
||||
@@ -73,7 +98,23 @@ public class LLMService {
|
||||
}
|
||||
|
||||
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
|
||||
String refinementPrompt, List<Reference> references) {
|
||||
String refinementPrompt, List<Reference> references,
|
||||
String provider, String apiKey) {
|
||||
return refineSite(userId, siteId, currentStructure, refinementPrompt, references, provider, apiKey, null, null);
|
||||
}
|
||||
|
||||
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
|
||||
String refinementPrompt, List<Reference> references,
|
||||
String provider, String apiKey, String baseUrl) {
|
||||
return refineSite(userId, siteId, currentStructure, refinementPrompt, references, provider, apiKey, baseUrl, null);
|
||||
}
|
||||
|
||||
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
|
||||
String refinementPrompt, List<Reference> references,
|
||||
String provider, String apiKey, String baseUrl, String model) {
|
||||
String resolvedKey = resolveApiKey(userId, provider, apiKey);
|
||||
String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl);
|
||||
String resolvedModel = resolveModel(userId, provider, model);
|
||||
String currentJson = serializeStructure(currentStructure);
|
||||
String referenceContext = referenceService.buildReferenceContext(references);
|
||||
String combinedPrompt = "Current site structure:\n" + currentJson
|
||||
@@ -84,7 +125,7 @@ public class LLMService {
|
||||
LLMOptions options = LLMOptions.builder()
|
||||
.temperature(0.5)
|
||||
.build();
|
||||
LLMResult result = callLLM(refinerPrompt, combinedPrompt, references, options);
|
||||
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, refinerPrompt, combinedPrompt, references, options);
|
||||
log.info("LLM refinement completed: model={}, latency={}ms, tokens={}+{}",
|
||||
result.getModel(), result.getLatencyMs(),
|
||||
result.getPromptTokens(), result.getCompletionTokens());
|
||||
@@ -118,16 +159,62 @@ public class LLMService {
|
||||
mongoTemplate.remove(version);
|
||||
}
|
||||
|
||||
private LLMResult callLLM(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options) {
|
||||
LLMProvider provider = llmFactory.getActiveProvider();
|
||||
private String resolveApiKey(UUID userId, String provider, String apiKey) {
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
return apiKey;
|
||||
}
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
Map<String, String> keys = user.getLlmApiKeys();
|
||||
if (keys != null && keys.containsKey(provider)) {
|
||||
return encryptionService.decrypt(keys.get(provider));
|
||||
}
|
||||
throw new IllegalStateException("No API key configured for " + provider
|
||||
+ ". Add one in Settings.");
|
||||
}
|
||||
|
||||
private String resolveModel(UUID userId, String provider, String model) {
|
||||
if (model != null && !model.isBlank()) {
|
||||
return model;
|
||||
}
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
Map<String, String> models = user.getLlmModels();
|
||||
if (models != null && models.containsKey(provider)) {
|
||||
return models.get(provider);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveBaseUrl(UUID userId, String provider, String baseUrl) {
|
||||
if (baseUrl != null && !baseUrl.isBlank()) {
|
||||
return baseUrl;
|
||||
}
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
Map<String, String> urls = user.getLlmBaseUrls();
|
||||
if (urls != null && urls.containsKey(provider)) {
|
||||
return urls.get(provider);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private LLMResult callLLM(String provider, String apiKey, String baseUrl, String model, String systemPrompt,
|
||||
String userPrompt, List<Reference> references, LLMOptions options) {
|
||||
LLMProvider p = providerFactory.createProvider(provider, apiKey, baseUrl, model);
|
||||
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);
|
||||
return p.generate(systemPrompt, userPrompt, references, options);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,13 @@ package com.krrishg.service.llm;
|
||||
import com.krrishg.dto.LLMResult;
|
||||
import com.krrishg.dto.LLMOptions;
|
||||
import com.krrishg.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;
|
||||
@@ -22,18 +17,11 @@ public class GeminiProvider implements LLMProvider {
|
||||
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) {
|
||||
public GeminiProvider(String apiKey, String model, String baseUrl, RestTemplate restTemplate) {
|
||||
this.apiKey = apiKey;
|
||||
this.model = model;
|
||||
this.baseUrl = baseUrl;
|
||||
this.restTemplate = builder
|
||||
.setConnectTimeout(Duration.ofSeconds(30))
|
||||
.setReadTimeout(Duration.ofSeconds(60))
|
||||
.build();
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.krrishg.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));
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,12 @@ package com.krrishg.service.llm;
|
||||
import com.krrishg.dto.LLMResult;
|
||||
import com.krrishg.dto.LLMOptions;
|
||||
import com.krrishg.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;
|
||||
@@ -21,18 +16,11 @@ public class OpenAIProvider implements LLMProvider {
|
||||
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) {
|
||||
public OpenAIProvider(String apiKey, String model, String url, RestTemplate restTemplate) {
|
||||
this.apiKey = apiKey;
|
||||
this.model = model;
|
||||
this.url = url;
|
||||
this.restTemplate = builder
|
||||
.setConnectTimeout(Duration.ofSeconds(30))
|
||||
.setReadTimeout(Duration.ofSeconds(60))
|
||||
.build();
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.krrishg.service.llm;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
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 ProviderFactory {
|
||||
|
||||
private final RestTemplateBuilder restTemplateBuilder;
|
||||
private final Map<String, ProviderConfig> providerConfigs;
|
||||
|
||||
public ProviderFactory(
|
||||
@Value("${indie.llm.providers.gemini.model:gemini-3.1-flash-lite}") String geminiModel,
|
||||
@Value("${indie.llm.providers.gemini.url:https://generativelanguage.googleapis.com/v1beta/models}") String geminiUrl,
|
||||
@Value("${indie.llm.providers.openai.model:gpt-4o-mini}") String openaiModel,
|
||||
@Value("${indie.llm.providers.openai.url:https://api.openai.com/v1/chat/completions}") String openaiUrl,
|
||||
RestTemplateBuilder restTemplateBuilder) {
|
||||
this.restTemplateBuilder = restTemplateBuilder;
|
||||
this.providerConfigs = Map.of(
|
||||
"gemini", new ProviderConfig(geminiModel, geminiUrl),
|
||||
"openai", new ProviderConfig(openaiModel, openaiUrl)
|
||||
);
|
||||
}
|
||||
|
||||
public LLMProvider createProvider(String name, String apiKey) {
|
||||
return createProvider(name, apiKey, null, null);
|
||||
}
|
||||
|
||||
public LLMProvider createProvider(String name, String apiKey, String baseUrl) {
|
||||
return createProvider(name, apiKey, baseUrl, null);
|
||||
}
|
||||
|
||||
public LLMProvider createProvider(String name, String apiKey, String baseUrl, String model) {
|
||||
ProviderConfig config = providerConfigs.get(name);
|
||||
if (config == null) {
|
||||
throw new IllegalArgumentException("Unknown LLM provider: " + name
|
||||
+ ". Available: " + providerConfigs.keySet());
|
||||
}
|
||||
String resolvedModel = (model != null && !model.isBlank()) ? model : config.model;
|
||||
String url = config.url;
|
||||
if (baseUrl != null && !baseUrl.isBlank()) {
|
||||
url = baseUrl;
|
||||
if ("openai".equals(name) && !url.endsWith("/chat/completions")) {
|
||||
url = url.endsWith("/") ? url + "chat/completions" : url + "/chat/completions";
|
||||
}
|
||||
}
|
||||
RestTemplate rt = restTemplateBuilder
|
||||
.setConnectTimeout(Duration.ofSeconds(30))
|
||||
.setReadTimeout(Duration.ofSeconds(60))
|
||||
.build();
|
||||
return switch (name) {
|
||||
case "gemini" -> new GeminiProvider(apiKey, resolvedModel, url, rt);
|
||||
case "openai" -> new OpenAIProvider(apiKey, resolvedModel, url, rt);
|
||||
default -> throw new IllegalArgumentException("Unknown LLM provider: " + name);
|
||||
};
|
||||
}
|
||||
|
||||
public List<String> getAvailableProviders() {
|
||||
return List.copyOf(providerConfigs.keySet());
|
||||
}
|
||||
|
||||
private record ProviderConfig(String model, String url) {}
|
||||
}
|
||||
@@ -55,17 +55,16 @@ indie:
|
||||
bucket: ${INDIE_STORAGE_BUCKET:indie-sites}
|
||||
public-url-base: ${INDIE_STORAGE_PUBLIC_URL:http://localhost:9000/indie-sites}
|
||||
|
||||
encryption:
|
||||
master-key-path: ${INDIE_ENCRYPTION_KEY_PATH:config/encryption.key}
|
||||
|
||||
llm:
|
||||
active-provider: ${INDIE_LLM_ACTIVE_PROVIDER:gemini}
|
||||
fallback-provider: ${INDIE_LLM_FALLBACK_PROVIDER:openai}
|
||||
rate-limit:
|
||||
max-requests-per-hour: ${INDIE_LLM_RATE_LIMIT:10}
|
||||
providers:
|
||||
gemini:
|
||||
api-key: ${GEMINI_API_KEY:}
|
||||
model: ${GEMINI_MODEL:gemini-3.1-flash-lite}
|
||||
url: https://generativelanguage.googleapis.com/v1beta/models
|
||||
openai:
|
||||
api-key: ${OPENAI_API_KEY:}
|
||||
model: ${OPENAI_MODEL:gpt-4o-mini}
|
||||
url: https://api.openai.com/v1/chat/completions
|
||||
|
||||
@@ -76,11 +76,12 @@ class LLMControllerTest {
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
when(llmService.generateSite(any(), any(), anyString(), any())).thenReturn(structure);
|
||||
when(llmService.generateSite(any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(structure);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
@@ -103,11 +104,12 @@ class LLMControllerTest {
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
when(llmService.generateSite(any(), any(), anyString(), any())).thenReturn(structure);
|
||||
when(llmService.generateSite(any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(structure);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"references", List.of(
|
||||
Map.of("type", "IMAGE", "url", "data:img", "altText", "Photo")
|
||||
)
|
||||
@@ -120,6 +122,34 @@ class LLMControllerTest {
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns200WithPerRequestApiKey() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.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(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"apiKey", "custom-key"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns429WhenRateLimited() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(false);
|
||||
@@ -127,7 +157,8 @@ class LLMControllerTest {
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
@@ -142,7 +173,8 @@ class LLMControllerTest {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
@@ -153,43 +185,12 @@ class LLMControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenPromptBlank() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", " ",
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenSiteIdMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenSiteIdBlank() throws Exception {
|
||||
void returns400WhenProviderMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", ""
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
@@ -198,6 +199,26 @@ class LLMControllerTest {
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenNoKeyConfigured() throws Exception {
|
||||
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"));
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("No API key configured for gemini"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -223,11 +244,12 @@ class LLMControllerTest {
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
when(llmService.refineSite(any(), any(), any(), anyString(), any())).thenReturn(refined);
|
||||
when(llmService.refineSite(any(), any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(refined);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Make it better",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"currentStructure", current
|
||||
);
|
||||
|
||||
@@ -247,6 +269,7 @@ class LLMControllerTest {
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"currentStructure", Map.of()
|
||||
);
|
||||
|
||||
@@ -258,10 +281,11 @@ class LLMControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenPromptMissing() throws Exception {
|
||||
void returns400WhenProviderMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"currentStructure", SiteStructure.builder().build()
|
||||
);
|
||||
@@ -272,38 +296,6 @@ class LLMControllerTest {
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenSiteIdMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"currentStructure", SiteStructure.builder().build()
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/refine")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenCurrentStructureMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/refine")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
@@ -3,8 +3,11 @@ package com.krrishg.service;
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.service.llm.LLMFactory;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.support.FakeEncryptionService;
|
||||
import com.krrishg.support.FakeLLMProvider;
|
||||
import com.krrishg.support.FakeProviderFactory;
|
||||
import com.krrishg.support.FakeUserRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -19,9 +22,9 @@ import org.springframework.data.mongodb.core.query.Query;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -37,11 +40,12 @@ class LLMServiceTest {
|
||||
@Mock
|
||||
private ResourceLoader resourceLoader;
|
||||
|
||||
private FakeLLMProvider activeProvider;
|
||||
private FakeLLMProvider fallbackProvider;
|
||||
private LLMFactory llmFactory;
|
||||
private FakeLLMProvider geminiProvider;
|
||||
private FakeProviderFactory providerFactory;
|
||||
private LLMResponseParser responseParser;
|
||||
private FakeReferenceService referenceService;
|
||||
private FakeUserRepository userRepository;
|
||||
private FakeEncryptionService encryptionService;
|
||||
private LLMService llmService;
|
||||
|
||||
private UUID userId;
|
||||
@@ -52,15 +56,22 @@ class LLMServiceTest {
|
||||
userId = UUID.randomUUID();
|
||||
siteId = UUID.randomUUID();
|
||||
|
||||
activeProvider = new FakeLLMProvider("gemini");
|
||||
fallbackProvider = new FakeLLMProvider("openai");
|
||||
llmFactory = new LLMFactory(List.of(activeProvider, fallbackProvider), "gemini", "openai");
|
||||
Method init = LLMFactory.class.getDeclaredMethod("init");
|
||||
init.setAccessible(true);
|
||||
init.invoke(llmFactory);
|
||||
geminiProvider = new FakeLLMProvider("gemini");
|
||||
providerFactory = new FakeProviderFactory()
|
||||
.withProvider("gemini", geminiProvider);
|
||||
|
||||
responseParser = new LLMResponseParser(new ObjectMapper());
|
||||
referenceService = new FakeReferenceService();
|
||||
userRepository = new FakeUserRepository();
|
||||
encryptionService = new FakeEncryptionService();
|
||||
|
||||
User user = User.builder()
|
||||
.id(userId)
|
||||
.email("user@test.com")
|
||||
.name("Test User")
|
||||
.llmApiKeys(Map.of("gemini", encryptionService.encrypt("test-key")))
|
||||
.build();
|
||||
userRepository.save(user);
|
||||
|
||||
Resource mockResource = mock(Resource.class);
|
||||
when(mockResource.exists()).thenReturn(true);
|
||||
@@ -68,14 +79,15 @@ class LLMServiceTest {
|
||||
new ByteArrayInputStream("You are a site generator.".getBytes(StandardCharsets.UTF_8)));
|
||||
when(resourceLoader.getResource(anyString())).thenReturn(mockResource);
|
||||
|
||||
llmService = new LLMService(llmFactory, responseParser, referenceService,
|
||||
mongoTemplate, resourceLoader, "classpath:prompts/site-generator.txt",
|
||||
llmService = new LLMService(providerFactory, responseParser, referenceService,
|
||||
mongoTemplate, userRepository, encryptionService,
|
||||
resourceLoader, "classpath:prompts/site-generator.txt",
|
||||
"classpath:prompts/site-refiner.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateSiteReturnsParsedStructure() {
|
||||
activeProvider.withContent("""
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"title": "Home", "slug": "home", "rawHtml": "<h1>Welcome</h1>"}
|
||||
@@ -85,7 +97,7 @@ class LLMServiceTest {
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
|
||||
SiteStructure result = llmService.generateSite(userId, siteId, "Build a site", null);
|
||||
SiteStructure result = llmService.generateSite(userId, siteId, "Build a site", null, "gemini", null);
|
||||
|
||||
assertEquals(1, result.getPages().size());
|
||||
assertEquals("Home", result.getPages().get(0).getTitle());
|
||||
@@ -94,7 +106,7 @@ class LLMServiceTest {
|
||||
|
||||
@Test
|
||||
void generateSiteSavesVersionWithCorrectData() {
|
||||
activeProvider.withContent("""
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"title": "About", "slug": "about", "rawHtml": "<p>About</p>"}
|
||||
@@ -104,7 +116,7 @@ class LLMServiceTest {
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
|
||||
llmService.generateSite(userId, siteId, "Build about page", null);
|
||||
llmService.generateSite(userId, siteId, "Build about page", null, "gemini", null);
|
||||
|
||||
ArgumentCaptor<GenerationVersion> captor = ArgumentCaptor.forClass(GenerationVersion.class);
|
||||
verify(mongoTemplate).save(captor.capture());
|
||||
@@ -119,7 +131,7 @@ class LLMServiceTest {
|
||||
|
||||
@Test
|
||||
void generateSiteIncrementsVersionNumber() {
|
||||
activeProvider.withContent("""
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"title": "Home", "slug": "home", "rawHtml": "<p>Hero</p>"}
|
||||
@@ -134,7 +146,7 @@ class LLMServiceTest {
|
||||
.build();
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(existing);
|
||||
|
||||
llmService.generateSite(userId, siteId, "Build a site", null);
|
||||
llmService.generateSite(userId, siteId, "Build a site", null, "gemini", null);
|
||||
|
||||
ArgumentCaptor<GenerationVersion> captor = ArgumentCaptor.forClass(GenerationVersion.class);
|
||||
verify(mongoTemplate).save(captor.capture());
|
||||
@@ -144,7 +156,7 @@ class LLMServiceTest {
|
||||
|
||||
@Test
|
||||
void generateSiteWithReferences() {
|
||||
activeProvider.withContent("""
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"title": "Home", "slug": "home", "rawHtml": "<p>Hero</p>"}
|
||||
@@ -158,7 +170,7 @@ class LLMServiceTest {
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
|
||||
llmService.generateSite(userId, siteId, "Build a site", references);
|
||||
llmService.generateSite(userId, siteId, "Build a site", references, "gemini", null);
|
||||
|
||||
ArgumentCaptor<GenerationVersion> captor = ArgumentCaptor.forClass(GenerationVersion.class);
|
||||
verify(mongoTemplate).save(captor.capture());
|
||||
@@ -169,31 +181,30 @@ class LLMServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateSiteFallsBackOnProviderFailure() {
|
||||
activeProvider.setFailure(new RuntimeException("API error"));
|
||||
fallbackProvider.withContent("""
|
||||
void generateSiteUsesProvidedApiKeyOverStored() {
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"title": "Fallback", "slug": "fallback", "rawHtml": "<p>Hero</p>"}
|
||||
{"title": "Home", "slug": "home", "rawHtml": "<p>Hero</p>"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
|
||||
SiteStructure result = llmService.generateSite(userId, siteId, "Build", null);
|
||||
llmService.generateSite(userId, siteId, "Build", null, "gemini", "override-key");
|
||||
|
||||
assertEquals("Fallback", result.getPages().get(0).getTitle());
|
||||
verify(mongoTemplate).save(any(GenerationVersion.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateSiteThrowsWhenAllProvidersFail() {
|
||||
activeProvider.setFailure(new RuntimeException("API error"));
|
||||
fallbackProvider.setFailure(new RuntimeException("Fallback also failed"));
|
||||
void generateSiteThrowsWhenNoKeyConfigured() {
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
user.setLlmApiKeys(null);
|
||||
userRepository.save(user);
|
||||
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> llmService.generateSite(userId, siteId, "Build", null));
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> llmService.generateSite(userId, siteId, "Build", null, "gemini", null));
|
||||
verify(mongoTemplate, never()).save(any());
|
||||
}
|
||||
|
||||
@@ -209,7 +220,7 @@ class LLMServiceTest {
|
||||
))
|
||||
.build();
|
||||
|
||||
activeProvider.withContent("""
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"title": "Refined", "slug": "refined", "rawHtml": "<p>Hero</p>"}
|
||||
@@ -219,7 +230,7 @@ class LLMServiceTest {
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
|
||||
SiteStructure result = llmService.refineSite(userId, siteId, current, "Make it better", null);
|
||||
SiteStructure result = llmService.refineSite(userId, siteId, current, "Make it better", null, "gemini", null);
|
||||
|
||||
assertEquals("Refined", result.getPages().get(0).getTitle());
|
||||
verify(mongoTemplate).save(any(GenerationVersion.class));
|
||||
|
||||
@@ -8,12 +8,10 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -34,25 +32,9 @@ class GeminiProviderTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RestTemplateBuilder builder = new RestTemplateBuilder()
|
||||
.setConnectTimeout(Duration.ofSeconds(1));
|
||||
try {
|
||||
var field = RestTemplateBuilder.class.getDeclaredField("interceptors");
|
||||
field.setAccessible(true);
|
||||
} catch (Exception e) {
|
||||
}
|
||||
|
||||
provider = new GeminiProvider("test-key", "gemini-2.0-flash",
|
||||
"https://generativelanguage.googleapis.com/v1beta/models",
|
||||
builder);
|
||||
|
||||
try {
|
||||
var restTemplateField = GeminiProvider.class.getDeclaredField("restTemplate");
|
||||
restTemplateField.setAccessible(true);
|
||||
restTemplateField.set(provider, restTemplate);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
restTemplate);
|
||||
|
||||
defaultOptions = LLMOptions.builder().build();
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package com.krrishg.service.llm;
|
||||
|
||||
import com.krrishg.support.FakeLLMProvider;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LLMFactoryTest {
|
||||
|
||||
private FakeLLMProvider gemini;
|
||||
private FakeLLMProvider openai;
|
||||
private List<LLMProvider> providers;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
gemini = new FakeLLMProvider("gemini");
|
||||
openai = new FakeLLMProvider("openai");
|
||||
providers = List.of(gemini, openai);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActiveProviderReturnsConfiguredProvider() {
|
||||
LLMFactory factory = new LLMFactory(providers, "gemini", "openai");
|
||||
factory.init();
|
||||
|
||||
LLMProvider active = factory.getActiveProvider();
|
||||
assertEquals("gemini", active.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActiveProviderThrowsWhenNotFound() {
|
||||
LLMFactory factory = new LLMFactory(providers, "nonexistent", "openai");
|
||||
factory.init();
|
||||
|
||||
assertThrows(IllegalStateException.class, factory::getActiveProvider);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActiveProviderThrowsWhenNoProviders() {
|
||||
LLMFactory factory = new LLMFactory(List.of(), "gemini", "openai");
|
||||
factory.init();
|
||||
|
||||
assertThrows(IllegalStateException.class, factory::getActiveProvider);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFallbackProviderReturnsConfiguredFallback() {
|
||||
LLMFactory factory = new LLMFactory(providers, "gemini", "openai");
|
||||
factory.init();
|
||||
|
||||
assertTrue(factory.getFallbackProvider().isPresent());
|
||||
assertEquals("openai", factory.getFallbackProvider().get().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFallbackProviderReturnsEmptyWhenNotFound() {
|
||||
LLMFactory factory = new LLMFactory(providers, "gemini", "nonexistent");
|
||||
factory.init();
|
||||
|
||||
assertTrue(factory.getFallbackProvider().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFallbackProviderReturnsEmptyWhenNotConfigured() {
|
||||
LLMFactory factory = new LLMFactory(providers, "gemini", "");
|
||||
factory.init();
|
||||
|
||||
assertTrue(factory.getFallbackProvider().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeAndFallbackCanBeSameProvider() {
|
||||
LLMFactory factory = new LLMFactory(providers, "gemini", "gemini");
|
||||
factory.init();
|
||||
|
||||
assertEquals("gemini", factory.getActiveProvider().getName());
|
||||
assertTrue(factory.getFallbackProvider().isPresent());
|
||||
assertEquals("gemini", factory.getFallbackProvider().get().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void initLogsAvailableProviders() {
|
||||
LLMFactory factory = new LLMFactory(providers, "gemini", "openai");
|
||||
assertDoesNotThrow(factory::init);
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,10 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -32,19 +30,8 @@ class OpenAIProviderTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RestTemplateBuilder builder = new RestTemplateBuilder()
|
||||
.setConnectTimeout(Duration.ofSeconds(1));
|
||||
|
||||
provider = new OpenAIProvider("sk-test", "gpt-4o-mini",
|
||||
"https://api.openai.com/v1/chat/completions", builder);
|
||||
|
||||
try {
|
||||
var field = OpenAIProvider.class.getDeclaredField("restTemplate");
|
||||
field.setAccessible(true);
|
||||
field.set(provider, restTemplate);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
"https://api.openai.com/v1/chat/completions", restTemplate);
|
||||
|
||||
defaultOptions = LLMOptions.builder().build();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.krrishg.support;
|
||||
|
||||
import com.krrishg.config.EncryptionService;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
public class FakeEncryptionService extends EncryptionService {
|
||||
|
||||
public FakeEncryptionService() {
|
||||
super("config/test-encryption.key");
|
||||
}
|
||||
|
||||
public void init() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String encrypt(String plaintext) {
|
||||
return Base64.getEncoder().encodeToString(plaintext.getBytes());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String decrypt(String encrypted) {
|
||||
return new String(Base64.getDecoder().decode(encrypted));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.krrishg.support;
|
||||
|
||||
import com.krrishg.service.llm.LLMProvider;
|
||||
import com.krrishg.service.llm.ProviderFactory;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class FakeProviderFactory extends ProviderFactory {
|
||||
|
||||
private final Map<String, FakeLLMProvider> providers = new HashMap<>();
|
||||
|
||||
public FakeProviderFactory() {
|
||||
super("gemini-2.0-flash", "https://generativelanguage.googleapis.com/v1beta/models",
|
||||
"gpt-4o-mini", "https://api.openai.com/v1/chat/completions",
|
||||
null);
|
||||
}
|
||||
|
||||
public FakeProviderFactory withProvider(String name, FakeLLMProvider provider) {
|
||||
providers.put(name, provider);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LLMProvider createProvider(String name, String apiKey) {
|
||||
return createProvider(name, apiKey, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LLMProvider createProvider(String name, String apiKey, String baseUrl) {
|
||||
return createProvider(name, apiKey, baseUrl, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LLMProvider createProvider(String name, String apiKey, String baseUrl, String model) {
|
||||
FakeLLMProvider p = providers.get(name);
|
||||
if (p == null) {
|
||||
throw new IllegalArgumentException("Unknown LLM provider: " + name
|
||||
+ ". Available: " + providers.keySet());
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getAvailableProviders() {
|
||||
return List.copyOf(providers.keySet());
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
export interface LLMGenerateRequest {
|
||||
prompt: string;
|
||||
siteId: string;
|
||||
provider: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
references?: Reference[];
|
||||
}
|
||||
|
||||
export interface LLMRefineRequest {
|
||||
prompt: string;
|
||||
siteId: string;
|
||||
provider: string;
|
||||
currentStructure: SiteStructure;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
references?: Reference[];
|
||||
}
|
||||
|
||||
@@ -64,3 +72,11 @@ export interface GenerationVersion {
|
||||
fullStructure: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LLMKeyEntry {
|
||||
configured: boolean;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export type LLMKeyStatus = Record<string, LLMKeyEntry>;
|
||||
|
||||
21
frontend/src/app/core/services/llm-config.service.ts
Normal file
21
frontend/src/app/core/services/llm-config.service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { LLMKeyStatus } from '../models/llm';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LlmConfigService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getStatus(): Observable<LLMKeyStatus> {
|
||||
return this.http.get<LLMKeyStatus>('/api/user/llm-config');
|
||||
}
|
||||
|
||||
saveKey(provider: string, apiKey?: string, baseUrl?: string, model?: string): Observable<void> {
|
||||
return this.http.put<void>('/api/user/llm-config', { provider, apiKey, baseUrl, model });
|
||||
}
|
||||
|
||||
deleteKey(provider: string): Observable<void> {
|
||||
return this.http.delete<void>(`/api/user/llm-config/${provider}`);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
@@ -16,8 +17,9 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { LlmApiService } from '../core/services/llm-api.service';
|
||||
import { LlmConfigService } from '../core/services/llm-config.service';
|
||||
import { LlmSessionService } from './llm-session.service';
|
||||
import { SiteStructure, GenerationVersion } from '../core/models/llm';
|
||||
import { SiteStructure, GenerationVersion, LLMKeyStatus } from '../core/models/llm';
|
||||
import { PreviewDialogComponent } from './preview-dialog/preview-dialog.component';
|
||||
import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dialog';
|
||||
|
||||
@@ -27,10 +29,14 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
imports: [
|
||||
NgIf, NgFor, AsyncPipe, KeyValuePipe, DatePipe, FormsModule, RouterModule,
|
||||
MatToolbarModule, MatButtonModule, MatIconModule,
|
||||
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule,
|
||||
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule, MatSelectModule,
|
||||
MatSnackBarModule, MatCardModule,
|
||||
MatDividerModule, MatExpansionModule, MatDialogModule,
|
||||
],
|
||||
styles: [`
|
||||
.provider-select { width: 140px; }
|
||||
.provider-select .mat-mdc-form-field-subscript-wrapper { display: none; }
|
||||
`],
|
||||
template: `
|
||||
<div class="h-screen flex flex-col bg-gray-50">
|
||||
<mat-toolbar color="primary" class="h-14 flex items-center justify-between px-4">
|
||||
@@ -45,6 +51,8 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
</button>
|
||||
</mat-toolbar>
|
||||
|
||||
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="px-6 py-6 space-y-6">
|
||||
|
||||
@@ -64,16 +72,30 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
<span class="text-xs text-gray-400">
|
||||
Describe the pages, layout, theme, and content you want.
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<mat-form-field appearance="outline" class="!mb-0 provider-select" *ngIf="configuredProviders.length > 1">
|
||||
<mat-label>Provider</mat-label>
|
||||
<mat-select [(ngModel)]="selectedProvider" class="text-sm">
|
||||
<mat-option *ngFor="let p of configuredProviders" [value]="p" class="capitalize">{{ p }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<span *ngIf="configuredProviders.length === 1" class="text-xs text-gray-500 capitalize mr-1">{{ configuredProviders[0] }}</span>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
[disabled]="!prompt.trim() || (session.generating$ | async)"
|
||||
[disabled]="!prompt.trim() || (session.generating$ | async) || configuredProviders.length === 0"
|
||||
(click)="generate()"
|
||||
>
|
||||
<mat-icon>auto_awesome</mat-icon>
|
||||
Generate
|
||||
</button>
|
||||
</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">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@@ -180,7 +202,14 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
[disabled]="(session.generating$ | async) === true"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
<div class="flex justify-end mt-2">
|
||||
<div class="flex justify-end items-center gap-2 mt-2">
|
||||
<mat-form-field appearance="outline" class="!mb-0 provider-select" *ngIf="configuredProviders.length > 1">
|
||||
<mat-label>Provider</mat-label>
|
||||
<mat-select [(ngModel)]="selectedProvider" class="text-sm">
|
||||
<mat-option *ngFor="let p of configuredProviders" [value]="p" class="capitalize">{{ p }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<span *ngIf="configuredProviders.length === 1" class="text-xs text-gray-500 capitalize mr-1">{{ configuredProviders[0] }}</span>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="accent"
|
||||
@@ -261,6 +290,7 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private api = inject(LlmApiService);
|
||||
private llmConfigService = inject(LlmConfigService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
session = inject(LlmSessionService);
|
||||
@@ -270,6 +300,11 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
showRefine = false;
|
||||
siteId: string | null = null;
|
||||
versions: GenerationVersion[] = [];
|
||||
llmKeyStatus: LLMKeyStatus = {};
|
||||
llmBaseUrls: Record<string, string> = {};
|
||||
llmModels: Record<string, string> = {};
|
||||
configuredProviders: string[] = [];
|
||||
selectedProvider = '';
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -285,10 +320,36 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
this.versions = [];
|
||||
this.session.clearHistory();
|
||||
this.loadVersions();
|
||||
this.loadLlmConfig();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadLlmConfig(): void {
|
||||
this.llmConfigService.getStatus()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (status) => {
|
||||
this.llmKeyStatus = status;
|
||||
const urls: Record<string, string> = {};
|
||||
const models: Record<string, string> = {};
|
||||
this.configuredProviders = Object.keys(status).filter(k => {
|
||||
if (status[k]?.baseUrl) urls[k] = status[k].baseUrl!;
|
||||
if (status[k]?.model) models[k] = status[k].model!;
|
||||
return status[k]?.configured;
|
||||
});
|
||||
this.llmBaseUrls = urls;
|
||||
this.llmModels = models;
|
||||
if (this.configuredProviders.length === 1) {
|
||||
this.selectedProvider = this.configuredProviders[0];
|
||||
} else if (this.configuredProviders.length > 1 && !this.selectedProvider) {
|
||||
this.selectedProvider = this.configuredProviders[0];
|
||||
}
|
||||
},
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
private loadVersions(): void {
|
||||
if (!this.siteId) return;
|
||||
this.session.setLoadingVersions(true);
|
||||
@@ -320,13 +381,21 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
|
||||
|
||||
generate(): void {
|
||||
if (!this.prompt.trim() || !this.siteId) return;
|
||||
if (!this.prompt.trim() || !this.siteId || !this.selectedProvider) return;
|
||||
this.showRefine = false;
|
||||
this.session.setPrompt(this.prompt);
|
||||
this.session.setGenerating(true);
|
||||
this.session.clearError();
|
||||
|
||||
this.api.generate({ prompt: this.prompt, siteId: this.siteId })
|
||||
const baseUrl = this.llmBaseUrls[this.selectedProvider];
|
||||
const model = this.llmModels[this.selectedProvider];
|
||||
this.api.generate({
|
||||
prompt: this.prompt,
|
||||
siteId: this.siteId,
|
||||
provider: this.selectedProvider,
|
||||
baseUrl,
|
||||
model,
|
||||
})
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (data) => {
|
||||
@@ -344,13 +413,18 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
|
||||
refine(): void {
|
||||
const result = this.session.currentResult;
|
||||
if (!result || !this.refinePrompt.trim() || !this.siteId) return;
|
||||
if (!result || !this.refinePrompt.trim() || !this.siteId || !this.selectedProvider) return;
|
||||
this.session.setGenerating(true);
|
||||
this.session.clearError();
|
||||
|
||||
const baseUrl = this.llmBaseUrls[this.selectedProvider];
|
||||
const model = this.llmModels[this.selectedProvider];
|
||||
this.api.refine({
|
||||
prompt: this.refinePrompt,
|
||||
siteId: this.siteId,
|
||||
provider: this.selectedProvider,
|
||||
baseUrl,
|
||||
model,
|
||||
currentStructure: result,
|
||||
})
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
|
||||
@@ -17,6 +17,8 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { SiteService } from '../../core/services/site.service';
|
||||
import { DeploymentService } from '../../core/services/deployment.service';
|
||||
import { LlmConfigService } from '../../core/services/llm-config.service';
|
||||
import { LLMKeyEntry, LLMKeyStatus } from '../../core/models/llm';
|
||||
import { SelfHostedConfigResponse } from '../../core/models/deployment';
|
||||
|
||||
@Component({
|
||||
@@ -224,6 +226,65 @@ import { SelfHostedConfigResponse } from '../../core/models/deployment';
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card 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">LLM API Keys</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Add API keys for AI providers to enable site generation. Keys are encrypted at rest.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div *ngFor="let provider of llmProviders" class="border rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full shrink-0"
|
||||
[class.bg-green-500]="llmKeyStatus[provider]?.configured"
|
||||
[class.bg-gray-300]="!llmKeyStatus[provider]?.configured"></span>
|
||||
<span class="text-sm font-medium capitalize">{{ provider }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button mat-stroked-button color="warn" *ngIf="llmKeyStatus[provider]?.configured"
|
||||
(click)="deleteLlmKey(provider)" class="!text-xs !min-w-0 !px-2">
|
||||
<mat-icon>delete</mat-icon>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>API Key</mat-label>
|
||||
<input matInput [type]="llmKeyVisible[provider] ? 'text' : 'password'"
|
||||
[(ngModel)]="llmNewKeys[provider]" [disabled]="llmSaving[provider]" />
|
||||
<button mat-icon-button matSuffix (click)="toggleKeyVisible(provider)"
|
||||
[attr.aria-label]="llmKeyVisible[provider] ? 'Hide key' : 'Show key'">
|
||||
<mat-icon>{{ llmKeyVisible[provider] ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="w-full !mb-4" *ngIf="provider === 'openai'">
|
||||
<mat-label>Base URL (optional)</mat-label>
|
||||
<input matInput
|
||||
placeholder="https://api.deepseek.com/v1"
|
||||
[(ngModel)]="llmNewBaseUrls[provider]" [disabled]="llmSaving[provider]" />
|
||||
<mat-hint>For OpenAI-compatible providers like DeepSeek. Leave empty for OpenAI.</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Model (optional)</mat-label>
|
||||
<input matInput
|
||||
placeholder="{{ provider === 'gemini' ? 'gemini-3.1-flash-lite' : 'gpt-4o-mini' }}"
|
||||
[(ngModel)]="llmNewModels[provider]" [disabled]="llmSaving[provider]" />
|
||||
<mat-hint>Override the default model name for this provider.</mat-hint>
|
||||
</mat-form-field>
|
||||
<div class="flex justify-end">
|
||||
<button mat-flat-button color="primary"
|
||||
[disabled]="(!llmNewKeys[provider] || !llmNewKeys[provider].trim()) && (!llmNewBaseUrls[provider] || !llmNewBaseUrls[provider].trim()) && (!llmNewModels[provider] || !llmNewModels[provider].trim()) || llmSaving[provider]"
|
||||
(click)="saveLlmKey(provider)">
|
||||
<mat-icon>save</mat-icon>
|
||||
{{ llmSaving[provider] ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-4" appearance="outlined">
|
||||
<mat-card-content class="!p-0">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -249,6 +310,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
private router = inject(Router);
|
||||
private siteService = inject(SiteService);
|
||||
private deploymentService = inject(DeploymentService);
|
||||
private llmConfigService = inject(LlmConfigService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
siteId: string | null = null;
|
||||
@@ -278,6 +340,14 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
shDeployedAt: string | null = null;
|
||||
shSaving = false;
|
||||
|
||||
llmProviders = ['gemini', 'openai'];
|
||||
llmKeyStatus: LLMKeyStatus = {};
|
||||
llmNewKeys: Record<string, string> = {};
|
||||
llmNewBaseUrls: Record<string, string> = {};
|
||||
llmNewModels: Record<string, string> = {};
|
||||
llmSaving: Record<string, boolean> = {};
|
||||
llmKeyVisible: Record<string, boolean> = {};
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -285,7 +355,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(params => {
|
||||
this.siteId = params.get('siteId');
|
||||
if (this.siteId) this.loadSite();
|
||||
if (this.siteId) {
|
||||
this.loadSite();
|
||||
this.loadLlmConfig();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -345,6 +418,73 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private loadLlmConfig(): void {
|
||||
this.llmConfigService.getStatus()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (status) => {
|
||||
this.llmKeyStatus = status;
|
||||
for (const p of this.llmProviders) {
|
||||
if (status[p]?.baseUrl) {
|
||||
this.llmNewBaseUrls[p] = status[p].baseUrl!;
|
||||
}
|
||||
if (status[p]?.model) {
|
||||
this.llmNewModels[p] = status[p].model!;
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
saveLlmKey(provider: string): void {
|
||||
const key = this.llmNewKeys[provider]?.trim();
|
||||
const baseUrl = this.llmNewBaseUrls[provider]?.trim() || undefined;
|
||||
const model = this.llmNewModels[provider]?.trim() || undefined;
|
||||
if (!key && !baseUrl && !model) return;
|
||||
this.llmSaving[provider] = true;
|
||||
this.llmConfigService.saveKey(provider, key, baseUrl, model)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.llmSaving[provider] = false;
|
||||
const entry: LLMKeyEntry = { configured: true };
|
||||
if (baseUrl) entry.baseUrl = baseUrl;
|
||||
if (model) entry.model = model;
|
||||
this.llmKeyStatus[provider] = entry;
|
||||
this.llmNewKeys[provider] = '';
|
||||
this.snackBar.open(`${provider} config saved`, 'Close', { duration: 2000 });
|
||||
},
|
||||
error: () => {
|
||||
this.llmSaving[provider] = false;
|
||||
this.snackBar.open(`Failed to save ${provider} API key`, 'Close', { duration: 3000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteLlmKey(provider: string): void {
|
||||
this.llmSaving[provider] = true;
|
||||
this.llmConfigService.deleteKey(provider)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.llmSaving[provider] = false;
|
||||
this.llmKeyStatus[provider] = { configured: false };
|
||||
this.llmNewBaseUrls[provider] = '';
|
||||
this.llmNewModels[provider] = '';
|
||||
this.snackBar.open(`${provider} API key removed`, 'Close', { duration: 2000 });
|
||||
},
|
||||
error: () => {
|
||||
this.llmSaving[provider] = false;
|
||||
this.snackBar.open(`Failed to remove ${provider} API key`, 'Close', { duration: 3000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleKeyVisible(provider: string): void {
|
||||
this.llmKeyVisible[provider] = !this.llmKeyVisible[provider];
|
||||
}
|
||||
|
||||
onDeploymentTargetChange(): void {
|
||||
if (!this.siteId) return;
|
||||
this.saving = true;
|
||||
|
||||
Reference in New Issue
Block a user