generated from fahricansecer/boilerplate-be
509 lines
16 KiB
TypeScript
509 lines
16 KiB
TypeScript
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { GoogleGenAI } from '@google/genai';
|
||
|
||
export interface GeminiGenerateOptions {
|
||
model?: string;
|
||
systemPrompt?: string;
|
||
temperature?: number;
|
||
maxTokens?: number;
|
||
}
|
||
|
||
export interface GeminiChatMessage {
|
||
role: 'user' | 'model';
|
||
content: string;
|
||
}
|
||
|
||
/**
|
||
* Gemini AI Service
|
||
*
|
||
* Provides AI-powered text generation using Google Gemini API.
|
||
* This service is globally available when ENABLE_GEMINI=true.
|
||
*
|
||
* @example
|
||
* ```typescript
|
||
* // Simple text generation
|
||
* const response = await geminiService.generateText('Write a poem about coding');
|
||
*
|
||
* // With options
|
||
* const response = await geminiService.generateText('Translate to Turkish', {
|
||
* temperature: 0.7,
|
||
* systemPrompt: 'You are a professional translator',
|
||
* });
|
||
*
|
||
* // Chat conversation
|
||
* const messages = [
|
||
* { role: 'user', content: 'Hello!' },
|
||
* { role: 'model', content: 'Hi there!' },
|
||
* { role: 'user', content: 'What is 2+2?' },
|
||
* ];
|
||
* const response = await geminiService.chat(messages);
|
||
* ```
|
||
*/
|
||
@Injectable()
|
||
export class GeminiService implements OnModuleInit {
|
||
private readonly logger = new Logger(GeminiService.name);
|
||
private client: GoogleGenAI | null = null;
|
||
private isEnabled = false;
|
||
private defaultModel: string;
|
||
|
||
constructor(private readonly configService: ConfigService) {
|
||
this.isEnabled = this.configService.get<boolean>('gemini.enabled', false);
|
||
this.defaultModel = this.configService.get<string>(
|
||
'gemini.defaultModel',
|
||
'gemini-2.5-flash',
|
||
);
|
||
}
|
||
|
||
onModuleInit() {
|
||
if (!this.isEnabled) {
|
||
this.logger.log(
|
||
'Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.',
|
||
);
|
||
return;
|
||
}
|
||
|
||
const apiKey = this.configService.get<string>('gemini.apiKey');
|
||
if (!apiKey) {
|
||
this.logger.warn(
|
||
'GOOGLE_API_KEY is not set. Gemini features will not work.',
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
this.client = new GoogleGenAI({ apiKey });
|
||
this.logger.log('✅ Gemini AI initialized successfully');
|
||
} catch (error) {
|
||
this.logger.error('Failed to initialize Gemini AI', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if Gemini is available and properly configured
|
||
*/
|
||
isAvailable(): boolean {
|
||
return this.isEnabled && this.client !== null;
|
||
}
|
||
|
||
/**
|
||
* Generate text content from a prompt
|
||
*
|
||
* @param prompt - The text prompt to send to the AI
|
||
* @param options - Optional configuration for the generation
|
||
* @returns Generated text response
|
||
*/
|
||
async generateText(
|
||
prompt: string,
|
||
options: GeminiGenerateOptions = {},
|
||
): Promise<{ text: string; usage?: any }> {
|
||
if (!this.isAvailable()) {
|
||
throw new Error('Gemini AI is not available. Check your configuration.');
|
||
}
|
||
|
||
const model = options.model || this.defaultModel;
|
||
|
||
try {
|
||
const contents: any[] = [];
|
||
|
||
// Add system prompt if provided
|
||
if (options.systemPrompt) {
|
||
contents.push({
|
||
role: 'user',
|
||
parts: [{ text: options.systemPrompt }],
|
||
});
|
||
contents.push({
|
||
role: 'model',
|
||
parts: [{ text: 'Understood. I will follow these instructions.' }],
|
||
});
|
||
}
|
||
|
||
contents.push({
|
||
role: 'user',
|
||
parts: [{ text: prompt }],
|
||
});
|
||
|
||
const response = await this.client!.models.generateContent({
|
||
model,
|
||
contents,
|
||
config: {
|
||
temperature: options.temperature,
|
||
maxOutputTokens: options.maxTokens,
|
||
},
|
||
});
|
||
|
||
return {
|
||
text: (response.text || '').trim(),
|
||
usage: response.usageMetadata,
|
||
};
|
||
} catch (error) {
|
||
this.logger.error('Gemini generation failed', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Have a multi-turn chat conversation
|
||
*
|
||
* @param messages - Array of chat messages
|
||
* @param options - Optional configuration for the generation
|
||
* @returns Generated text response
|
||
*/
|
||
async chat(
|
||
messages: GeminiChatMessage[],
|
||
options: GeminiGenerateOptions = {},
|
||
): Promise<{ text: string; usage?: any }> {
|
||
if (!this.isAvailable()) {
|
||
throw new Error('Gemini AI is not available. Check your configuration.');
|
||
}
|
||
|
||
const model = options.model || this.defaultModel;
|
||
|
||
try {
|
||
const contents = messages.map((msg) => ({
|
||
role: msg.role,
|
||
parts: [{ text: msg.content }],
|
||
}));
|
||
|
||
// Prepend system prompt if provided
|
||
if (options.systemPrompt) {
|
||
contents.unshift(
|
||
{
|
||
role: 'user',
|
||
parts: [{ text: options.systemPrompt }],
|
||
},
|
||
{
|
||
role: 'model',
|
||
parts: [{ text: 'Understood. I will follow these instructions.' }],
|
||
},
|
||
);
|
||
}
|
||
|
||
const response = await this.client!.models.generateContent({
|
||
model,
|
||
contents,
|
||
config: {
|
||
temperature: options.temperature,
|
||
maxOutputTokens: options.maxTokens,
|
||
},
|
||
});
|
||
|
||
return {
|
||
text: (response.text || '').trim(),
|
||
usage: response.usageMetadata,
|
||
};
|
||
} catch (error) {
|
||
this.logger.error('Gemini chat failed', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate structured JSON output
|
||
*
|
||
* @param prompt - The prompt describing what JSON to generate
|
||
* @param schema - JSON schema description for the expected output
|
||
* @param options - Optional configuration for the generation
|
||
* @returns Parsed JSON object
|
||
*/
|
||
async generateJSON<T = any>(
|
||
prompt: string,
|
||
schema: string,
|
||
options: GeminiGenerateOptions = {},
|
||
): Promise<{ data: T; usage?: any }> {
|
||
const fullPrompt = `${prompt}
|
||
|
||
Output the result as valid JSON that matches this schema:
|
||
${schema}
|
||
|
||
IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||
|
||
const response = await this.generateText(fullPrompt, options);
|
||
|
||
try {
|
||
// Try to extract JSON from the response
|
||
let jsonStr = response.text;
|
||
|
||
// Remove potential markdown code blocks
|
||
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||
if (jsonMatch) {
|
||
jsonStr = jsonMatch[1].trim();
|
||
}
|
||
|
||
const data = JSON.parse(jsonStr) as T;
|
||
return { data, usage: response.usage };
|
||
} catch (error) {
|
||
this.logger.error('Failed to parse JSON response', error);
|
||
throw new Error('Failed to parse AI response as JSON');
|
||
}
|
||
}
|
||
|
||
// ── Görsel Üretim (Gemini Image Generation) ─────────────────────────
|
||
|
||
/**
|
||
* Gemini Image Generation API ile görsel üret.
|
||
* 3 katmanlı fallback mimarisi:
|
||
* 1) gemini-2.5-flash-image (Nano Banana — hızlı, stabil)
|
||
* 2) gemini-3.1-flash-image-preview (Nano Banana 2 — en yeni)
|
||
* 3) Imagen 4 Fast (generateImages API)
|
||
*
|
||
* Raspberry Pi 5 bellek koruması için buffer olarak döner.
|
||
*
|
||
* @param prompt - İngilizce görsel açıklaması
|
||
* @param aspectRatio - Görsel en-boy oranı (16:9, 9:16, 1:1)
|
||
* @returns Base64 decoded image buffer ve mime type
|
||
*/
|
||
async generateImage(
|
||
prompt: string,
|
||
aspectRatio: '16:9' | '9:16' | '1:1' = '16:9',
|
||
isIllustration: boolean = false,
|
||
forceImagen: boolean = false,
|
||
): Promise<{ buffer: Buffer; mimeType: string } | null> {
|
||
if (!this.isAvailable()) {
|
||
throw new Error('Gemini AI is not available. Check your configuration.');
|
||
}
|
||
|
||
// Güncel model sıralaması (Nisan 2026):
|
||
// - gemini-2.5-flash-image: Nano Banana — stabil, hızlı
|
||
// - gemini-3.1-flash-image-preview: Nano Banana 2 — en yeni, yüksek kalite
|
||
const primaryModel = 'gemini-2.5-flash-image';
|
||
const fallbackModel = 'gemini-3.1-flash-image-preview';
|
||
|
||
try {
|
||
this.logger.log(
|
||
`🎨 Görsel üretiliyor: "${prompt.substring(0, 100)}..." [${aspectRatio}]`,
|
||
);
|
||
|
||
// En-boy oranına göre yönlendirmeyi zorla
|
||
const orientation =
|
||
aspectRatio === '9:16'
|
||
? '(VERTICAL / PORTRAIT)'
|
||
: aspectRatio === '16:9'
|
||
? '(HORIZONTAL / LANDSCAPE)'
|
||
: '(SQUARE)';
|
||
// Gemini modelleri ana konunun (subject) prompt'un en başında olmasını tercih eder.
|
||
// Jenerik stil kelimelerini sonuna ekliyoruz ki ana konu (prompt) kaybolmasın.
|
||
const enhancedPrompt = isIllustration
|
||
? `SUBJECT: ${prompt}\n\nINSTRUCTIONS: Generate a premium digital illustration of the EXACT SUBJECT described above. Make it highly detailed and vivid, but NOT photorealistic. Aspect ratio: ${aspectRatio} ${orientation}. DO NOT deviate from the SUBJECT.`
|
||
: `SUBJECT: ${prompt}\n\nINSTRUCTIONS: Generate a high-quality photorealistic cinematic image of the EXACT SUBJECT described above. Use professional lighting and make it highly detailed. Aspect ratio: ${aspectRatio} ${orientation}. DO NOT deviate from the SUBJECT.`;
|
||
|
||
// ── Katman 1: gemini-2.5-flash-image (Nano Banana) — 2 deneme ──
|
||
if (!forceImagen) {
|
||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||
try {
|
||
this.logger.log(`🔄 Katman 1 (deneme ${attempt}/2): ${primaryModel}`);
|
||
const result = await this.tryGenerateContentImage(
|
||
primaryModel,
|
||
enhancedPrompt,
|
||
);
|
||
if (result && result.buffer.length > 0) {
|
||
this.logger.log(
|
||
`✅ Görsel üretildi (${primaryModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`,
|
||
);
|
||
return { buffer: result.buffer, mimeType: result.mimeType };
|
||
}
|
||
|
||
const reason = result?.errorReason || 'null response';
|
||
this.logger.warn(
|
||
`⚠️ ${primaryModel} deneme ${attempt}: görsel döndürmedi (${reason})`,
|
||
);
|
||
|
||
if (
|
||
['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(reason)
|
||
) {
|
||
this.logger.warn(
|
||
`🚫 Güvenlik/Politika filtresi tetiklendi (${reason}). Denemeler iptal ediliyor.`,
|
||
);
|
||
break; // Fail fast for safety blocks
|
||
}
|
||
|
||
if (attempt < 2) await this.sleep(2000);
|
||
} catch (err1: any) {
|
||
this.logger.warn(
|
||
`⚠️ ${primaryModel} deneme ${attempt} hata: ${err1.message?.substring(0, 200)}`,
|
||
);
|
||
if (attempt < 2) await this.sleep(2000);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Katman 2: gemini-3.1-flash-image-preview (Nano Banana 2) ──
|
||
if (!forceImagen) {
|
||
try {
|
||
this.logger.log(`🔄 Katman 2: ${fallbackModel}`);
|
||
const result = await this.tryGenerateContentImage(
|
||
fallbackModel,
|
||
enhancedPrompt,
|
||
);
|
||
if (result && result.buffer.length > 0) {
|
||
this.logger.log(
|
||
`✅ Görsel üretildi (${fallbackModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`,
|
||
);
|
||
return { buffer: result.buffer, mimeType: result.mimeType };
|
||
}
|
||
this.logger.warn(
|
||
`⚠️ ${fallbackModel}: görsel döndürmedi (${result?.errorReason || 'null response'})`,
|
||
);
|
||
|
||
if (
|
||
['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(
|
||
result?.errorReason || '',
|
||
)
|
||
) {
|
||
this.logger.warn(
|
||
`🚫 Katman 2 Güvenlik/Politika filtresi tetiklendi. Katman 3'e geçiliyor.`,
|
||
);
|
||
}
|
||
} catch (err2: any) {
|
||
this.logger.warn(
|
||
`⚠️ ${fallbackModel} hata: ${err2.message?.substring(0, 200)}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Katman 3: Imagen 4 Fast (generateImages API) ──
|
||
try {
|
||
this.logger.log(`🔄 Katman 3: Imagen 4 Fast`);
|
||
const response = await this.client!.models.generateImages({
|
||
model: 'imagen-4.0-fast-generate-001',
|
||
prompt: enhancedPrompt,
|
||
config: {
|
||
numberOfImages: 1,
|
||
aspectRatio: aspectRatio,
|
||
outputMimeType: 'image/jpeg',
|
||
},
|
||
});
|
||
|
||
if (response.generatedImages?.[0]?.image?.imageBytes) {
|
||
const buffer = Buffer.from(
|
||
response.generatedImages[0].image.imageBytes,
|
||
'base64',
|
||
);
|
||
const mimeType = 'image/jpeg';
|
||
this.logger.log(
|
||
`✅ Görsel üretildi (Imagen 4): ${(buffer.length / 1024).toFixed(1)} KB`,
|
||
);
|
||
return { buffer, mimeType };
|
||
}
|
||
this.logger.warn(
|
||
`⚠️ Imagen 4: görsel döndürmedi. Üretilen görsel sayısı: ${response.generatedImages?.length || 0}`,
|
||
);
|
||
} catch (err3: any) {
|
||
this.logger.warn(
|
||
`⚠️ Imagen 4 hata: ${err3.message?.substring(0, 200)}`,
|
||
);
|
||
}
|
||
|
||
this.logger.error('❌ Tüm görsel üretim katmanları başarısız oldu');
|
||
return null;
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Gemini görsel üretim hatası: ${error instanceof Error ? error.message : error}`,
|
||
);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* generateContent API ile görsel üretim denemesi.
|
||
* responseModalities: ['IMAGE', 'TEXT'] kullanarak inlineData içinden resim çıkarır.
|
||
*/
|
||
private async tryGenerateContentImage(
|
||
model: string,
|
||
prompt: string,
|
||
): Promise<{
|
||
buffer: Buffer;
|
||
mimeType: string;
|
||
errorReason?: string;
|
||
} | null> {
|
||
const response = await this.client!.models.generateContent({
|
||
model,
|
||
contents: prompt,
|
||
config: {
|
||
responseModalities: ['IMAGE', 'TEXT'],
|
||
},
|
||
});
|
||
|
||
const candidate = response.candidates?.[0];
|
||
|
||
// Safety filter veya boş yanıt kontrolü
|
||
if (!candidate?.content?.parts || candidate.content.parts.length === 0) {
|
||
const finishReason = candidate?.finishReason || 'UNKNOWN';
|
||
this.logger.warn(
|
||
`⚠️ ${model}: boş yanıt (finishReason: ${finishReason})`,
|
||
);
|
||
return {
|
||
buffer: Buffer.from([]),
|
||
mimeType: '',
|
||
errorReason: finishReason,
|
||
};
|
||
}
|
||
|
||
const imagePart = candidate.content.parts.find((p: any) =>
|
||
p.inlineData?.mimeType?.startsWith('image/'),
|
||
);
|
||
|
||
if (imagePart?.inlineData?.data) {
|
||
const buffer = Buffer.from(imagePart.inlineData.data, 'base64');
|
||
const mimeType = imagePart.inlineData.mimeType || 'image/png';
|
||
return { buffer, mimeType };
|
||
}
|
||
|
||
// Text-only response geldi (görsel yok)
|
||
const textParts = candidate.content.parts.filter((p: any) => p.text);
|
||
if (textParts.length > 0) {
|
||
this.logger.warn(
|
||
`⚠️ ${model}: sadece text döndü, görsel yok. Text: "${textParts[0].text?.substring(0, 100)}"`,
|
||
);
|
||
return {
|
||
buffer: Buffer.from([]),
|
||
mimeType: '',
|
||
errorReason: 'TEXT_ONLY',
|
||
};
|
||
}
|
||
|
||
return {
|
||
buffer: Buffer.from([]),
|
||
mimeType: '',
|
||
errorReason: 'NO_IMAGE_DATA',
|
||
};
|
||
}
|
||
|
||
/** Basit uyku fonksiyonu — retry aralarında kullanılır */
|
||
private sleep(ms: number): Promise<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
/**
|
||
* Sahne bazlı görsel üret — visualPrompt ve video stili kullanarak.
|
||
*
|
||
* @param visualPrompt - Sahnenin İngilizce görsel açıklaması
|
||
* @param style - Video stili (cinematic, documentary, educational vb.)
|
||
* @param aspectRatio - En-boy oranı
|
||
* @returns Buffer ve mimeType
|
||
*/
|
||
async generateImageForScene(
|
||
visualPrompt: string,
|
||
style: string = 'cinematic',
|
||
aspectRatio: '16:9' | '9:16' | '1:1' = '16:9',
|
||
): Promise<{ buffer: Buffer; mimeType: string } | null> {
|
||
const enhancedPrompt = `${visualPrompt}. Style: ${style}, professional production quality, volumetric lighting, sharp details, 8K resolution.`;
|
||
return this.generateImage(enhancedPrompt, aspectRatio);
|
||
}
|
||
|
||
/**
|
||
* Video için thumbnail görsel üret — proje başlığı ve açıklamasından.
|
||
*
|
||
* @param title - Video başlığı
|
||
* @param description - Video açıklaması
|
||
* @returns Buffer ve mimeType
|
||
*/
|
||
async generateThumbnail(
|
||
title: string,
|
||
description: string,
|
||
): Promise<{ buffer: Buffer; mimeType: string } | null> {
|
||
const prompt = `Create a compelling YouTube video thumbnail for a video titled "${title}". ${description}. Make it eye-catching with bold, dynamic composition. No text overlay needed.`;
|
||
return this.generateImage(prompt, '16:9');
|
||
}
|
||
}
|