@umituz/react-native-ai-gemini-provider 1.0.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 +39 -0
- package/src/domain/entities/error.types.ts +39 -0
- package/src/domain/entities/gemini.types.ts +103 -0
- package/src/domain/entities/index.ts +6 -0
- package/src/index.ts +78 -0
- package/src/infrastructure/services/gemini-client.service.ts +276 -0
- package/src/infrastructure/services/gemini-provider.service.ts +250 -0
- package/src/infrastructure/services/index.ts +12 -0
- package/src/infrastructure/utils/error-mapper.util.ts +114 -0
- package/src/infrastructure/utils/index.ts +9 -0
- package/src/presentation/hooks/index.ts +6 -0
- package/src/presentation/hooks/use-gemini.ts +124 -0
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@umituz/react-native-ai-gemini-provider",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Google Gemini AI provider for React Native applications",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"src"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"lint": "eslint src --ext .ts,.tsx",
|
|
13
|
+
"lint:fix": "eslint src --ext .ts,.tsx --fix"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"react-native",
|
|
17
|
+
"ai",
|
|
18
|
+
"gemini",
|
|
19
|
+
"google",
|
|
20
|
+
"generation",
|
|
21
|
+
"llm"
|
|
22
|
+
],
|
|
23
|
+
"author": "umituz",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/umituz/react-native-ai-gemini-provider.git"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": ">=18.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/react": "^19.0.0",
|
|
34
|
+
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
35
|
+
"@typescript-eslint/parser": "^7.0.0",
|
|
36
|
+
"eslint": "^8.57.0",
|
|
37
|
+
"typescript": "^5.3.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Error Types
|
|
3
|
+
* Error classification for Gemini API
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export enum GeminiErrorType {
|
|
7
|
+
NETWORK = "NETWORK",
|
|
8
|
+
RATE_LIMIT = "RATE_LIMIT",
|
|
9
|
+
AUTHENTICATION = "AUTHENTICATION",
|
|
10
|
+
VALIDATION = "VALIDATION",
|
|
11
|
+
SAFETY = "SAFETY",
|
|
12
|
+
SERVER = "SERVER",
|
|
13
|
+
TIMEOUT = "TIMEOUT",
|
|
14
|
+
QUOTA_EXCEEDED = "QUOTA_EXCEEDED",
|
|
15
|
+
MODEL_NOT_FOUND = "MODEL_NOT_FOUND",
|
|
16
|
+
UNKNOWN = "UNKNOWN",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GeminiErrorInfo {
|
|
20
|
+
type: GeminiErrorType;
|
|
21
|
+
messageKey: string;
|
|
22
|
+
retryable: boolean;
|
|
23
|
+
originalError?: unknown;
|
|
24
|
+
statusCode?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GeminiApiError {
|
|
28
|
+
error?: {
|
|
29
|
+
code?: number;
|
|
30
|
+
message?: string;
|
|
31
|
+
status?: string;
|
|
32
|
+
details?: Array<{
|
|
33
|
+
"@type"?: string;
|
|
34
|
+
reason?: string;
|
|
35
|
+
domain?: string;
|
|
36
|
+
metadata?: Record<string, string>;
|
|
37
|
+
}>;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Provider Types
|
|
3
|
+
* Configuration and response types for Google Gemini AI
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface GeminiConfig {
|
|
7
|
+
apiKey: string;
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
maxRetries?: number;
|
|
10
|
+
baseDelay?: number;
|
|
11
|
+
maxDelay?: number;
|
|
12
|
+
defaultTimeoutMs?: number;
|
|
13
|
+
defaultModel?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GeminiGenerationConfig {
|
|
17
|
+
temperature?: number;
|
|
18
|
+
topK?: number;
|
|
19
|
+
topP?: number;
|
|
20
|
+
maxOutputTokens?: number;
|
|
21
|
+
stopSequences?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GeminiSafetySettings {
|
|
25
|
+
category: GeminiHarmCategory;
|
|
26
|
+
threshold: GeminiHarmBlockThreshold;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type GeminiHarmCategory =
|
|
30
|
+
| "HARM_CATEGORY_HARASSMENT"
|
|
31
|
+
| "HARM_CATEGORY_HATE_SPEECH"
|
|
32
|
+
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
|
33
|
+
| "HARM_CATEGORY_DANGEROUS_CONTENT";
|
|
34
|
+
|
|
35
|
+
export type GeminiHarmBlockThreshold =
|
|
36
|
+
| "BLOCK_NONE"
|
|
37
|
+
| "BLOCK_LOW_AND_ABOVE"
|
|
38
|
+
| "BLOCK_MEDIUM_AND_ABOVE"
|
|
39
|
+
| "BLOCK_ONLY_HIGH";
|
|
40
|
+
|
|
41
|
+
export interface GeminiContent {
|
|
42
|
+
parts: GeminiPart[];
|
|
43
|
+
role?: "user" | "model";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type GeminiPart =
|
|
47
|
+
| { text: string }
|
|
48
|
+
| { inlineData: { mimeType: string; data: string } }
|
|
49
|
+
| { fileData: { mimeType: string; fileUri: string } };
|
|
50
|
+
|
|
51
|
+
export interface GeminiRequest {
|
|
52
|
+
contents: GeminiContent[];
|
|
53
|
+
generationConfig?: GeminiGenerationConfig;
|
|
54
|
+
safetySettings?: GeminiSafetySettings[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface GeminiResponse {
|
|
58
|
+
candidates?: GeminiCandidate[];
|
|
59
|
+
promptFeedback?: GeminiPromptFeedback;
|
|
60
|
+
usageMetadata?: GeminiUsageMetadata;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface GeminiCandidate {
|
|
64
|
+
content: GeminiContent;
|
|
65
|
+
finishReason?: GeminiFinishReason;
|
|
66
|
+
safetyRatings?: GeminiSafetyRating[];
|
|
67
|
+
index?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type GeminiFinishReason =
|
|
71
|
+
| "FINISH_REASON_UNSPECIFIED"
|
|
72
|
+
| "STOP"
|
|
73
|
+
| "MAX_TOKENS"
|
|
74
|
+
| "SAFETY"
|
|
75
|
+
| "RECITATION"
|
|
76
|
+
| "OTHER";
|
|
77
|
+
|
|
78
|
+
export interface GeminiSafetyRating {
|
|
79
|
+
category: GeminiHarmCategory;
|
|
80
|
+
probability: "NEGLIGIBLE" | "LOW" | "MEDIUM" | "HIGH";
|
|
81
|
+
blocked?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface GeminiPromptFeedback {
|
|
85
|
+
blockReason?: "BLOCK_REASON_UNSPECIFIED" | "SAFETY" | "OTHER";
|
|
86
|
+
safetyRatings?: GeminiSafetyRating[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface GeminiUsageMetadata {
|
|
90
|
+
promptTokenCount?: number;
|
|
91
|
+
candidatesTokenCount?: number;
|
|
92
|
+
totalTokenCount?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface GeminiModel {
|
|
96
|
+
id: string;
|
|
97
|
+
name: string;
|
|
98
|
+
displayName: string;
|
|
99
|
+
description?: string;
|
|
100
|
+
inputTokenLimit?: number;
|
|
101
|
+
outputTokenLimit?: number;
|
|
102
|
+
supportedCapabilities?: string[];
|
|
103
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @umituz/react-native-ai-gemini-provider
|
|
3
|
+
* Google Gemini AI provider for React Native applications
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* import {
|
|
7
|
+
* geminiClientService,
|
|
8
|
+
* geminiProviderService,
|
|
9
|
+
* useGemini,
|
|
10
|
+
* mapGeminiError,
|
|
11
|
+
* } from '@umituz/react-native-ai-gemini-provider';
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// DOMAIN LAYER - Types
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
export type {
|
|
19
|
+
GeminiConfig,
|
|
20
|
+
GeminiGenerationConfig,
|
|
21
|
+
GeminiSafetySettings,
|
|
22
|
+
GeminiHarmCategory,
|
|
23
|
+
GeminiHarmBlockThreshold,
|
|
24
|
+
GeminiContent,
|
|
25
|
+
GeminiPart,
|
|
26
|
+
GeminiRequest,
|
|
27
|
+
GeminiResponse,
|
|
28
|
+
GeminiCandidate,
|
|
29
|
+
GeminiFinishReason,
|
|
30
|
+
GeminiSafetyRating,
|
|
31
|
+
GeminiPromptFeedback,
|
|
32
|
+
GeminiUsageMetadata,
|
|
33
|
+
GeminiModel,
|
|
34
|
+
} from "./domain/entities";
|
|
35
|
+
|
|
36
|
+
export { GeminiErrorType } from "./domain/entities";
|
|
37
|
+
|
|
38
|
+
export type {
|
|
39
|
+
GeminiErrorInfo,
|
|
40
|
+
GeminiApiError,
|
|
41
|
+
} from "./domain/entities";
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// INFRASTRUCTURE LAYER - Services
|
|
45
|
+
// =============================================================================
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
geminiClientService,
|
|
49
|
+
geminiProviderService,
|
|
50
|
+
} from "./infrastructure/services";
|
|
51
|
+
|
|
52
|
+
export type {
|
|
53
|
+
AIProviderConfig,
|
|
54
|
+
JobSubmission,
|
|
55
|
+
JobStatus,
|
|
56
|
+
SubscribeOptions,
|
|
57
|
+
} from "./infrastructure/services";
|
|
58
|
+
|
|
59
|
+
// =============================================================================
|
|
60
|
+
// INFRASTRUCTURE LAYER - Utils
|
|
61
|
+
// =============================================================================
|
|
62
|
+
|
|
63
|
+
export {
|
|
64
|
+
mapGeminiError,
|
|
65
|
+
isGeminiErrorRetryable,
|
|
66
|
+
categorizeGeminiError,
|
|
67
|
+
} from "./infrastructure/utils";
|
|
68
|
+
|
|
69
|
+
// =============================================================================
|
|
70
|
+
// PRESENTATION LAYER - Hooks
|
|
71
|
+
// =============================================================================
|
|
72
|
+
|
|
73
|
+
export { useGemini } from "./presentation/hooks";
|
|
74
|
+
|
|
75
|
+
export type {
|
|
76
|
+
UseGeminiOptions,
|
|
77
|
+
UseGeminiReturn,
|
|
78
|
+
} from "./presentation/hooks";
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Client Service
|
|
3
|
+
* Google Gemini AI client wrapper
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
GeminiConfig,
|
|
8
|
+
GeminiRequest,
|
|
9
|
+
GeminiResponse,
|
|
10
|
+
GeminiContent,
|
|
11
|
+
GeminiGenerationConfig,
|
|
12
|
+
} from "../../domain/entities";
|
|
13
|
+
|
|
14
|
+
declare const __DEV__: boolean;
|
|
15
|
+
|
|
16
|
+
const DEFAULT_CONFIG: Partial<GeminiConfig> = {
|
|
17
|
+
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
|
18
|
+
maxRetries: 3,
|
|
19
|
+
baseDelay: 1000,
|
|
20
|
+
maxDelay: 10000,
|
|
21
|
+
defaultTimeoutMs: 60000,
|
|
22
|
+
defaultModel: "gemini-1.5-flash",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
class GeminiClientService {
|
|
26
|
+
private apiKey: string | null = null;
|
|
27
|
+
private config: GeminiConfig | null = null;
|
|
28
|
+
private initialized = false;
|
|
29
|
+
|
|
30
|
+
initialize(config: GeminiConfig): void {
|
|
31
|
+
this.apiKey = config.apiKey;
|
|
32
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
33
|
+
this.initialized = true;
|
|
34
|
+
|
|
35
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
36
|
+
// eslint-disable-next-line no-console
|
|
37
|
+
console.log("[Gemini] Client initialized");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
isInitialized(): boolean {
|
|
42
|
+
return this.initialized;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getConfig(): GeminiConfig | null {
|
|
46
|
+
return this.config;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private validateInitialization(): void {
|
|
50
|
+
if (!this.apiKey || !this.initialized) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"Gemini client not initialized. Call initialize() first.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private getEndpoint(model: string, action: string): string {
|
|
58
|
+
const baseUrl = this.config?.baseUrl ?? DEFAULT_CONFIG.baseUrl;
|
|
59
|
+
return `${baseUrl}/models/${model}:${action}?key=${this.apiKey}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private async request<T>(
|
|
63
|
+
endpoint: string,
|
|
64
|
+
body: unknown,
|
|
65
|
+
timeoutMs?: number,
|
|
66
|
+
): Promise<T> {
|
|
67
|
+
const timeout = timeoutMs ?? this.config?.defaultTimeoutMs ?? 60000;
|
|
68
|
+
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(endpoint, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: {
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
signal: controller.signal,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
let errorData: { error?: { message?: string } } = {};
|
|
84
|
+
try {
|
|
85
|
+
errorData = (await response.json()) as { error?: { message?: string } };
|
|
86
|
+
} catch {
|
|
87
|
+
// Ignore JSON parse errors for error responses
|
|
88
|
+
}
|
|
89
|
+
const errorMessage = errorData?.error?.message ?? `HTTP ${response.status}`;
|
|
90
|
+
const error = new Error(errorMessage);
|
|
91
|
+
(error as unknown as Record<string, unknown>).status = response.status;
|
|
92
|
+
(error as unknown as Record<string, unknown>).response = errorData;
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return response.json() as Promise<T>;
|
|
97
|
+
} finally {
|
|
98
|
+
clearTimeout(timeoutId);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async generateContent(
|
|
103
|
+
model: string,
|
|
104
|
+
contents: GeminiContent[],
|
|
105
|
+
generationConfig?: GeminiGenerationConfig,
|
|
106
|
+
): Promise<GeminiResponse> {
|
|
107
|
+
this.validateInitialization();
|
|
108
|
+
|
|
109
|
+
const effectiveModel = model || this.config?.defaultModel || "gemini-1.5-flash";
|
|
110
|
+
const endpoint = this.getEndpoint(effectiveModel, "generateContent");
|
|
111
|
+
|
|
112
|
+
const body: GeminiRequest = {
|
|
113
|
+
contents,
|
|
114
|
+
generationConfig,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
118
|
+
// eslint-disable-next-line no-console
|
|
119
|
+
console.log("[Gemini] Generate content:", { model: effectiveModel });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return this.request<GeminiResponse>(endpoint, body);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async generateText(
|
|
126
|
+
model: string,
|
|
127
|
+
prompt: string,
|
|
128
|
+
config?: GeminiGenerationConfig,
|
|
129
|
+
): Promise<string> {
|
|
130
|
+
const contents: GeminiContent[] = [
|
|
131
|
+
{ parts: [{ text: prompt }], role: "user" },
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
const response = await this.generateContent(model, contents, config);
|
|
135
|
+
return this.extractTextFromResponse(response);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async generateWithImage(
|
|
139
|
+
model: string,
|
|
140
|
+
prompt: string,
|
|
141
|
+
imageBase64: string,
|
|
142
|
+
mimeType: string,
|
|
143
|
+
config?: GeminiGenerationConfig,
|
|
144
|
+
): Promise<string> {
|
|
145
|
+
const contents: GeminiContent[] = [
|
|
146
|
+
{
|
|
147
|
+
parts: [
|
|
148
|
+
{ text: prompt },
|
|
149
|
+
{ inlineData: { mimeType, data: imageBase64 } },
|
|
150
|
+
],
|
|
151
|
+
role: "user",
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
const response = await this.generateContent(model, contents, config);
|
|
156
|
+
return this.extractTextFromResponse(response);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private extractTextFromResponse(response: GeminiResponse): string {
|
|
160
|
+
const candidate = response.candidates?.[0];
|
|
161
|
+
|
|
162
|
+
if (!candidate) {
|
|
163
|
+
throw new Error("No response candidates");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (candidate.finishReason === "SAFETY") {
|
|
167
|
+
throw new Error("Content blocked by safety filters");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const textPart = candidate.content.parts.find(
|
|
171
|
+
(p): p is { text: string } => "text" in p,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (!textPart) {
|
|
175
|
+
throw new Error("No text in response");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return textPart.text;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async streamContent(
|
|
182
|
+
model: string,
|
|
183
|
+
contents: GeminiContent[],
|
|
184
|
+
onChunk: (text: string) => void,
|
|
185
|
+
generationConfig?: GeminiGenerationConfig,
|
|
186
|
+
): Promise<string> {
|
|
187
|
+
this.validateInitialization();
|
|
188
|
+
|
|
189
|
+
const effectiveModel = model || this.config?.defaultModel || "gemini-1.5-flash";
|
|
190
|
+
const endpoint = this.getEndpoint(effectiveModel, "streamGenerateContent");
|
|
191
|
+
|
|
192
|
+
const body: GeminiRequest = {
|
|
193
|
+
contents,
|
|
194
|
+
generationConfig,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const timeout = this.config?.defaultTimeoutMs ?? 60000;
|
|
198
|
+
const controller = new AbortController();
|
|
199
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const response = await fetch(endpoint, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { "Content-Type": "application/json" },
|
|
205
|
+
body: JSON.stringify(body),
|
|
206
|
+
signal: controller.signal,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
throw new Error(`HTTP ${response.status}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const reader = response.body?.getReader();
|
|
214
|
+
if (!reader) {
|
|
215
|
+
throw new Error("No response body");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const decoder = new TextDecoder();
|
|
219
|
+
let fullText = "";
|
|
220
|
+
let reading = true;
|
|
221
|
+
|
|
222
|
+
while (reading) {
|
|
223
|
+
const { done, value } = await reader.read();
|
|
224
|
+
|
|
225
|
+
if (done) {
|
|
226
|
+
reading = false;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
231
|
+
const text = this.parseStreamChunk(chunk);
|
|
232
|
+
|
|
233
|
+
if (text) {
|
|
234
|
+
fullText += text;
|
|
235
|
+
onChunk(text);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return fullText;
|
|
240
|
+
} finally {
|
|
241
|
+
clearTimeout(timeoutId);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private parseStreamChunk(chunk: string): string {
|
|
246
|
+
try {
|
|
247
|
+
const lines = chunk.split("\n").filter((l) => l.trim());
|
|
248
|
+
let text = "";
|
|
249
|
+
|
|
250
|
+
for (const line of lines) {
|
|
251
|
+
if (line.startsWith("data: ")) {
|
|
252
|
+
const data = JSON.parse(line.slice(6)) as GeminiResponse;
|
|
253
|
+
const candidate = data.candidates?.[0];
|
|
254
|
+
const textPart = candidate?.content.parts.find(
|
|
255
|
+
(p): p is { text: string } => "text" in p,
|
|
256
|
+
);
|
|
257
|
+
if (textPart) {
|
|
258
|
+
text += textPart.text;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return text;
|
|
264
|
+
} catch {
|
|
265
|
+
return "";
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
reset(): void {
|
|
270
|
+
this.apiKey = null;
|
|
271
|
+
this.config = null;
|
|
272
|
+
this.initialized = false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export const geminiClientService = new GeminiClientService();
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Provider Service
|
|
3
|
+
* IAIProvider implementation for Google Gemini
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { GeminiConfig, GeminiContent } from "../../domain/entities";
|
|
7
|
+
import { geminiClientService } from "./gemini-client.service";
|
|
8
|
+
|
|
9
|
+
declare const __DEV__: boolean;
|
|
10
|
+
|
|
11
|
+
export interface AIProviderConfig {
|
|
12
|
+
apiKey: string;
|
|
13
|
+
maxRetries?: number;
|
|
14
|
+
baseDelay?: number;
|
|
15
|
+
maxDelay?: number;
|
|
16
|
+
defaultTimeoutMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface JobSubmission {
|
|
20
|
+
requestId: string;
|
|
21
|
+
statusUrl?: string;
|
|
22
|
+
responseUrl?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface JobStatus {
|
|
26
|
+
status: "IN_QUEUE" | "IN_PROGRESS" | "COMPLETED" | "FAILED";
|
|
27
|
+
logs?: Array<{ message: string; level: string; timestamp?: string }>;
|
|
28
|
+
queuePosition?: number;
|
|
29
|
+
eta?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SubscribeOptions<T = unknown> {
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
onQueueUpdate?: (status: JobStatus) => void;
|
|
35
|
+
onProgress?: (progress: number) => void;
|
|
36
|
+
onResult?: (result: T) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PendingJob {
|
|
40
|
+
model: string;
|
|
41
|
+
input: Record<string, unknown>;
|
|
42
|
+
status: JobStatus["status"];
|
|
43
|
+
result?: unknown;
|
|
44
|
+
error?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class GeminiProviderService {
|
|
48
|
+
readonly providerId = "gemini";
|
|
49
|
+
readonly providerName = "Google Gemini";
|
|
50
|
+
|
|
51
|
+
private pendingJobs: Map<string, PendingJob> = new Map();
|
|
52
|
+
private jobCounter = 0;
|
|
53
|
+
|
|
54
|
+
initialize(config: AIProviderConfig): void {
|
|
55
|
+
const geminiConfig: GeminiConfig = {
|
|
56
|
+
apiKey: config.apiKey,
|
|
57
|
+
maxRetries: config.maxRetries,
|
|
58
|
+
baseDelay: config.baseDelay,
|
|
59
|
+
maxDelay: config.maxDelay,
|
|
60
|
+
defaultTimeoutMs: config.defaultTimeoutMs,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
geminiClientService.initialize(geminiConfig);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
isInitialized(): boolean {
|
|
67
|
+
return geminiClientService.isInitialized();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
submitJob(
|
|
71
|
+
model: string,
|
|
72
|
+
input: Record<string, unknown>,
|
|
73
|
+
): Promise<JobSubmission> {
|
|
74
|
+
const requestId = this.generateRequestId();
|
|
75
|
+
|
|
76
|
+
this.pendingJobs.set(requestId, {
|
|
77
|
+
model,
|
|
78
|
+
input,
|
|
79
|
+
status: "IN_QUEUE",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
83
|
+
// eslint-disable-next-line no-console
|
|
84
|
+
console.log("[GeminiProvider] Job submitted:", { requestId, model });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.processJobAsync(requestId).catch((error) => {
|
|
88
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
89
|
+
// eslint-disable-next-line no-console
|
|
90
|
+
console.error("[GeminiProvider] Job failed:", error);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return Promise.resolve({
|
|
95
|
+
requestId,
|
|
96
|
+
statusUrl: undefined,
|
|
97
|
+
responseUrl: undefined,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
getJobStatus(_model: string, requestId: string): Promise<JobStatus> {
|
|
102
|
+
const job = this.pendingJobs.get(requestId);
|
|
103
|
+
|
|
104
|
+
if (!job) {
|
|
105
|
+
return Promise.resolve({ status: "FAILED" });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return Promise.resolve({ status: job.status });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getJobResult<T = unknown>(_model: string, requestId: string): Promise<T> {
|
|
112
|
+
const job = this.pendingJobs.get(requestId);
|
|
113
|
+
|
|
114
|
+
if (!job) {
|
|
115
|
+
return Promise.reject(new Error(`Job ${requestId} not found`));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (job.status !== "COMPLETED") {
|
|
119
|
+
return Promise.reject(new Error(`Job ${requestId} not completed`));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (job.error) {
|
|
123
|
+
return Promise.reject(new Error(job.error));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this.pendingJobs.delete(requestId);
|
|
127
|
+
|
|
128
|
+
return Promise.resolve(job.result as T);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async subscribe<T = unknown>(
|
|
132
|
+
model: string,
|
|
133
|
+
input: Record<string, unknown>,
|
|
134
|
+
options?: SubscribeOptions<T>,
|
|
135
|
+
): Promise<T> {
|
|
136
|
+
options?.onQueueUpdate?.({ status: "IN_QUEUE" });
|
|
137
|
+
|
|
138
|
+
const result = await this.executeGeneration<T>(model, input);
|
|
139
|
+
|
|
140
|
+
options?.onQueueUpdate?.({ status: "COMPLETED" });
|
|
141
|
+
options?.onResult?.(result);
|
|
142
|
+
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async run<T = unknown>(
|
|
147
|
+
model: string,
|
|
148
|
+
input: Record<string, unknown>,
|
|
149
|
+
): Promise<T> {
|
|
150
|
+
return this.executeGeneration<T>(model, input);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
reset(): void {
|
|
154
|
+
geminiClientService.reset();
|
|
155
|
+
this.pendingJobs.clear();
|
|
156
|
+
this.jobCounter = 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private generateRequestId(): string {
|
|
160
|
+
this.jobCounter++;
|
|
161
|
+
return `gemini-${Date.now()}-${this.jobCounter}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async processJobAsync(requestId: string): Promise<void> {
|
|
165
|
+
const job = this.pendingJobs.get(requestId);
|
|
166
|
+
|
|
167
|
+
if (!job) return;
|
|
168
|
+
|
|
169
|
+
job.status = "IN_PROGRESS";
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const result = await this.executeGeneration(job.model, job.input);
|
|
173
|
+
job.result = result;
|
|
174
|
+
job.status = "COMPLETED";
|
|
175
|
+
} catch (error) {
|
|
176
|
+
job.status = "FAILED";
|
|
177
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async executeGeneration<T>(
|
|
182
|
+
model: string,
|
|
183
|
+
input: Record<string, unknown>,
|
|
184
|
+
): Promise<T> {
|
|
185
|
+
const contents = this.buildContents(input);
|
|
186
|
+
|
|
187
|
+
const response = await geminiClientService.generateContent(
|
|
188
|
+
model,
|
|
189
|
+
contents,
|
|
190
|
+
input.generationConfig as undefined,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
return this.formatResponse<T>(response, input);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private buildContents(input: Record<string, unknown>): GeminiContent[] {
|
|
197
|
+
const contents: GeminiContent[] = [];
|
|
198
|
+
|
|
199
|
+
if (typeof input.prompt === "string") {
|
|
200
|
+
const parts: GeminiContent["parts"] = [{ text: input.prompt }];
|
|
201
|
+
|
|
202
|
+
if (input.image_url && typeof input.image_url === "string") {
|
|
203
|
+
const base64Match = input.image_url.match(
|
|
204
|
+
/^data:([^;]+);base64,(.+)$/,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (base64Match) {
|
|
208
|
+
parts.push({
|
|
209
|
+
inlineData: {
|
|
210
|
+
mimeType: base64Match[1],
|
|
211
|
+
data: base64Match[2],
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
contents.push({ parts, role: "user" });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (Array.isArray(input.contents)) {
|
|
221
|
+
contents.push(...(input.contents as GeminiContent[]));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return contents;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private formatResponse<T>(
|
|
228
|
+
response: unknown,
|
|
229
|
+
input: Record<string, unknown>,
|
|
230
|
+
): T {
|
|
231
|
+
const resp = response as {
|
|
232
|
+
candidates?: Array<{
|
|
233
|
+
content: { parts: Array<{ text?: string }> };
|
|
234
|
+
}>;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const text = resp.candidates?.[0]?.content.parts.find((p) => p.text)?.text;
|
|
238
|
+
|
|
239
|
+
if (input.outputFormat === "text") {
|
|
240
|
+
return text as T;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
text,
|
|
245
|
+
response,
|
|
246
|
+
} as T;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export const geminiProviderService = new GeminiProviderService();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure Services
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { geminiClientService } from "./gemini-client.service";
|
|
6
|
+
export { geminiProviderService } from "./gemini-provider.service";
|
|
7
|
+
export type {
|
|
8
|
+
AIProviderConfig,
|
|
9
|
+
JobSubmission,
|
|
10
|
+
JobStatus,
|
|
11
|
+
SubscribeOptions,
|
|
12
|
+
} from "./gemini-provider.service";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Error Mapper
|
|
3
|
+
* Maps Gemini API errors to standardized format
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
GeminiErrorType,
|
|
8
|
+
type GeminiErrorInfo,
|
|
9
|
+
type GeminiApiError,
|
|
10
|
+
} from "../../domain/entities";
|
|
11
|
+
|
|
12
|
+
const ERROR_PATTERNS: Array<{
|
|
13
|
+
pattern: RegExp | string[];
|
|
14
|
+
type: GeminiErrorType;
|
|
15
|
+
retryable: boolean;
|
|
16
|
+
}> = [
|
|
17
|
+
{
|
|
18
|
+
pattern: ["quota", "resource exhausted", "429"],
|
|
19
|
+
type: GeminiErrorType.QUOTA_EXCEEDED,
|
|
20
|
+
retryable: true,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
pattern: ["rate limit", "too many requests"],
|
|
24
|
+
type: GeminiErrorType.RATE_LIMIT,
|
|
25
|
+
retryable: true,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
pattern: ["unauthorized", "invalid api key", "401", "403", "permission"],
|
|
29
|
+
type: GeminiErrorType.AUTHENTICATION,
|
|
30
|
+
retryable: false,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
pattern: ["safety", "blocked", "harmful"],
|
|
34
|
+
type: GeminiErrorType.SAFETY,
|
|
35
|
+
retryable: false,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
pattern: ["model not found", "404", "not found"],
|
|
39
|
+
type: GeminiErrorType.MODEL_NOT_FOUND,
|
|
40
|
+
retryable: false,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
pattern: ["network", "fetch failed", "connection", "socket"],
|
|
44
|
+
type: GeminiErrorType.NETWORK,
|
|
45
|
+
retryable: true,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
pattern: ["timeout", "timed out"],
|
|
49
|
+
type: GeminiErrorType.TIMEOUT,
|
|
50
|
+
retryable: true,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
pattern: ["500", "502", "503", "504", "internal server", "unavailable"],
|
|
54
|
+
type: GeminiErrorType.SERVER,
|
|
55
|
+
retryable: true,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
pattern: ["invalid", "bad request", "400"],
|
|
59
|
+
type: GeminiErrorType.VALIDATION,
|
|
60
|
+
retryable: false,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
function getStatusCode(error: unknown): number | undefined {
|
|
65
|
+
if (error && typeof error === "object") {
|
|
66
|
+
const err = error as Record<string, unknown>;
|
|
67
|
+
if (typeof err.status === "number") return err.status;
|
|
68
|
+
if (typeof err.statusCode === "number") return err.statusCode;
|
|
69
|
+
|
|
70
|
+
const response = err.response as GeminiApiError | undefined;
|
|
71
|
+
if (response?.error?.code) return response.error.code;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function matchesPattern(message: string, patterns: string[]): boolean {
|
|
77
|
+
const lower = message.toLowerCase();
|
|
78
|
+
return patterns.some((p) => lower.includes(p.toLowerCase()));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function mapGeminiError(error: unknown): GeminiErrorInfo {
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
const statusCode = getStatusCode(error);
|
|
84
|
+
|
|
85
|
+
for (const { pattern, type, retryable } of ERROR_PATTERNS) {
|
|
86
|
+
const patterns = Array.isArray(pattern) ? pattern : [pattern.source];
|
|
87
|
+
|
|
88
|
+
if (matchesPattern(message, patterns)) {
|
|
89
|
+
return {
|
|
90
|
+
type,
|
|
91
|
+
messageKey: `error.gemini.${type.toLowerCase()}`,
|
|
92
|
+
retryable,
|
|
93
|
+
originalError: error,
|
|
94
|
+
statusCode,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
type: GeminiErrorType.UNKNOWN,
|
|
101
|
+
messageKey: "error.gemini.unknown",
|
|
102
|
+
retryable: false,
|
|
103
|
+
originalError: error,
|
|
104
|
+
statusCode,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function isGeminiErrorRetryable(error: unknown): boolean {
|
|
109
|
+
return mapGeminiError(error).retryable;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function categorizeGeminiError(error: unknown): GeminiErrorType {
|
|
113
|
+
return mapGeminiError(error).type;
|
|
114
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useGemini Hook
|
|
3
|
+
* React hook for Gemini AI generation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useCallback, useRef } from "react";
|
|
7
|
+
import type { GeminiGenerationConfig } from "../../domain/entities";
|
|
8
|
+
import { geminiClientService } from "../../infrastructure/services";
|
|
9
|
+
|
|
10
|
+
export interface UseGeminiOptions {
|
|
11
|
+
model?: string;
|
|
12
|
+
generationConfig?: GeminiGenerationConfig;
|
|
13
|
+
onSuccess?: (result: string) => void;
|
|
14
|
+
onError?: (error: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseGeminiReturn {
|
|
18
|
+
generate: (prompt: string) => Promise<void>;
|
|
19
|
+
generateWithImage: (
|
|
20
|
+
prompt: string,
|
|
21
|
+
imageBase64: string,
|
|
22
|
+
mimeType: string,
|
|
23
|
+
) => Promise<void>;
|
|
24
|
+
result: string | null;
|
|
25
|
+
isGenerating: boolean;
|
|
26
|
+
error: string | null;
|
|
27
|
+
reset: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useGemini(options: UseGeminiOptions = {}): UseGeminiReturn {
|
|
31
|
+
const [result, setResult] = useState<string | null>(null);
|
|
32
|
+
const [isGenerating, setIsGenerating] = useState(false);
|
|
33
|
+
const [error, setError] = useState<string | null>(null);
|
|
34
|
+
|
|
35
|
+
const abortRef = useRef(false);
|
|
36
|
+
|
|
37
|
+
const generate = useCallback(
|
|
38
|
+
async (prompt: string) => {
|
|
39
|
+
abortRef.current = false;
|
|
40
|
+
setIsGenerating(true);
|
|
41
|
+
setError(null);
|
|
42
|
+
setResult(null);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const model = options.model ?? "gemini-1.5-flash";
|
|
46
|
+
const text = await geminiClientService.generateText(
|
|
47
|
+
model,
|
|
48
|
+
prompt,
|
|
49
|
+
options.generationConfig,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (abortRef.current) return;
|
|
53
|
+
|
|
54
|
+
setResult(text);
|
|
55
|
+
options.onSuccess?.(text);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (abortRef.current) return;
|
|
58
|
+
|
|
59
|
+
const errorMessage =
|
|
60
|
+
err instanceof Error ? err.message : "Generation failed";
|
|
61
|
+
setError(errorMessage);
|
|
62
|
+
options.onError?.(errorMessage);
|
|
63
|
+
} finally {
|
|
64
|
+
if (!abortRef.current) {
|
|
65
|
+
setIsGenerating(false);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
[options],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const generateWithImage = useCallback(
|
|
73
|
+
async (prompt: string, imageBase64: string, mimeType: string) => {
|
|
74
|
+
abortRef.current = false;
|
|
75
|
+
setIsGenerating(true);
|
|
76
|
+
setError(null);
|
|
77
|
+
setResult(null);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const model = options.model ?? "gemini-1.5-flash";
|
|
81
|
+
const text = await geminiClientService.generateWithImage(
|
|
82
|
+
model,
|
|
83
|
+
prompt,
|
|
84
|
+
imageBase64,
|
|
85
|
+
mimeType,
|
|
86
|
+
options.generationConfig,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (abortRef.current) return;
|
|
90
|
+
|
|
91
|
+
setResult(text);
|
|
92
|
+
options.onSuccess?.(text);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (abortRef.current) return;
|
|
95
|
+
|
|
96
|
+
const errorMessage =
|
|
97
|
+
err instanceof Error ? err.message : "Generation failed";
|
|
98
|
+
setError(errorMessage);
|
|
99
|
+
options.onError?.(errorMessage);
|
|
100
|
+
} finally {
|
|
101
|
+
if (!abortRef.current) {
|
|
102
|
+
setIsGenerating(false);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
[options],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const reset = useCallback(() => {
|
|
110
|
+
abortRef.current = true;
|
|
111
|
+
setResult(null);
|
|
112
|
+
setIsGenerating(false);
|
|
113
|
+
setError(null);
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
generate,
|
|
118
|
+
generateWithImage,
|
|
119
|
+
result,
|
|
120
|
+
isGenerating,
|
|
121
|
+
error,
|
|
122
|
+
reset,
|
|
123
|
+
};
|
|
124
|
+
}
|