diff --git a/pom.xml b/pom.xml index f426642..7ba95c6 100644 --- a/pom.xml +++ b/pom.xml @@ -42,8 +42,12 @@ spring-boot-starter-web - - + + com.google.auth + google-auth-library-oauth2-http + 1.23.0 + + org.springframework.boot spring-boot-starter-data-mongodb diff --git a/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java b/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java index dfe5599..97d130c 100644 --- a/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java @@ -1,5 +1,8 @@ package kz.konturai.parser.service; +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.GoogleCredentials; +import io.netty.resolver.DefaultAddressResolverGroup; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -9,10 +12,11 @@ import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.ExchangeStrategies; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; -import reactor.core.publisher.Mono; import reactor.netty.http.client.HttpClient; import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import java.io.IOException; +import java.net.URI; import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; @@ -24,15 +28,19 @@ import java.util.concurrent.Semaphore; public class NanoBananaImageGenerationService implements ImageGenerationService { private static final Logger logger = LoggerFactory.getLogger(NanoBananaImageGenerationService.class); - // Базовый URL остался, но эндпоинты могут отличаться - private static final String GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models/"; + + // Шаблон URL для Vertex AI (не Generative Language!) + private static final String VERTEX_API_TEMPLATE = "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predict"; private final Semaphore semaphore = new Semaphore(1); private volatile long lastRequestTimestamp = 0; private final WebClient webClient; - @Value("${google.gemini.api.key:}") - private String apiKey; + @Value("${google.cloud.project-id}") + private String projectId; + + @Value("${google.cloud.location:us-central1}") + private String location; @Value("${google.gemini.image.model:imagen-3.0-generate-001}") private String model; @@ -48,14 +56,15 @@ public class NanoBananaImageGenerationService implements ImageGenerationService public NanoBananaImageGenerationService() { HttpClient httpClient = HttpClient.create() + .resolver(DefaultAddressResolverGroup.INSTANCE) .responseTimeout(Duration.ofMillis(120000)); + // Увеличиваем буфер памяти для приема больших картинок (Base64) ExchangeStrategies strategies = ExchangeStrategies.builder() - .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(15 * 1024 * 1024)) // Увеличим до 15MB + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(20 * 1024 * 1024)) // 20MB .build(); this.webClient = WebClient.builder() - .baseUrl(GEMINI_API_BASE_URL) .clientConnector(new ReactorClientHttpConnector(httpClient)) .exchangeStrategies(strategies) .build(); @@ -63,8 +72,8 @@ public class NanoBananaImageGenerationService implements ImageGenerationService @Override public byte[] generateImage(String prompt) { - if (apiKey == null || apiKey.trim().isEmpty()) { - logger.error("API Key is missing!"); + if (projectId == null || projectId.trim().isEmpty()) { + logger.error("Project ID is missing! Check application.properties"); return null; } @@ -76,41 +85,63 @@ public class NanoBananaImageGenerationService implements ImageGenerationService Thread.sleep(rateLimitDelayMs - timeSinceLast); } + String accessToken = getAccessToken(); + if (accessToken == null) { + logger.error("Failed to get Access Token. Check 'gcloud auth login' or JSON key file."); + return null; + } + + // 2. Формируем URL для Vertex AI + // Пример: https://us-central1-aiplatform.googleapis.com/... + String endpointUrl = String.format(VERTEX_API_TEMPLATE, location, projectId, location, model); + String enrichedPrompt = enrichPromptForGemini(prompt); - - String endpoint = model + ":predict"; - Map requestBody = buildImagenRequestBody(enrichedPrompt); - logger.info("Sending request to Imagen model: {}", model); + logger.info("Sending request to Vertex AI. Project: {}, Model: {}", projectId, model); + // 3. Выполняем POST запрос Map response = webClient.post() - .uri(uriBuilder -> uriBuilder - .path(endpoint) - .queryParam("key", apiKey) - .build()) + .uri(URI.create(endpointUrl)) + .header("Authorization", "Bearer " + accessToken) // Авторизация через токен .contentType(MediaType.APPLICATION_JSON) .bodyValue(requestBody) .retrieve() .bodyToMono(new ParameterizedTypeReference>() {}) - .retryWhen(createRetrySpecFor429("generateImage")) - .doOnError(e -> logger.error("API Error: {}", e.getMessage())) + .retryWhen(createRetrySpecFor429()) + .doOnError(e -> { + logger.error("Vertex AI API Error: {}", e.getMessage()); + if (e instanceof WebClientResponseException) { + logger.error("Response Body: {}", ((WebClientResponseException) e).getResponseBodyAsString()); + } + }) .block(Duration.ofMillis(timeoutMs)); lastRequestTimestamp = System.currentTimeMillis(); return extractImageFromImagenResponse(response); } catch (Exception e) { - logger.error("Fatal error in NanoBanana: {}", e.getMessage(), e); + logger.error("Fatal error in NanoBanana (Vertex AI): {}", e.getMessage(), e); return null; } finally { semaphore.release(); } } - /** - * Специфичный Request Body для модели Imagen - */ + + private String getAccessToken() { + try { + GoogleCredentials credentials = GoogleCredentials.getApplicationDefault() + .createScoped("https://www.googleapis.com/auth/cloud-platform"); + credentials.refreshIfExpired(); + AccessToken token = credentials.getAccessToken(); + return token.getTokenValue(); + } catch (IOException e) { + logger.error("Error obtaining Google Credentials", e); + return null; + } + } + private Map buildImagenRequestBody(String prompt) { Map body = new HashMap<>(); @@ -118,13 +149,11 @@ public class NanoBananaImageGenerationService implements ImageGenerationService Map instance = new HashMap<>(); instance.put("prompt", prompt); instances.add(instance); - body.put("instances", instances); Map parameters = new HashMap<>(); - parameters.put("sampleCount", 1); // Генерируем 1 картинку - parameters.put("aspectRatio", "1:1"); // Можно вынести в настройки - // parameters.put("personGeneration", "allow_adult"); // Если нужно (зависит от прав API ключа) + parameters.put("sampleCount", 1); // Количество картинок + parameters.put("aspectRatio", "1:1"); // Пропорции body.put("parameters", parameters); return body; @@ -139,9 +168,12 @@ public class NanoBananaImageGenerationService implements ImageGenerationService if (predictions != null && !predictions.isEmpty()) { Map firstPrediction = predictions.get(0); + + // Imagen на Vertex AI обычно отдает поле "bytesBase64Encoded" String base64Image = (String) firstPrediction.get("bytesBase64Encoded"); if (base64Image == null) { + // Резервный вариант, если формат API изменится base64Image = (String) firstPrediction.get("b64"); } @@ -149,14 +181,14 @@ public class NanoBananaImageGenerationService implements ImageGenerationService return java.util.Base64.getDecoder().decode(base64Image); } } - logger.warn("No 'predictions' or base64 data found in response: {}", response); + logger.warn("No image data found in Vertex AI response. Response: {}", response); } catch (Exception e) { logger.error("Parsing error: {}", e.getMessage()); } return null; } - private reactor.util.retry.RetryBackoffSpec createRetrySpecFor429(String operation) { + private reactor.util.retry.RetryBackoffSpec createRetrySpecFor429() { return reactor.util.retry.Retry.backoff(maxRetryAttempts, Duration.ofSeconds(5)) .maxBackoff(Duration.ofSeconds(30)) .filter(t -> t instanceof WebClientResponseException && @@ -164,6 +196,6 @@ public class NanoBananaImageGenerationService implements ImageGenerationService } private String enrichPromptForGemini(String original) { - return "High quality, photorealistic image of: " + original; + return "high quality, photorealistic image of: " + original; } } \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ea95693..3d1cab1 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -94,13 +94,14 @@ openai.image.style=natural # Set to 'nano-banana' to use Google Gemini Nano Banana for image generation image.generation.provider=nano-banana -# Google Gemini Nano Banana Image Generation Configuration -# Get API key from: https://aistudio.google.com/ -google.gemini.api.key=AIzaSyDMhORdwYaqlXUDP--mmxXUWozFxMAp2hY -google.gemini.image.model=imagegeneration@006 +google.cloud.project-id=onyx-yeti-456518-d4 +google.cloud.location=us-central1 + +google.gemini.image.model=imagen-3.0-generate-001 + google.gemini.timeoutMs=120000 google.gemini.retry.maxAttempts=3 -google.gemini.retry.initialDelayMs=10000 +\google.gemini.retry.initialDelayMs=10000 google.gemini.retry.maxDelayMs=120000 google.gemini.retry.multiplier=2.0 google.gemini.rateLimit.delayMs=12000