@umituz/react-native-ai-gemini-provider 1.8.3 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.ts +6 -1
- package/src/infrastructure/services/gemini-client-core.service.ts +83 -0
- package/src/infrastructure/services/gemini-image-edit.service.ts +134 -0
- package/src/infrastructure/services/gemini-image-generation.service.ts +113 -0
- package/src/infrastructure/services/gemini-provider.service.ts +13 -17
- package/src/infrastructure/services/gemini-retry.service.ts +63 -0
- package/src/infrastructure/services/gemini-streaming.service.ts +61 -0
- package/src/infrastructure/services/gemini-text-generation.service.ts +164 -0
- package/src/infrastructure/services/index.ts +9 -1
- package/src/infrastructure/utils/gemini-data-transformer.util.ts +42 -0
- package/src/infrastructure/utils/index.ts +5 -0
- package/src/presentation/hooks/use-gemini.ts +4 -4
- package/src/infrastructure/services/gemini-client.service.ts +0 -548
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -56,7 +56,12 @@ export type { ResponseModality } from "./domain/entities";
|
|
|
56
56
|
// =============================================================================
|
|
57
57
|
|
|
58
58
|
export {
|
|
59
|
-
|
|
59
|
+
geminiClientCoreService,
|
|
60
|
+
geminiRetryService,
|
|
61
|
+
geminiTextGenerationService,
|
|
62
|
+
geminiImageGenerationService,
|
|
63
|
+
geminiImageEditService,
|
|
64
|
+
geminiStreamingService,
|
|
60
65
|
geminiProviderService,
|
|
61
66
|
createGeminiProvider,
|
|
62
67
|
} from "./infrastructure/services";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Client Core Service
|
|
3
|
+
* Handles client initialization, configuration, and validation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { GoogleGenerativeAI, type GenerativeModel } from "@google/generative-ai";
|
|
7
|
+
import { DEFAULT_MODELS } from "../../domain/entities";
|
|
8
|
+
import type { GeminiConfig } from "../../domain/entities";
|
|
9
|
+
|
|
10
|
+
declare const __DEV__: boolean;
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CONFIG: Partial<GeminiConfig> = {
|
|
13
|
+
maxRetries: 3,
|
|
14
|
+
baseDelay: 1000,
|
|
15
|
+
maxDelay: 10000,
|
|
16
|
+
defaultTimeoutMs: 60000,
|
|
17
|
+
defaultModel: DEFAULT_MODELS.TEXT,
|
|
18
|
+
imageModel: DEFAULT_MODELS.TEXT_TO_IMAGE,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
class GeminiClientCoreService {
|
|
22
|
+
private client: GoogleGenerativeAI | null = null;
|
|
23
|
+
private config: GeminiConfig | null = null;
|
|
24
|
+
private initialized = false;
|
|
25
|
+
|
|
26
|
+
initialize(config: GeminiConfig): void {
|
|
27
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
28
|
+
// eslint-disable-next-line no-console
|
|
29
|
+
console.log("[GeminiClient] initialize() called", {
|
|
30
|
+
hasApiKey: !!config.apiKey,
|
|
31
|
+
defaultModel: config.defaultModel,
|
|
32
|
+
imageModel: config.imageModel,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.client = new GoogleGenerativeAI(config.apiKey);
|
|
37
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
38
|
+
this.initialized = true;
|
|
39
|
+
|
|
40
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
41
|
+
// eslint-disable-next-line no-console
|
|
42
|
+
console.log("[GeminiClient] initialized successfully", {
|
|
43
|
+
defaultModel: this.config.defaultModel,
|
|
44
|
+
imageModel: this.config.imageModel,
|
|
45
|
+
maxRetries: this.config.maxRetries,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
isInitialized(): boolean {
|
|
51
|
+
return this.initialized;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getConfig(): GeminiConfig | null {
|
|
55
|
+
return this.config;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getClient(): GoogleGenerativeAI | null {
|
|
59
|
+
return this.client;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
validateInitialization(): void {
|
|
63
|
+
if (!this.client || !this.initialized) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
"Gemini client not initialized. Call initialize() first.",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getModel(modelName?: string): GenerativeModel {
|
|
71
|
+
this.validateInitialization();
|
|
72
|
+
const effectiveModel = modelName || this.config?.defaultModel || "gemini-1.5-flash";
|
|
73
|
+
return this.client!.getGenerativeModel({ model: effectiveModel });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
reset(): void {
|
|
77
|
+
this.client = null;
|
|
78
|
+
this.config = null;
|
|
79
|
+
this.initialized = false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const geminiClientCoreService = new GeminiClientCoreService();
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Image Edit Service
|
|
3
|
+
* Handles image editing/transformation using Gemini API
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { geminiClientCoreService } from "./gemini-client-core.service";
|
|
7
|
+
import { geminiRetryService } from "./gemini-retry.service";
|
|
8
|
+
import { extractBase64Data } from "../utils/gemini-data-transformer.util";
|
|
9
|
+
import { DEFAULT_MODELS } from "../../domain/entities";
|
|
10
|
+
import type { GeminiImageGenerationResult } from "../../domain/entities";
|
|
11
|
+
|
|
12
|
+
declare const __DEV__: boolean;
|
|
13
|
+
|
|
14
|
+
interface GeminiContentResponse {
|
|
15
|
+
candidates?: Array<{
|
|
16
|
+
content?: {
|
|
17
|
+
parts?: Array<{
|
|
18
|
+
text?: string;
|
|
19
|
+
inlineData?: {
|
|
20
|
+
data?: string;
|
|
21
|
+
mimeType?: string;
|
|
22
|
+
};
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class GeminiImageEditService {
|
|
29
|
+
/**
|
|
30
|
+
* Edit/transform image using Gemini generateContent API
|
|
31
|
+
* Takes input image + prompt and generates new image
|
|
32
|
+
*/
|
|
33
|
+
async editImage(
|
|
34
|
+
prompt: string,
|
|
35
|
+
images: Array<{ base64: string; mimeType: string }>,
|
|
36
|
+
): Promise<GeminiImageGenerationResult> {
|
|
37
|
+
geminiClientCoreService.validateInitialization();
|
|
38
|
+
|
|
39
|
+
const config = geminiClientCoreService.getConfig();
|
|
40
|
+
const editModel = DEFAULT_MODELS.IMAGE_EDIT;
|
|
41
|
+
const apiKey = config?.apiKey;
|
|
42
|
+
|
|
43
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
44
|
+
// eslint-disable-next-line no-console
|
|
45
|
+
console.log("[GeminiClient] editImage() called", {
|
|
46
|
+
model: editModel,
|
|
47
|
+
promptLength: prompt.length,
|
|
48
|
+
imagesCount: images.length,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${editModel}:generateContent`;
|
|
53
|
+
|
|
54
|
+
const parts: Array<Record<string, unknown>> = [];
|
|
55
|
+
|
|
56
|
+
for (const image of images) {
|
|
57
|
+
parts.push({
|
|
58
|
+
inlineData: {
|
|
59
|
+
mimeType: image.mimeType,
|
|
60
|
+
data: extractBase64Data(image.base64),
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
parts.push({ text: prompt });
|
|
66
|
+
|
|
67
|
+
const requestBody = {
|
|
68
|
+
contents: [{ parts }],
|
|
69
|
+
generationConfig: {
|
|
70
|
+
responseModalities: ["TEXT", "IMAGE"],
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
75
|
+
// eslint-disable-next-line no-console
|
|
76
|
+
console.log("[GeminiClient] editImage() request", {
|
|
77
|
+
url,
|
|
78
|
+
partsCount: parts.length,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const response = await geminiRetryService.executeWithRetry(async () => {
|
|
83
|
+
const res = await fetch(url, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
"x-goog-api-key": apiKey!,
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify(requestBody),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
const errorText = await res.text();
|
|
94
|
+
throw new Error(`Image edit API error (${res.status}): ${errorText}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return res.json() as Promise<GeminiContentResponse>;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const result: GeminiImageGenerationResult = {
|
|
101
|
+
text: undefined,
|
|
102
|
+
imageUrl: undefined,
|
|
103
|
+
imageBase64: undefined,
|
|
104
|
+
mimeType: "image/png",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const candidate = response.candidates?.[0];
|
|
108
|
+
const responseParts = candidate?.content?.parts || [];
|
|
109
|
+
|
|
110
|
+
for (const part of responseParts) {
|
|
111
|
+
if (part.text) {
|
|
112
|
+
result.text = part.text;
|
|
113
|
+
}
|
|
114
|
+
if (part.inlineData) {
|
|
115
|
+
result.imageBase64 = part.inlineData.data;
|
|
116
|
+
result.mimeType = part.inlineData.mimeType || "image/png";
|
|
117
|
+
result.imageUrl = `data:${result.mimeType};base64,${result.imageBase64}`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
122
|
+
// eslint-disable-next-line no-console
|
|
123
|
+
console.log("[GeminiClient] editImage() completed", {
|
|
124
|
+
hasImage: !!result.imageBase64,
|
|
125
|
+
hasText: !!result.text,
|
|
126
|
+
imageDataLength: result.imageBase64?.length ?? 0,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const geminiImageEditService = new GeminiImageEditService();
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Image Generation Service
|
|
3
|
+
* Handles image generation using Imagen API
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { geminiClientCoreService } from "./gemini-client-core.service";
|
|
7
|
+
import { geminiRetryService } from "./gemini-retry.service";
|
|
8
|
+
import { DEFAULT_MODELS } from "../../domain/entities";
|
|
9
|
+
import type {
|
|
10
|
+
GeminiGenerationConfig,
|
|
11
|
+
GeminiImageGenerationResult,
|
|
12
|
+
} from "../../domain/entities";
|
|
13
|
+
|
|
14
|
+
declare const __DEV__: boolean;
|
|
15
|
+
|
|
16
|
+
interface ImagenApiResponse {
|
|
17
|
+
generatedImages?: Array<{
|
|
18
|
+
image?: {
|
|
19
|
+
imageBytes?: string;
|
|
20
|
+
};
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class GeminiImageGenerationService {
|
|
25
|
+
/**
|
|
26
|
+
* Generate image from prompt using Imagen API
|
|
27
|
+
* Uses REST API endpoint: /v1beta/models/{model}:predict
|
|
28
|
+
*/
|
|
29
|
+
async generateImage(
|
|
30
|
+
prompt: string,
|
|
31
|
+
_images?: Array<{ base64: string; mimeType: string }>,
|
|
32
|
+
_config?: GeminiGenerationConfig,
|
|
33
|
+
): Promise<GeminiImageGenerationResult> {
|
|
34
|
+
geminiClientCoreService.validateInitialization();
|
|
35
|
+
|
|
36
|
+
const config = geminiClientCoreService.getConfig();
|
|
37
|
+
const imageModel = config?.imageModel || DEFAULT_MODELS.TEXT_TO_IMAGE;
|
|
38
|
+
const apiKey = config?.apiKey;
|
|
39
|
+
|
|
40
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
41
|
+
// eslint-disable-next-line no-console
|
|
42
|
+
console.log("[GeminiClient] generateImage() called (Imagen API)", {
|
|
43
|
+
model: imageModel,
|
|
44
|
+
promptLength: prompt.length,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${imageModel}:predict`;
|
|
49
|
+
|
|
50
|
+
const requestBody = {
|
|
51
|
+
instances: [{ prompt }],
|
|
52
|
+
parameters: {
|
|
53
|
+
sampleCount: 1,
|
|
54
|
+
aspectRatio: "1:1",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
59
|
+
// eslint-disable-next-line no-console
|
|
60
|
+
console.log("[GeminiClient] Imagen API request", {
|
|
61
|
+
url,
|
|
62
|
+
prompt: prompt.substring(0, 100) + "...",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const response = await geminiRetryService.executeWithRetry(async () => {
|
|
67
|
+
const res = await fetch(url, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: {
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
"x-goog-api-key": apiKey!,
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify(requestBody),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
const errorText = await res.text();
|
|
78
|
+
throw new Error(`Imagen API error (${res.status}): ${errorText}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return res.json() as Promise<ImagenApiResponse>;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const result: GeminiImageGenerationResult = {
|
|
85
|
+
text: undefined,
|
|
86
|
+
imageUrl: undefined,
|
|
87
|
+
imageBase64: undefined,
|
|
88
|
+
mimeType: "image/png",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (response.generatedImages && response.generatedImages.length > 0) {
|
|
92
|
+
const generatedImage = response.generatedImages[0];
|
|
93
|
+
const imageBytes = generatedImage.image?.imageBytes;
|
|
94
|
+
|
|
95
|
+
if (imageBytes) {
|
|
96
|
+
result.imageBase64 = imageBytes;
|
|
97
|
+
result.imageUrl = `data:image/png;base64,${imageBytes}`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
102
|
+
// eslint-disable-next-line no-console
|
|
103
|
+
console.log("[GeminiClient] generateImage() completed (Imagen)", {
|
|
104
|
+
hasImage: !!result.imageBase64,
|
|
105
|
+
imageDataLength: result.imageBase64?.length ?? 0,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const geminiImageGenerationService = new GeminiImageGenerationService();
|
|
@@ -9,18 +9,14 @@ import type {
|
|
|
9
9
|
GeminiImageInput,
|
|
10
10
|
GeminiImageGenerationResult,
|
|
11
11
|
} from "../../domain/entities";
|
|
12
|
-
import {
|
|
12
|
+
import { geminiClientCoreService } from "./gemini-client-core.service";
|
|
13
|
+
import { geminiTextGenerationService } from "./gemini-text-generation.service";
|
|
14
|
+
import { geminiImageGenerationService } from "./gemini-image-generation.service";
|
|
15
|
+
import { geminiImageEditService } from "./gemini-image-edit.service";
|
|
16
|
+
import { extractBase64Data } from "../utils/gemini-data-transformer.util";
|
|
13
17
|
|
|
14
18
|
declare const __DEV__: boolean;
|
|
15
19
|
|
|
16
|
-
function extractBase64Data(base64String: string): string {
|
|
17
|
-
if (!base64String.includes(",")) {
|
|
18
|
-
return base64String;
|
|
19
|
-
}
|
|
20
|
-
const parts = base64String.split(",");
|
|
21
|
-
return parts[1] ?? parts[0] ?? base64String;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
20
|
export interface AIProviderConfig {
|
|
25
21
|
apiKey: string;
|
|
26
22
|
maxRetries?: number;
|
|
@@ -84,7 +80,7 @@ class GeminiProviderService {
|
|
|
84
80
|
imageModel: config.imageModel,
|
|
85
81
|
};
|
|
86
82
|
|
|
87
|
-
|
|
83
|
+
geminiClientCoreService.initialize(geminiConfig);
|
|
88
84
|
|
|
89
85
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
90
86
|
// eslint-disable-next-line no-console
|
|
@@ -93,7 +89,7 @@ class GeminiProviderService {
|
|
|
93
89
|
}
|
|
94
90
|
|
|
95
91
|
isInitialized(): boolean {
|
|
96
|
-
return
|
|
92
|
+
return geminiClientCoreService.isInitialized();
|
|
97
93
|
}
|
|
98
94
|
|
|
99
95
|
submitJob(
|
|
@@ -207,7 +203,7 @@ class GeminiProviderService {
|
|
|
207
203
|
async generateImage(
|
|
208
204
|
prompt: string,
|
|
209
205
|
): Promise<GeminiImageGenerationResult> {
|
|
210
|
-
return
|
|
206
|
+
return geminiImageGenerationService.generateImage(prompt);
|
|
211
207
|
}
|
|
212
208
|
|
|
213
209
|
/**
|
|
@@ -218,7 +214,7 @@ class GeminiProviderService {
|
|
|
218
214
|
prompt: string,
|
|
219
215
|
images: GeminiImageInput[],
|
|
220
216
|
): Promise<GeminiImageGenerationResult> {
|
|
221
|
-
return
|
|
217
|
+
return geminiImageEditService.editImage(prompt, images);
|
|
222
218
|
}
|
|
223
219
|
|
|
224
220
|
/**
|
|
@@ -238,7 +234,7 @@ class GeminiProviderService {
|
|
|
238
234
|
});
|
|
239
235
|
}
|
|
240
236
|
|
|
241
|
-
const response = await
|
|
237
|
+
const response = await geminiTextGenerationService.generateWithImages(
|
|
242
238
|
model,
|
|
243
239
|
prompt,
|
|
244
240
|
images,
|
|
@@ -261,7 +257,7 @@ class GeminiProviderService {
|
|
|
261
257
|
}
|
|
262
258
|
|
|
263
259
|
reset(): void {
|
|
264
|
-
|
|
260
|
+
geminiClientCoreService.reset();
|
|
265
261
|
this.pendingJobs.clear();
|
|
266
262
|
this.jobCounter = 0;
|
|
267
263
|
}
|
|
@@ -306,12 +302,12 @@ class GeminiProviderService {
|
|
|
306
302
|
if (isImageGeneration) {
|
|
307
303
|
const prompt = String(input.prompt || "");
|
|
308
304
|
const images = input.images as GeminiImageInput[] | undefined;
|
|
309
|
-
const result = await
|
|
305
|
+
const result = await geminiImageGenerationService.generateImage(prompt, images);
|
|
310
306
|
return result as T;
|
|
311
307
|
}
|
|
312
308
|
|
|
313
309
|
const contents = this.buildContents(input);
|
|
314
|
-
const response = await
|
|
310
|
+
const response = await geminiTextGenerationService.generateContent(
|
|
315
311
|
model,
|
|
316
312
|
contents,
|
|
317
313
|
input.generationConfig as undefined,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Retry Service
|
|
3
|
+
* Handles retry logic with exponential backoff
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { geminiClientCoreService } from "./gemini-client-core.service";
|
|
7
|
+
|
|
8
|
+
declare const __DEV__: boolean;
|
|
9
|
+
|
|
10
|
+
const RETRYABLE_ERROR_PATTERNS = [
|
|
11
|
+
"rate limit",
|
|
12
|
+
"too many requests",
|
|
13
|
+
"429",
|
|
14
|
+
"500",
|
|
15
|
+
"502",
|
|
16
|
+
"503",
|
|
17
|
+
"504",
|
|
18
|
+
"timeout",
|
|
19
|
+
"network",
|
|
20
|
+
"econnrefused",
|
|
21
|
+
"fetch failed",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function isRetryableError(error: unknown): boolean {
|
|
25
|
+
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
26
|
+
return RETRYABLE_ERROR_PATTERNS.some((pattern) => message.includes(pattern));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sleep(ms: number): Promise<void> {
|
|
30
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class GeminiRetryService {
|
|
34
|
+
async executeWithRetry<T>(
|
|
35
|
+
operation: () => Promise<T>,
|
|
36
|
+
retryCount = 0,
|
|
37
|
+
): Promise<T> {
|
|
38
|
+
const config = geminiClientCoreService.getConfig();
|
|
39
|
+
const maxRetries = config?.maxRetries ?? 3;
|
|
40
|
+
const baseDelay = config?.baseDelay ?? 1000;
|
|
41
|
+
const maxDelay = config?.maxDelay ?? 10000;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
return await operation();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!isRetryableError(error) || retryCount >= maxRetries) {
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const delay = Math.min(baseDelay * Math.pow(2, retryCount), maxDelay);
|
|
51
|
+
|
|
52
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
53
|
+
// eslint-disable-next-line no-console
|
|
54
|
+
console.log(`[Gemini] Retry ${retryCount + 1}/${maxRetries} after ${delay}ms`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await sleep(delay);
|
|
58
|
+
return this.executeWithRetry(operation, retryCount + 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const geminiRetryService = new GeminiRetryService();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Streaming Service
|
|
3
|
+
* Handles streaming content generation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { geminiClientCoreService } from "./gemini-client-core.service";
|
|
7
|
+
import type {
|
|
8
|
+
GeminiContent,
|
|
9
|
+
GeminiGenerationConfig,
|
|
10
|
+
} from "../../domain/entities";
|
|
11
|
+
|
|
12
|
+
class GeminiStreamingService {
|
|
13
|
+
/**
|
|
14
|
+
* Stream content generation
|
|
15
|
+
*/
|
|
16
|
+
async streamContent(
|
|
17
|
+
model: string,
|
|
18
|
+
contents: GeminiContent[],
|
|
19
|
+
onChunk: (text: string) => void,
|
|
20
|
+
generationConfig?: GeminiGenerationConfig,
|
|
21
|
+
): Promise<string> {
|
|
22
|
+
const genModel = geminiClientCoreService.getModel(model);
|
|
23
|
+
|
|
24
|
+
const sdkContents = contents.map((content) => ({
|
|
25
|
+
role: content.role || "user",
|
|
26
|
+
parts: content.parts.map((part) => {
|
|
27
|
+
if ("text" in part) {
|
|
28
|
+
return { text: part.text };
|
|
29
|
+
}
|
|
30
|
+
if ("inlineData" in part) {
|
|
31
|
+
return {
|
|
32
|
+
inlineData: {
|
|
33
|
+
mimeType: part.inlineData.mimeType,
|
|
34
|
+
data: part.inlineData.data,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return part;
|
|
39
|
+
}),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const result = await genModel.generateContentStream({
|
|
43
|
+
contents: sdkContents as Parameters<typeof genModel.generateContentStream>[0] extends { contents: infer C } ? C : never,
|
|
44
|
+
generationConfig,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
let fullText = "";
|
|
48
|
+
|
|
49
|
+
for await (const chunk of result.stream) {
|
|
50
|
+
const chunkText = chunk.text();
|
|
51
|
+
if (chunkText) {
|
|
52
|
+
fullText += chunkText;
|
|
53
|
+
onChunk(chunkText);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return fullText;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const geminiStreamingService = new GeminiStreamingService();
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Text Generation Service
|
|
3
|
+
* Handles text and multimodal content generation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { geminiClientCoreService } from "./gemini-client-core.service";
|
|
7
|
+
import { geminiRetryService } from "./gemini-retry.service";
|
|
8
|
+
import { extractBase64Data, extractTextFromResponse } from "../utils/gemini-data-transformer.util";
|
|
9
|
+
import type {
|
|
10
|
+
GeminiContent,
|
|
11
|
+
GeminiGenerationConfig,
|
|
12
|
+
GeminiResponse,
|
|
13
|
+
GeminiPart,
|
|
14
|
+
GeminiFinishReason,
|
|
15
|
+
} from "../../domain/entities";
|
|
16
|
+
|
|
17
|
+
declare const __DEV__: boolean;
|
|
18
|
+
|
|
19
|
+
class GeminiTextGenerationService {
|
|
20
|
+
/**
|
|
21
|
+
* Generate content (text, with optional images)
|
|
22
|
+
*/
|
|
23
|
+
async generateContent(
|
|
24
|
+
model: string,
|
|
25
|
+
contents: GeminiContent[],
|
|
26
|
+
generationConfig?: GeminiGenerationConfig,
|
|
27
|
+
): Promise<GeminiResponse> {
|
|
28
|
+
const genModel = geminiClientCoreService.getModel(model);
|
|
29
|
+
|
|
30
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
31
|
+
// eslint-disable-next-line no-console
|
|
32
|
+
console.log("[Gemini] Generate content:", { model });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sdkContents = contents.map((content) => ({
|
|
36
|
+
role: content.role || "user",
|
|
37
|
+
parts: content.parts.map((part) => {
|
|
38
|
+
if ("text" in part) {
|
|
39
|
+
return { text: part.text };
|
|
40
|
+
}
|
|
41
|
+
if ("inlineData" in part) {
|
|
42
|
+
return {
|
|
43
|
+
inlineData: {
|
|
44
|
+
mimeType: part.inlineData.mimeType,
|
|
45
|
+
data: part.inlineData.data,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return part;
|
|
50
|
+
}),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const result = await geminiRetryService.executeWithRetry(() =>
|
|
55
|
+
genModel.generateContent({
|
|
56
|
+
contents: sdkContents as Parameters<typeof genModel.generateContent>[0] extends { contents: infer C } ? C : never,
|
|
57
|
+
generationConfig,
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const response = result.response;
|
|
62
|
+
|
|
63
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
64
|
+
// eslint-disable-next-line no-console
|
|
65
|
+
console.log("[Gemini] Content generated:", {
|
|
66
|
+
candidatesCount: response.candidates?.length ?? 0,
|
|
67
|
+
finishReason: response.candidates?.[0]?.finishReason,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
candidates: response.candidates?.map((candidate) => ({
|
|
73
|
+
content: {
|
|
74
|
+
parts: candidate.content.parts
|
|
75
|
+
.map((part): GeminiPart | null => {
|
|
76
|
+
if ("text" in part && part.text !== undefined) {
|
|
77
|
+
return { text: part.text };
|
|
78
|
+
}
|
|
79
|
+
if ("inlineData" in part && part.inlineData) {
|
|
80
|
+
return {
|
|
81
|
+
inlineData: {
|
|
82
|
+
mimeType: part.inlineData.mimeType,
|
|
83
|
+
data: part.inlineData.data,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
})
|
|
89
|
+
.filter((p): p is GeminiPart => p !== null),
|
|
90
|
+
role: (candidate.content.role || "model") as "user" | "model",
|
|
91
|
+
},
|
|
92
|
+
finishReason: candidate.finishReason as GeminiFinishReason | undefined,
|
|
93
|
+
})),
|
|
94
|
+
};
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
97
|
+
// eslint-disable-next-line no-console
|
|
98
|
+
console.error("[Gemini] Content generation failed:", {
|
|
99
|
+
model,
|
|
100
|
+
error: error instanceof Error ? error.message : String(error),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Generate text from prompt
|
|
109
|
+
*/
|
|
110
|
+
async generateText(
|
|
111
|
+
model: string,
|
|
112
|
+
prompt: string,
|
|
113
|
+
config?: GeminiGenerationConfig,
|
|
114
|
+
): Promise<string> {
|
|
115
|
+
const contents: GeminiContent[] = [
|
|
116
|
+
{ parts: [{ text: prompt }], role: "user" },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const response = await this.generateContent(model, contents, config);
|
|
120
|
+
return extractTextFromResponse(response);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Generate content with images (multimodal)
|
|
125
|
+
*/
|
|
126
|
+
async generateWithImages(
|
|
127
|
+
model: string,
|
|
128
|
+
prompt: string,
|
|
129
|
+
images: Array<{ base64: string; mimeType: string }>,
|
|
130
|
+
config?: GeminiGenerationConfig,
|
|
131
|
+
): Promise<GeminiResponse> {
|
|
132
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
133
|
+
// eslint-disable-next-line no-console
|
|
134
|
+
console.log("[GeminiClient] generateWithImages() called", {
|
|
135
|
+
model,
|
|
136
|
+
promptLength: prompt.length,
|
|
137
|
+
imagesCount: images.length,
|
|
138
|
+
imageMimeTypes: images.map(i => i.mimeType),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const parts: GeminiContent["parts"] = [{ text: prompt }];
|
|
143
|
+
|
|
144
|
+
for (const image of images) {
|
|
145
|
+
parts.push({
|
|
146
|
+
inlineData: {
|
|
147
|
+
mimeType: image.mimeType,
|
|
148
|
+
data: extractBase64Data(image.base64),
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const contents: GeminiContent[] = [{ parts, role: "user" }];
|
|
154
|
+
|
|
155
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
156
|
+
// eslint-disable-next-line no-console
|
|
157
|
+
console.log("[GeminiClient] generateWithImages() → calling generateContent()");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return this.generateContent(model, contents, config);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const geminiTextGenerationService = new GeminiTextGenerationService();
|
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
* Infrastructure Services
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Core services
|
|
6
|
+
export { geminiClientCoreService } from "./gemini-client-core.service";
|
|
7
|
+
export { geminiRetryService } from "./gemini-retry.service";
|
|
8
|
+
export { geminiTextGenerationService } from "./gemini-text-generation.service";
|
|
9
|
+
export { geminiImageGenerationService } from "./gemini-image-generation.service";
|
|
10
|
+
export { geminiImageEditService } from "./gemini-image-edit.service";
|
|
11
|
+
export { geminiStreamingService } from "./gemini-streaming.service";
|
|
12
|
+
|
|
13
|
+
// Public provider API
|
|
6
14
|
export {
|
|
7
15
|
geminiProviderService,
|
|
8
16
|
createGeminiProvider,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Data Transformer Utility
|
|
3
|
+
* Handles data extraction and response parsing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { GeminiResponse } from "../../domain/entities";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract base64 data from data URL or return as-is
|
|
10
|
+
*/
|
|
11
|
+
export function extractBase64Data(base64String: string): string {
|
|
12
|
+
if (!base64String.includes(",")) {
|
|
13
|
+
return base64String;
|
|
14
|
+
}
|
|
15
|
+
const parts = base64String.split(",");
|
|
16
|
+
return parts[1] ?? parts[0] ?? base64String;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract text from Gemini response
|
|
21
|
+
*/
|
|
22
|
+
export function extractTextFromResponse(response: GeminiResponse): string {
|
|
23
|
+
const candidate = response.candidates?.[0];
|
|
24
|
+
|
|
25
|
+
if (!candidate) {
|
|
26
|
+
throw new Error("No response candidates");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (candidate.finishReason === "SAFETY") {
|
|
30
|
+
throw new Error("Content blocked by safety filters");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const textPart = candidate.content.parts.find(
|
|
34
|
+
(p): p is { text: string } => "text" in p && typeof p.text === "string",
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (!textPart) {
|
|
38
|
+
throw new Error("No text in response");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return textPart.text;
|
|
42
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { useState, useCallback, useRef } from "react";
|
|
7
7
|
import type { GeminiGenerationConfig } from "../../domain/entities";
|
|
8
|
-
import {
|
|
8
|
+
import { geminiTextGenerationService } from "../../infrastructure/services";
|
|
9
9
|
|
|
10
10
|
export interface UseGeminiOptions {
|
|
11
11
|
model?: string;
|
|
@@ -43,7 +43,7 @@ export function useGemini(options: UseGeminiOptions = {}): UseGeminiReturn {
|
|
|
43
43
|
|
|
44
44
|
try {
|
|
45
45
|
const model = options.model ?? "gemini-1.5-flash";
|
|
46
|
-
const text = await
|
|
46
|
+
const text = await geminiTextGenerationService.generateText(
|
|
47
47
|
model,
|
|
48
48
|
prompt,
|
|
49
49
|
options.generationConfig,
|
|
@@ -78,7 +78,7 @@ export function useGemini(options: UseGeminiOptions = {}): UseGeminiReturn {
|
|
|
78
78
|
|
|
79
79
|
try {
|
|
80
80
|
const model = options.model ?? "gemini-1.5-flash";
|
|
81
|
-
const response = await
|
|
81
|
+
const response = await geminiTextGenerationService.generateWithImages(
|
|
82
82
|
model,
|
|
83
83
|
prompt,
|
|
84
84
|
[{ base64: imageBase64, mimeType }],
|
|
@@ -91,7 +91,7 @@ export function useGemini(options: UseGeminiOptions = {}): UseGeminiReturn {
|
|
|
91
91
|
const text =
|
|
92
92
|
response.candidates?.[0]?.content.parts
|
|
93
93
|
.filter((p): p is { text: string } => "text" in p)
|
|
94
|
-
.map((p) => p.text)
|
|
94
|
+
.map((p: { text: string }) => p.text)
|
|
95
95
|
.join("") || "";
|
|
96
96
|
|
|
97
97
|
setResult(text);
|
|
@@ -1,548 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Gemini Client Service
|
|
3
|
-
* Google Gemini AI client using official SDK
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { GoogleGenerativeAI, type GenerativeModel } from "@google/generative-ai";
|
|
7
|
-
import { DEFAULT_MODELS } from "../../domain/entities";
|
|
8
|
-
import type {
|
|
9
|
-
GeminiConfig,
|
|
10
|
-
GeminiContent,
|
|
11
|
-
GeminiGenerationConfig,
|
|
12
|
-
GeminiResponse,
|
|
13
|
-
GeminiImageGenerationResult,
|
|
14
|
-
GeminiPart,
|
|
15
|
-
GeminiFinishReason,
|
|
16
|
-
} from "../../domain/entities";
|
|
17
|
-
|
|
18
|
-
declare const __DEV__: boolean;
|
|
19
|
-
|
|
20
|
-
const DEFAULT_CONFIG: Partial<GeminiConfig> = {
|
|
21
|
-
maxRetries: 3,
|
|
22
|
-
baseDelay: 1000,
|
|
23
|
-
maxDelay: 10000,
|
|
24
|
-
defaultTimeoutMs: 60000,
|
|
25
|
-
defaultModel: DEFAULT_MODELS.TEXT,
|
|
26
|
-
imageModel: DEFAULT_MODELS.TEXT_TO_IMAGE,
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const RETRYABLE_ERROR_PATTERNS = [
|
|
30
|
-
"rate limit",
|
|
31
|
-
"too many requests",
|
|
32
|
-
"429",
|
|
33
|
-
"500",
|
|
34
|
-
"502",
|
|
35
|
-
"503",
|
|
36
|
-
"504",
|
|
37
|
-
"timeout",
|
|
38
|
-
"network",
|
|
39
|
-
"econnrefused",
|
|
40
|
-
"fetch failed",
|
|
41
|
-
];
|
|
42
|
-
|
|
43
|
-
function isRetryableError(error: unknown): boolean {
|
|
44
|
-
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
45
|
-
return RETRYABLE_ERROR_PATTERNS.some((pattern) => message.includes(pattern));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function sleep(ms: number): Promise<void> {
|
|
49
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function extractBase64Data(base64String: string): string {
|
|
53
|
-
if (!base64String.includes(",")) {
|
|
54
|
-
return base64String;
|
|
55
|
-
}
|
|
56
|
-
const parts = base64String.split(",");
|
|
57
|
-
return parts[1] ?? parts[0] ?? base64String;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
class GeminiClientService {
|
|
61
|
-
private client: GoogleGenerativeAI | null = null;
|
|
62
|
-
private config: GeminiConfig | null = null;
|
|
63
|
-
private initialized = false;
|
|
64
|
-
|
|
65
|
-
initialize(config: GeminiConfig): void {
|
|
66
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
67
|
-
// eslint-disable-next-line no-console
|
|
68
|
-
console.log("[GeminiClient] initialize() called", {
|
|
69
|
-
hasApiKey: !!config.apiKey,
|
|
70
|
-
defaultModel: config.defaultModel,
|
|
71
|
-
imageModel: config.imageModel,
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
this.client = new GoogleGenerativeAI(config.apiKey);
|
|
76
|
-
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
77
|
-
this.initialized = true;
|
|
78
|
-
|
|
79
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
80
|
-
// eslint-disable-next-line no-console
|
|
81
|
-
console.log("[GeminiClient] initialized successfully", {
|
|
82
|
-
defaultModel: this.config.defaultModel,
|
|
83
|
-
imageModel: this.config.imageModel,
|
|
84
|
-
maxRetries: this.config.maxRetries,
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
isInitialized(): boolean {
|
|
90
|
-
return this.initialized;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
getConfig(): GeminiConfig | null {
|
|
94
|
-
return this.config;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
private validateInitialization(): void {
|
|
98
|
-
if (!this.client || !this.initialized) {
|
|
99
|
-
throw new Error(
|
|
100
|
-
"Gemini client not initialized. Call initialize() first.",
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
private getModel(modelName?: string): GenerativeModel {
|
|
106
|
-
this.validateInitialization();
|
|
107
|
-
const effectiveModel = modelName || this.config?.defaultModel || "gemini-1.5-flash";
|
|
108
|
-
return this.client!.getGenerativeModel({ model: effectiveModel });
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Generate content (text, with optional images)
|
|
113
|
-
*/
|
|
114
|
-
async generateContent(
|
|
115
|
-
model: string,
|
|
116
|
-
contents: GeminiContent[],
|
|
117
|
-
generationConfig?: GeminiGenerationConfig,
|
|
118
|
-
): Promise<GeminiResponse> {
|
|
119
|
-
const genModel = this.getModel(model);
|
|
120
|
-
|
|
121
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
122
|
-
// eslint-disable-next-line no-console
|
|
123
|
-
console.log("[Gemini] Generate content:", { model });
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Convert our content format to SDK format
|
|
127
|
-
const sdkContents = contents.map((content) => ({
|
|
128
|
-
role: content.role || "user",
|
|
129
|
-
parts: content.parts.map((part) => {
|
|
130
|
-
if ("text" in part) {
|
|
131
|
-
return { text: part.text };
|
|
132
|
-
}
|
|
133
|
-
if ("inlineData" in part) {
|
|
134
|
-
return {
|
|
135
|
-
inlineData: {
|
|
136
|
-
mimeType: part.inlineData.mimeType,
|
|
137
|
-
data: part.inlineData.data,
|
|
138
|
-
},
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
// fileData parts
|
|
142
|
-
return part;
|
|
143
|
-
}),
|
|
144
|
-
}));
|
|
145
|
-
|
|
146
|
-
try {
|
|
147
|
-
const result = await this.executeWithRetry(() =>
|
|
148
|
-
genModel.generateContent({
|
|
149
|
-
contents: sdkContents as Parameters<typeof genModel.generateContent>[0] extends { contents: infer C } ? C : never,
|
|
150
|
-
generationConfig,
|
|
151
|
-
}),
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
const response = result.response;
|
|
155
|
-
|
|
156
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
157
|
-
// eslint-disable-next-line no-console
|
|
158
|
-
console.log("[Gemini] Content generated:", {
|
|
159
|
-
candidatesCount: response.candidates?.length ?? 0,
|
|
160
|
-
finishReason: response.candidates?.[0]?.finishReason,
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return {
|
|
165
|
-
candidates: response.candidates?.map((candidate) => ({
|
|
166
|
-
content: {
|
|
167
|
-
parts: candidate.content.parts
|
|
168
|
-
.map((part): GeminiPart | null => {
|
|
169
|
-
if ("text" in part && part.text !== undefined) {
|
|
170
|
-
return { text: part.text };
|
|
171
|
-
}
|
|
172
|
-
if ("inlineData" in part && part.inlineData) {
|
|
173
|
-
return {
|
|
174
|
-
inlineData: {
|
|
175
|
-
mimeType: part.inlineData.mimeType,
|
|
176
|
-
data: part.inlineData.data,
|
|
177
|
-
},
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
return null;
|
|
181
|
-
})
|
|
182
|
-
.filter((p): p is GeminiPart => p !== null),
|
|
183
|
-
role: (candidate.content.role || "model") as "user" | "model",
|
|
184
|
-
},
|
|
185
|
-
finishReason: candidate.finishReason as GeminiFinishReason | undefined,
|
|
186
|
-
})),
|
|
187
|
-
};
|
|
188
|
-
} catch (error) {
|
|
189
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
190
|
-
// eslint-disable-next-line no-console
|
|
191
|
-
console.error("[Gemini] Content generation failed:", {
|
|
192
|
-
model,
|
|
193
|
-
error: error instanceof Error ? error.message : String(error),
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
throw error;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Generate text from prompt
|
|
202
|
-
*/
|
|
203
|
-
async generateText(
|
|
204
|
-
model: string,
|
|
205
|
-
prompt: string,
|
|
206
|
-
config?: GeminiGenerationConfig,
|
|
207
|
-
): Promise<string> {
|
|
208
|
-
const contents: GeminiContent[] = [
|
|
209
|
-
{ parts: [{ text: prompt }], role: "user" },
|
|
210
|
-
];
|
|
211
|
-
|
|
212
|
-
const response = await this.generateContent(model, contents, config);
|
|
213
|
-
return this.extractTextFromResponse(response);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Generate content with images (multimodal)
|
|
218
|
-
*/
|
|
219
|
-
async generateWithImages(
|
|
220
|
-
model: string,
|
|
221
|
-
prompt: string,
|
|
222
|
-
images: Array<{ base64: string; mimeType: string }>,
|
|
223
|
-
config?: GeminiGenerationConfig,
|
|
224
|
-
): Promise<GeminiResponse> {
|
|
225
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
226
|
-
// eslint-disable-next-line no-console
|
|
227
|
-
console.log("[GeminiClient] generateWithImages() called", {
|
|
228
|
-
model,
|
|
229
|
-
promptLength: prompt.length,
|
|
230
|
-
imagesCount: images.length,
|
|
231
|
-
imageMimeTypes: images.map(i => i.mimeType),
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const parts: GeminiContent["parts"] = [{ text: prompt }];
|
|
236
|
-
|
|
237
|
-
for (const image of images) {
|
|
238
|
-
parts.push({
|
|
239
|
-
inlineData: {
|
|
240
|
-
mimeType: image.mimeType,
|
|
241
|
-
data: extractBase64Data(image.base64),
|
|
242
|
-
},
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const contents: GeminiContent[] = [{ parts, role: "user" }];
|
|
247
|
-
|
|
248
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
249
|
-
// eslint-disable-next-line no-console
|
|
250
|
-
console.log("[GeminiClient] generateWithImages() → calling generateContent()");
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return this.generateContent(model, contents, config);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Generate image from prompt using Imagen API
|
|
258
|
-
* Uses REST API endpoint: /v1beta/models/{model}:predict
|
|
259
|
-
*/
|
|
260
|
-
async generateImage(
|
|
261
|
-
prompt: string,
|
|
262
|
-
_images?: Array<{ base64: string; mimeType: string }>,
|
|
263
|
-
_config?: GeminiGenerationConfig,
|
|
264
|
-
): Promise<GeminiImageGenerationResult> {
|
|
265
|
-
this.validateInitialization();
|
|
266
|
-
|
|
267
|
-
const imageModel = this.config?.imageModel || DEFAULT_MODELS.TEXT_TO_IMAGE;
|
|
268
|
-
const apiKey = this.config?.apiKey;
|
|
269
|
-
|
|
270
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
271
|
-
// eslint-disable-next-line no-console
|
|
272
|
-
console.log("[GeminiClient] generateImage() called (Imagen API)", {
|
|
273
|
-
model: imageModel,
|
|
274
|
-
promptLength: prompt.length,
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const url = `https://generativelanguage.googleapis.com/v1beta/models/${imageModel}:predict`;
|
|
279
|
-
|
|
280
|
-
const requestBody = {
|
|
281
|
-
instances: [{ prompt }],
|
|
282
|
-
parameters: {
|
|
283
|
-
sampleCount: 1,
|
|
284
|
-
aspectRatio: "1:1",
|
|
285
|
-
},
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
289
|
-
// eslint-disable-next-line no-console
|
|
290
|
-
console.log("[GeminiClient] Imagen API request", {
|
|
291
|
-
url,
|
|
292
|
-
prompt: prompt.substring(0, 100) + "...",
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const response = await this.executeWithRetry(async () => {
|
|
297
|
-
const res = await fetch(url, {
|
|
298
|
-
method: "POST",
|
|
299
|
-
headers: {
|
|
300
|
-
"Content-Type": "application/json",
|
|
301
|
-
"x-goog-api-key": apiKey!,
|
|
302
|
-
},
|
|
303
|
-
body: JSON.stringify(requestBody),
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
if (!res.ok) {
|
|
307
|
-
const errorText = await res.text();
|
|
308
|
-
throw new Error(`Imagen API error (${res.status}): ${errorText}`);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return res.json();
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
const result: GeminiImageGenerationResult = {
|
|
315
|
-
text: undefined,
|
|
316
|
-
imageUrl: undefined,
|
|
317
|
-
imageBase64: undefined,
|
|
318
|
-
mimeType: "image/png",
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
if (response.generatedImages && response.generatedImages.length > 0) {
|
|
322
|
-
const generatedImage = response.generatedImages[0];
|
|
323
|
-
const imageBytes = generatedImage.image?.imageBytes;
|
|
324
|
-
|
|
325
|
-
if (imageBytes) {
|
|
326
|
-
result.imageBase64 = imageBytes;
|
|
327
|
-
result.imageUrl = `data:image/png;base64,${imageBytes}`;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
332
|
-
// eslint-disable-next-line no-console
|
|
333
|
-
console.log("[GeminiClient] generateImage() completed (Imagen)", {
|
|
334
|
-
hasImage: !!result.imageBase64,
|
|
335
|
-
imageDataLength: result.imageBase64?.length ?? 0,
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
return result;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Edit/transform image using Gemini generateContent API
|
|
344
|
-
* Takes input image + prompt and generates new image
|
|
345
|
-
*/
|
|
346
|
-
async editImage(
|
|
347
|
-
prompt: string,
|
|
348
|
-
images: Array<{ base64: string; mimeType: string }>,
|
|
349
|
-
): Promise<GeminiImageGenerationResult> {
|
|
350
|
-
this.validateInitialization();
|
|
351
|
-
|
|
352
|
-
const editModel = DEFAULT_MODELS.IMAGE_EDIT;
|
|
353
|
-
const apiKey = this.config?.apiKey;
|
|
354
|
-
|
|
355
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
356
|
-
// eslint-disable-next-line no-console
|
|
357
|
-
console.log("[GeminiClient] editImage() called", {
|
|
358
|
-
model: editModel,
|
|
359
|
-
promptLength: prompt.length,
|
|
360
|
-
imagesCount: images.length,
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const url = `https://generativelanguage.googleapis.com/v1beta/models/${editModel}:generateContent`;
|
|
365
|
-
|
|
366
|
-
const parts: Array<Record<string, unknown>> = [];
|
|
367
|
-
|
|
368
|
-
for (const image of images) {
|
|
369
|
-
parts.push({
|
|
370
|
-
inlineData: {
|
|
371
|
-
mimeType: image.mimeType,
|
|
372
|
-
data: extractBase64Data(image.base64),
|
|
373
|
-
},
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
parts.push({ text: prompt });
|
|
378
|
-
|
|
379
|
-
const requestBody = {
|
|
380
|
-
contents: [{ parts }],
|
|
381
|
-
generationConfig: {
|
|
382
|
-
responseModalities: ["TEXT", "IMAGE"],
|
|
383
|
-
},
|
|
384
|
-
};
|
|
385
|
-
|
|
386
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
387
|
-
// eslint-disable-next-line no-console
|
|
388
|
-
console.log("[GeminiClient] editImage() request", {
|
|
389
|
-
url,
|
|
390
|
-
partsCount: parts.length,
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
const response = await this.executeWithRetry(async () => {
|
|
395
|
-
const res = await fetch(url, {
|
|
396
|
-
method: "POST",
|
|
397
|
-
headers: {
|
|
398
|
-
"Content-Type": "application/json",
|
|
399
|
-
"x-goog-api-key": apiKey!,
|
|
400
|
-
},
|
|
401
|
-
body: JSON.stringify(requestBody),
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
if (!res.ok) {
|
|
405
|
-
const errorText = await res.text();
|
|
406
|
-
throw new Error(`Image edit API error (${res.status}): ${errorText}`);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
return res.json();
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
const result: GeminiImageGenerationResult = {
|
|
413
|
-
text: undefined,
|
|
414
|
-
imageUrl: undefined,
|
|
415
|
-
imageBase64: undefined,
|
|
416
|
-
mimeType: "image/png",
|
|
417
|
-
};
|
|
418
|
-
|
|
419
|
-
const candidate = response.candidates?.[0];
|
|
420
|
-
const responseParts = candidate?.content?.parts || [];
|
|
421
|
-
|
|
422
|
-
for (const part of responseParts) {
|
|
423
|
-
if (part.text) {
|
|
424
|
-
result.text = part.text;
|
|
425
|
-
}
|
|
426
|
-
if (part.inlineData) {
|
|
427
|
-
result.imageBase64 = part.inlineData.data;
|
|
428
|
-
result.mimeType = part.inlineData.mimeType || "image/png";
|
|
429
|
-
result.imageUrl = `data:${result.mimeType};base64,${result.imageBase64}`;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
434
|
-
// eslint-disable-next-line no-console
|
|
435
|
-
console.log("[GeminiClient] editImage() completed", {
|
|
436
|
-
hasImage: !!result.imageBase64,
|
|
437
|
-
hasText: !!result.text,
|
|
438
|
-
imageDataLength: result.imageBase64?.length ?? 0,
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
return result;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/**
|
|
446
|
-
* Stream content generation
|
|
447
|
-
*/
|
|
448
|
-
async streamContent(
|
|
449
|
-
model: string,
|
|
450
|
-
contents: GeminiContent[],
|
|
451
|
-
onChunk: (text: string) => void,
|
|
452
|
-
generationConfig?: GeminiGenerationConfig,
|
|
453
|
-
): Promise<string> {
|
|
454
|
-
const genModel = this.getModel(model);
|
|
455
|
-
|
|
456
|
-
const sdkContents = contents.map((content) => ({
|
|
457
|
-
role: content.role || "user",
|
|
458
|
-
parts: content.parts.map((part) => {
|
|
459
|
-
if ("text" in part) {
|
|
460
|
-
return { text: part.text };
|
|
461
|
-
}
|
|
462
|
-
if ("inlineData" in part) {
|
|
463
|
-
return {
|
|
464
|
-
inlineData: {
|
|
465
|
-
mimeType: part.inlineData.mimeType,
|
|
466
|
-
data: part.inlineData.data,
|
|
467
|
-
},
|
|
468
|
-
};
|
|
469
|
-
}
|
|
470
|
-
return part;
|
|
471
|
-
}),
|
|
472
|
-
}));
|
|
473
|
-
|
|
474
|
-
const result = await genModel.generateContentStream({
|
|
475
|
-
contents: sdkContents as Parameters<typeof genModel.generateContentStream>[0] extends { contents: infer C } ? C : never,
|
|
476
|
-
generationConfig,
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
let fullText = "";
|
|
480
|
-
|
|
481
|
-
for await (const chunk of result.stream) {
|
|
482
|
-
const chunkText = chunk.text();
|
|
483
|
-
if (chunkText) {
|
|
484
|
-
fullText += chunkText;
|
|
485
|
-
onChunk(chunkText);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
return fullText;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
private async executeWithRetry<T>(
|
|
493
|
-
operation: () => Promise<T>,
|
|
494
|
-
retryCount = 0,
|
|
495
|
-
): Promise<T> {
|
|
496
|
-
const maxRetries = this.config?.maxRetries ?? 3;
|
|
497
|
-
const baseDelay = this.config?.baseDelay ?? 1000;
|
|
498
|
-
const maxDelay = this.config?.maxDelay ?? 10000;
|
|
499
|
-
|
|
500
|
-
try {
|
|
501
|
-
return await operation();
|
|
502
|
-
} catch (error) {
|
|
503
|
-
if (!isRetryableError(error) || retryCount >= maxRetries) {
|
|
504
|
-
throw error;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
const delay = Math.min(baseDelay * Math.pow(2, retryCount), maxDelay);
|
|
508
|
-
|
|
509
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
510
|
-
// eslint-disable-next-line no-console
|
|
511
|
-
console.log(`[Gemini] Retry ${retryCount + 1}/${maxRetries} after ${delay}ms`);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
await sleep(delay);
|
|
515
|
-
return this.executeWithRetry(operation, retryCount + 1);
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
private extractTextFromResponse(response: GeminiResponse): string {
|
|
520
|
-
const candidate = response.candidates?.[0];
|
|
521
|
-
|
|
522
|
-
if (!candidate) {
|
|
523
|
-
throw new Error("No response candidates");
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
if (candidate.finishReason === "SAFETY") {
|
|
527
|
-
throw new Error("Content blocked by safety filters");
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const textPart = candidate.content.parts.find(
|
|
531
|
-
(p): p is { text: string } => "text" in p && typeof p.text === "string",
|
|
532
|
-
);
|
|
533
|
-
|
|
534
|
-
if (!textPart) {
|
|
535
|
-
throw new Error("No text in response");
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
return textPart.text;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
reset(): void {
|
|
542
|
-
this.client = null;
|
|
543
|
-
this.config = null;
|
|
544
|
-
this.initialized = false;
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
export const geminiClientService = new GeminiClientService();
|