react-native-ai-hooks 0.3.0 → 0.5.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/.github/workflows/ci.yml +34 -0
- package/CONTRIBUTING.md +122 -0
- package/README.md +73 -20
- package/docs/ARCHITECTURE.md +301 -0
- package/docs/ARCHITECTURE_GUIDE.md +467 -0
- package/docs/IMPLEMENTATION_COMPLETE.md +349 -0
- package/docs/README.md +17 -0
- package/docs/TECHNICAL_SPECIFICATION.md +748 -0
- package/example/App.tsx +95 -0
- package/example/README.md +27 -0
- package/example/index.js +5 -0
- package/example/package.json +22 -0
- package/example/src/components/ProviderPicker.tsx +62 -0
- package/example/src/context/APIKeysContext.tsx +96 -0
- package/example/src/screens/ChatScreen.tsx +205 -0
- package/example/src/screens/SettingsScreen.tsx +124 -0
- package/example/tsconfig.json +7 -0
- package/jest.config.cjs +7 -0
- package/jest.setup.ts +28 -0
- package/package.json +17 -3
- package/src/hooks/__tests__/useAIForm.test.ts +345 -0
- package/src/hooks/__tests__/useAIStream.test.ts +427 -0
- package/src/hooks/useAIChat.ts +111 -51
- package/src/hooks/useAICode.ts +8 -0
- package/src/hooks/useAIForm.ts +92 -202
- package/src/hooks/useAIStream.ts +114 -58
- package/src/hooks/useAISummarize.ts +8 -0
- package/src/hooks/useAITranslate.ts +9 -0
- package/src/hooks/useAIVoice.ts +8 -0
- package/src/hooks/useImageAnalysis.ts +134 -79
- package/src/index.ts +25 -1
- package/src/types/index.ts +178 -4
- package/src/utils/__tests__/fetchWithRetry.test.ts +168 -0
- package/src/utils/__tests__/providerFactory.test.ts +493 -0
- package/src/utils/fetchWithRetry.ts +100 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/providerFactory.ts +288 -0
|
@@ -1,47 +1,20 @@
|
|
|
1
|
-
import { useCallback, useState } from 'react';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
apiKey: string;
|
|
5
|
-
model?: string;
|
|
6
|
-
maxTokens?: number;
|
|
7
|
-
system?: string;
|
|
8
|
-
uriToBase64?: (uri: string) => Promise<string>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface AnalyzeImageInput {
|
|
12
|
-
image: string;
|
|
13
|
-
mediaType?: string;
|
|
14
|
-
prompt?: string;
|
|
15
|
-
}
|
|
1
|
+
import { useCallback, useState, useRef, useMemo } from 'react';
|
|
2
|
+
import type { UseImageAnalysisOptions, UseImageAnalysisReturn } from '../types';
|
|
3
|
+
import { createProvider } from '../utils/providerFactory';
|
|
16
4
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
clearDescription: () => void;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface ParsedImageData {
|
|
26
|
-
base64: string;
|
|
27
|
-
mediaType: string;
|
|
28
|
-
}
|
|
5
|
+
const DEFAULT_MODEL_MAP = {
|
|
6
|
+
anthropic: 'claude-sonnet-4-20250514',
|
|
7
|
+
openai: 'gpt-4-vision',
|
|
8
|
+
gemini: 'gemini-pro-vision',
|
|
9
|
+
};
|
|
29
10
|
|
|
30
11
|
const DATA_URI_REGEX = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/;
|
|
31
12
|
|
|
32
13
|
function getMediaTypeFromUri(uri: string): string {
|
|
33
14
|
const normalized = uri.toLowerCase();
|
|
34
|
-
|
|
35
|
-
if (normalized.includes('.
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
if (normalized.includes('.webp')) {
|
|
39
|
-
return 'image/webp';
|
|
40
|
-
}
|
|
41
|
-
if (normalized.includes('.gif')) {
|
|
42
|
-
return 'image/gif';
|
|
43
|
-
}
|
|
44
|
-
|
|
15
|
+
if (normalized.includes('.png')) return 'image/png';
|
|
16
|
+
if (normalized.includes('.webp')) return 'image/webp';
|
|
17
|
+
if (normalized.includes('.gif')) return 'image/gif';
|
|
45
18
|
return 'image/jpeg';
|
|
46
19
|
}
|
|
47
20
|
|
|
@@ -56,7 +29,7 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
|
56
29
|
}
|
|
57
30
|
|
|
58
31
|
if (typeof btoa !== 'function') {
|
|
59
|
-
throw new Error('Base64 conversion
|
|
32
|
+
throw new Error('Base64 conversion unavailable. Provide uriToBase64 in hook options.');
|
|
60
33
|
}
|
|
61
34
|
|
|
62
35
|
return btoa(binary);
|
|
@@ -72,26 +45,44 @@ async function defaultUriToBase64(uri: string): Promise<string> {
|
|
|
72
45
|
return arrayBufferToBase64(imageBuffer);
|
|
73
46
|
}
|
|
74
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Analyzes an image with a vision-capable model and stores the generated description.
|
|
50
|
+
*
|
|
51
|
+
* @param options Vision configuration including provider credentials, model selection,
|
|
52
|
+
* request limits, and optional URI-to-base64 conversion for React Native environments.
|
|
53
|
+
* @returns Image analysis state with the latest description, loading/error flags, and
|
|
54
|
+
* actions to analyze a new image or clear the stored description.
|
|
55
|
+
*/
|
|
75
56
|
export function useImageAnalysis(options: UseImageAnalysisOptions): UseImageAnalysisReturn {
|
|
76
57
|
const [description, setDescription] = useState('');
|
|
77
58
|
const [isLoading, setIsLoading] = useState(false);
|
|
78
59
|
const [error, setError] = useState<string | null>(null);
|
|
79
60
|
|
|
61
|
+
const isMountedRef = useRef(true);
|
|
62
|
+
|
|
63
|
+
const providerConfig = useMemo(
|
|
64
|
+
() => ({
|
|
65
|
+
provider: (options.provider || 'anthropic') as 'anthropic' | 'openai' | 'gemini',
|
|
66
|
+
apiKey: options.apiKey,
|
|
67
|
+
model: options.model || DEFAULT_MODEL_MAP[options.provider || 'anthropic'],
|
|
68
|
+
baseUrl: options.baseUrl,
|
|
69
|
+
timeout: options.timeout,
|
|
70
|
+
maxRetries: options.maxRetries,
|
|
71
|
+
}),
|
|
72
|
+
[options],
|
|
73
|
+
);
|
|
74
|
+
|
|
80
75
|
const normalizeImage = useCallback(
|
|
81
|
-
async (image: string, mediaType?: string): Promise<
|
|
76
|
+
async (image: string, mediaType?: string): Promise<{ base64: string; mediaType: string }> => {
|
|
82
77
|
const dataUriMatch = image.match(DATA_URI_REGEX);
|
|
83
78
|
if (dataUriMatch) {
|
|
84
|
-
const matchedMediaType = dataUriMatch[1];
|
|
85
|
-
const base64Data = dataUriMatch[2];
|
|
86
79
|
return {
|
|
87
|
-
base64:
|
|
88
|
-
mediaType: mediaType ||
|
|
80
|
+
base64: dataUriMatch[2],
|
|
81
|
+
mediaType: mediaType || dataUriMatch[1],
|
|
89
82
|
};
|
|
90
83
|
}
|
|
91
84
|
|
|
92
|
-
const isLikelyUri = /^(https?:\/\/|file:\/\/|content:\/\/|ph:\/\/|assets-library:\/\/)/i.test(
|
|
93
|
-
image,
|
|
94
|
-
);
|
|
85
|
+
const isLikelyUri = /^(https?:\/\/|file:\/\/|content:\/\/|ph:\/\/|assets-library:\/\/)/i.test(image);
|
|
95
86
|
|
|
96
87
|
if (isLikelyUri) {
|
|
97
88
|
const toBase64 = options.uriToBase64 || defaultUriToBase64;
|
|
@@ -116,24 +107,39 @@ export function useImageAnalysis(options: UseImageAnalysisOptions): UseImageAnal
|
|
|
116
107
|
}, []);
|
|
117
108
|
|
|
118
109
|
const analyzeImage = useCallback(
|
|
119
|
-
async (
|
|
120
|
-
|
|
110
|
+
async (uri: string, prompt?: string) => {
|
|
111
|
+
if (!uri) {
|
|
112
|
+
setError('Image URI is required');
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!options.apiKey) {
|
|
117
|
+
setError('Missing API key');
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
121
|
setError(null);
|
|
122
|
+
setIsLoading(true);
|
|
122
123
|
|
|
123
124
|
try {
|
|
124
|
-
const
|
|
125
|
+
const imagData = await normalizeImage(uri);
|
|
126
|
+
const analysisPrompt = prompt || 'Describe this image in detail.';
|
|
125
127
|
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
128
|
+
const base_url = providerConfig.baseUrl || 'https://api.anthropic.com';
|
|
129
|
+
const headers: Record<string, string> = {
|
|
130
|
+
'Content-Type': 'application/json',
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
let url = '';
|
|
134
|
+
let body: Record<string, unknown> = {};
|
|
135
|
+
|
|
136
|
+
if (providerConfig.provider === 'anthropic') {
|
|
137
|
+
url = `${base_url}/v1/messages`;
|
|
138
|
+
headers['x-api-key'] = options.apiKey;
|
|
139
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
140
|
+
body = {
|
|
141
|
+
model: providerConfig.model,
|
|
135
142
|
max_tokens: options.maxTokens ?? 1024,
|
|
136
|
-
system: options.system,
|
|
137
143
|
messages: [
|
|
138
144
|
{
|
|
139
145
|
role: 'user',
|
|
@@ -142,47 +148,96 @@ export function useImageAnalysis(options: UseImageAnalysisOptions): UseImageAnal
|
|
|
142
148
|
type: 'image',
|
|
143
149
|
source: {
|
|
144
150
|
type: 'base64',
|
|
145
|
-
media_type:
|
|
146
|
-
data:
|
|
151
|
+
media_type: imagData.mediaType,
|
|
152
|
+
data: imagData.base64,
|
|
147
153
|
},
|
|
148
154
|
},
|
|
149
155
|
{
|
|
150
156
|
type: 'text',
|
|
151
|
-
text:
|
|
157
|
+
text: analysisPrompt,
|
|
152
158
|
},
|
|
153
159
|
],
|
|
154
160
|
},
|
|
155
161
|
],
|
|
156
|
-
}
|
|
162
|
+
};
|
|
163
|
+
} else if (providerConfig.provider === 'openai') {
|
|
164
|
+
url = `${providerConfig.baseUrl || 'https://api.openai.com'}/v1/chat/completions`;
|
|
165
|
+
headers.Authorization = `Bearer ${options.apiKey}`;
|
|
166
|
+
body = {
|
|
167
|
+
model: providerConfig.model,
|
|
168
|
+
max_tokens: options.maxTokens ?? 1024,
|
|
169
|
+
messages: [
|
|
170
|
+
{
|
|
171
|
+
role: 'user',
|
|
172
|
+
content: [
|
|
173
|
+
{
|
|
174
|
+
type: 'image_url',
|
|
175
|
+
image_url: {
|
|
176
|
+
url: `data:${imagData.mediaType};base64,${imagData.base64}`,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
type: 'text',
|
|
181
|
+
text: analysisPrompt,
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
} else {
|
|
188
|
+
throw new Error(`Image analysis not supported for provider: ${providerConfig.provider}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const response = await fetch(url, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers,
|
|
194
|
+
body: JSON.stringify(body),
|
|
157
195
|
});
|
|
158
196
|
|
|
159
|
-
if (!
|
|
160
|
-
const errorText = await
|
|
161
|
-
throw new Error(errorText || `
|
|
197
|
+
if (!response.ok) {
|
|
198
|
+
const errorText = await response.text();
|
|
199
|
+
throw new Error(errorText || `API error: ${response.status}`);
|
|
162
200
|
}
|
|
163
201
|
|
|
164
|
-
const data = await
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
)
|
|
202
|
+
const data = await response.json();
|
|
203
|
+
let resultText = '';
|
|
204
|
+
|
|
205
|
+
if (providerConfig.provider === 'anthropic') {
|
|
206
|
+
resultText = data?.content?.[0]?.text || '';
|
|
207
|
+
} else if (providerConfig.provider === 'openai') {
|
|
208
|
+
resultText = data?.choices?.[0]?.message?.content || '';
|
|
209
|
+
}
|
|
168
210
|
|
|
169
|
-
if (!
|
|
170
|
-
throw new Error('No description returned
|
|
211
|
+
if (!resultText) {
|
|
212
|
+
throw new Error('No description returned by vision API');
|
|
171
213
|
}
|
|
172
214
|
|
|
173
|
-
|
|
174
|
-
|
|
215
|
+
if (isMountedRef.current) {
|
|
216
|
+
setDescription(resultText);
|
|
217
|
+
}
|
|
218
|
+
return resultText;
|
|
175
219
|
} catch (err) {
|
|
176
|
-
|
|
177
|
-
|
|
220
|
+
if (isMountedRef.current) {
|
|
221
|
+
const message = err instanceof Error ? err.message : 'Failed to analyze image';
|
|
222
|
+
setError(message);
|
|
223
|
+
}
|
|
178
224
|
return null;
|
|
179
225
|
} finally {
|
|
180
|
-
|
|
226
|
+
if (isMountedRef.current) {
|
|
227
|
+
setIsLoading(false);
|
|
228
|
+
}
|
|
181
229
|
}
|
|
182
230
|
},
|
|
183
|
-
[normalizeImage, options],
|
|
231
|
+
[normalizeImage, options, providerConfig],
|
|
184
232
|
);
|
|
185
233
|
|
|
234
|
+
useState(() => {
|
|
235
|
+
isMountedRef.current = true;
|
|
236
|
+
return () => {
|
|
237
|
+
isMountedRef.current = false;
|
|
238
|
+
};
|
|
239
|
+
}, []);
|
|
240
|
+
|
|
186
241
|
return {
|
|
187
242
|
description,
|
|
188
243
|
isLoading,
|
package/src/index.ts
CHANGED
|
@@ -5,4 +5,28 @@ export { useAIForm } from './hooks/useAIForm';
|
|
|
5
5
|
export { useAIVoice } from './hooks/useAIVoice';
|
|
6
6
|
export { useAITranslate } from './hooks/useAITranslate';
|
|
7
7
|
export { useAISummarize } from './hooks/useAISummarize';
|
|
8
|
-
export { useAICode } from './hooks/useAICode';
|
|
8
|
+
export { useAICode } from './hooks/useAICode';
|
|
9
|
+
|
|
10
|
+
// Type exports
|
|
11
|
+
export type {
|
|
12
|
+
Message,
|
|
13
|
+
AIProviderType,
|
|
14
|
+
ProviderConfig,
|
|
15
|
+
AIResponse,
|
|
16
|
+
AIRequestOptions,
|
|
17
|
+
UseAIChatOptions,
|
|
18
|
+
UseAIChatReturn,
|
|
19
|
+
UseAIStreamOptions,
|
|
20
|
+
UseAIStreamReturn,
|
|
21
|
+
UseImageAnalysisOptions,
|
|
22
|
+
UseImageAnalysisReturn,
|
|
23
|
+
UseAIFormOptions,
|
|
24
|
+
UseAIFormReturn,
|
|
25
|
+
FormValidationRequest,
|
|
26
|
+
FormValidationResult,
|
|
27
|
+
} from './types';
|
|
28
|
+
|
|
29
|
+
// Utility exports for advanced use cases
|
|
30
|
+
export { createProvider, ProviderFactory } from './utils/providerFactory';
|
|
31
|
+
export { fetchWithRetry } from './utils/fetchWithRetry';
|
|
32
|
+
export type { AIResponse, AIRequestOptions } from './utils/providerFactory';
|
package/src/types/index.ts
CHANGED
|
@@ -1,15 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Message Types
|
|
3
|
+
*/
|
|
1
4
|
export interface Message {
|
|
2
5
|
role: 'user' | 'assistant';
|
|
3
6
|
content: string;
|
|
7
|
+
timestamp?: number;
|
|
4
8
|
}
|
|
5
9
|
|
|
6
|
-
|
|
10
|
+
/**
|
|
11
|
+
* AI Provider Types
|
|
12
|
+
*/
|
|
13
|
+
export type AIProviderType = 'anthropic' | 'openai' | 'gemini';
|
|
14
|
+
|
|
15
|
+
export interface ProviderConfig {
|
|
16
|
+
provider: AIProviderType;
|
|
7
17
|
apiKey: string;
|
|
8
|
-
|
|
9
|
-
|
|
18
|
+
model: string;
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
timeout?: number;
|
|
21
|
+
maxRetries?: number;
|
|
10
22
|
}
|
|
11
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Standardized API Response
|
|
26
|
+
*/
|
|
12
27
|
export interface AIResponse {
|
|
13
|
-
|
|
28
|
+
text: string;
|
|
29
|
+
raw: Record<string, unknown>;
|
|
30
|
+
usage?: {
|
|
31
|
+
inputTokens?: number;
|
|
32
|
+
outputTokens?: number;
|
|
33
|
+
totalTokens?: number;
|
|
34
|
+
};
|
|
14
35
|
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* API Request Options
|
|
40
|
+
*/
|
|
41
|
+
export interface AIRequestOptions {
|
|
42
|
+
system?: string;
|
|
43
|
+
temperature?: number;
|
|
44
|
+
maxTokens?: number;
|
|
45
|
+
topP?: number;
|
|
46
|
+
stopSequences?: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Hook Options Interface
|
|
51
|
+
*/
|
|
52
|
+
export interface UseAIChatOptions {
|
|
53
|
+
apiKey: string;
|
|
54
|
+
provider?: AIProviderType;
|
|
55
|
+
model?: string;
|
|
56
|
+
system?: string;
|
|
57
|
+
temperature?: number;
|
|
58
|
+
maxTokens?: number;
|
|
59
|
+
baseUrl?: string;
|
|
60
|
+
timeout?: number;
|
|
61
|
+
maxRetries?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface UseAIStreamOptions extends UseAIChatOptions {}
|
|
65
|
+
|
|
66
|
+
export interface UseAIFormOptions {
|
|
67
|
+
apiKey: string;
|
|
68
|
+
provider?: AIProviderType;
|
|
69
|
+
model?: string;
|
|
70
|
+
system?: string;
|
|
71
|
+
temperature?: number;
|
|
72
|
+
maxTokens?: number;
|
|
73
|
+
baseUrl?: string;
|
|
74
|
+
timeout?: number;
|
|
75
|
+
maxRetries?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface UseImageAnalysisOptions {
|
|
79
|
+
apiKey: string;
|
|
80
|
+
provider?: AIProviderType;
|
|
81
|
+
model?: string;
|
|
82
|
+
system?: string;
|
|
83
|
+
maxTokens?: number;
|
|
84
|
+
baseUrl?: string;
|
|
85
|
+
timeout?: number;
|
|
86
|
+
maxRetries?: number;
|
|
87
|
+
uriToBase64?: (uri: string) => Promise<string>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Normalized Internal API Structures
|
|
92
|
+
*/
|
|
93
|
+
export interface NormalizedMessage {
|
|
94
|
+
role: 'user' | 'assistant';
|
|
95
|
+
content: NormalizedContent;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type NormalizedContent =
|
|
99
|
+
| { type: 'text'; text: string }
|
|
100
|
+
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
|
|
101
|
+
| Array<NormalizedContent>;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Provider-Specific Raw Responses
|
|
105
|
+
*/
|
|
106
|
+
export interface AnthropicResponse {
|
|
107
|
+
content?: Array<{ type: string; text?: string }>;
|
|
108
|
+
error?: { type: string; message?: string };
|
|
109
|
+
usage?: {
|
|
110
|
+
input_tokens?: number;
|
|
111
|
+
output_tokens?: number;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface OpenAIResponse {
|
|
116
|
+
choices?: Array<{ message?: { content?: string } }>;
|
|
117
|
+
error?: { message?: string };
|
|
118
|
+
usage?: {
|
|
119
|
+
prompt_tokens?: number;
|
|
120
|
+
completion_tokens?: number;
|
|
121
|
+
total_tokens?: number;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface GeminiResponse {
|
|
126
|
+
candidates?: Array<{
|
|
127
|
+
content?: {
|
|
128
|
+
parts?: Array<{ text?: string }>;
|
|
129
|
+
};
|
|
130
|
+
}>;
|
|
131
|
+
error?: { message?: string };
|
|
132
|
+
usageMetadata?: {
|
|
133
|
+
promptTokenCount?: number;
|
|
134
|
+
candidatesTokenCount?: number;
|
|
135
|
+
totalTokenCount?: number;
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Form Validation Input/Output
|
|
141
|
+
*/
|
|
142
|
+
export interface FormValidationRequest {
|
|
143
|
+
formData: Record<string, unknown>;
|
|
144
|
+
validationSchema?: Record<string, string>;
|
|
145
|
+
customInstructions?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface FormValidationResult {
|
|
149
|
+
isValid: boolean;
|
|
150
|
+
errors: Record<string, string>;
|
|
151
|
+
raw: unknown;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Generic Hook Return Types
|
|
156
|
+
*/
|
|
157
|
+
export interface UseAIChatReturn {
|
|
158
|
+
messages: Message[];
|
|
159
|
+
isLoading: boolean;
|
|
160
|
+
error: string | null;
|
|
161
|
+
sendMessage: (content: string) => Promise<void>;
|
|
162
|
+
abort: () => void;
|
|
163
|
+
clearMessages: () => void;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface UseAIStreamReturn {
|
|
167
|
+
response: string;
|
|
168
|
+
isLoading: boolean;
|
|
169
|
+
error: string | null;
|
|
170
|
+
streamResponse: (prompt: string) => Promise<void>;
|
|
171
|
+
abort: () => void;
|
|
172
|
+
clearResponse: () => void;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface UseImageAnalysisReturn {
|
|
176
|
+
description: string;
|
|
177
|
+
isLoading: boolean;
|
|
178
|
+
error: string | null;
|
|
179
|
+
analyzeImage: (uri: string, prompt?: string) => Promise<string | null>;
|
|
180
|
+
clearDescription: () => void;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface UseAIFormReturn {
|
|
184
|
+
validationResult: FormValidationResult | null;
|
|
185
|
+
isLoading: boolean;
|
|
186
|
+
error: string | null;
|
|
187
|
+
validateForm: (input: FormValidationRequest) => Promise<FormValidationResult | null>;
|
|
188
|
+
clearValidation: () => void;
|
|
15
189
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { fetchWithRetry } from '../fetchWithRetry';
|
|
2
|
+
|
|
3
|
+
describe('fetchWithRetry', () => {
|
|
4
|
+
let fetchSpy: jest.SpyInstance<Promise<Response>, Parameters<typeof fetch>>;
|
|
5
|
+
|
|
6
|
+
const createResponse = (status: number, body: unknown, retryAfter?: string): Response => {
|
|
7
|
+
return {
|
|
8
|
+
ok: status >= 200 && status < 300,
|
|
9
|
+
status,
|
|
10
|
+
headers: {
|
|
11
|
+
get: (name: string) => {
|
|
12
|
+
if (name.toLowerCase() === 'retry-after') {
|
|
13
|
+
return retryAfter ?? null;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
json: async () => body,
|
|
19
|
+
text: async () => JSON.stringify(body),
|
|
20
|
+
} as unknown as Response;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
fetchSpy = jest.spyOn(global, 'fetch');
|
|
25
|
+
fetchSpy.mockReset();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
fetchSpy.mockRestore();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('retries 429 responses with exponential backoff delays', async () => {
|
|
33
|
+
const allDelays: number[] = [];
|
|
34
|
+
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0);
|
|
35
|
+
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((callback: TimerHandler, delay?: number) => {
|
|
36
|
+
allDelays.push(Number(delay));
|
|
37
|
+
if (typeof callback === 'function') {
|
|
38
|
+
callback();
|
|
39
|
+
}
|
|
40
|
+
return 0 as unknown as ReturnType<typeof setTimeout>;
|
|
41
|
+
}) as typeof setTimeout);
|
|
42
|
+
|
|
43
|
+
fetchSpy
|
|
44
|
+
.mockResolvedValueOnce(createResponse(429, { error: 'rate_limited' }, '1'))
|
|
45
|
+
.mockResolvedValueOnce(createResponse(429, { error: 'rate_limited' }))
|
|
46
|
+
.mockResolvedValueOnce(createResponse(200, { ok: true }));
|
|
47
|
+
|
|
48
|
+
const response = await fetchWithRetry(
|
|
49
|
+
'https://api.example.com/test',
|
|
50
|
+
{ method: 'POST' },
|
|
51
|
+
{ maxRetries: 2, baseDelay: 1000, backoffMultiplier: 2, timeout: 1000 },
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(response.status).toBe(200);
|
|
55
|
+
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
|
56
|
+
// allDelays contains: [abort_timeout, retry_delay, abort_timeout, retry_delay, abort_timeout]
|
|
57
|
+
// Extract only the odd-indexed elements (1, 3) which are the retry delays
|
|
58
|
+
const retryDelays = [allDelays[1], allDelays[3]];
|
|
59
|
+
expect(retryDelays).toEqual([1000, 2000]);
|
|
60
|
+
|
|
61
|
+
randomSpy.mockRestore();
|
|
62
|
+
setTimeoutSpy.mockRestore();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('uses default retry options when none are provided', async () => {
|
|
66
|
+
fetchSpy.mockResolvedValueOnce(createResponse(200, { ok: true }));
|
|
67
|
+
|
|
68
|
+
const response = await fetchWithRetry('https://api.example.com/default-options');
|
|
69
|
+
|
|
70
|
+
expect(response.status).toBe(200);
|
|
71
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('retries 5xx responses with jittered backoff and then succeeds', async () => {
|
|
75
|
+
const allDelays: number[] = [];
|
|
76
|
+
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0);
|
|
77
|
+
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((callback: TimerHandler, delay?: number) => {
|
|
78
|
+
allDelays.push(Number(delay));
|
|
79
|
+
if (typeof callback === 'function') {
|
|
80
|
+
callback();
|
|
81
|
+
}
|
|
82
|
+
return 0 as unknown as ReturnType<typeof setTimeout>;
|
|
83
|
+
}) as typeof setTimeout);
|
|
84
|
+
|
|
85
|
+
fetchSpy
|
|
86
|
+
.mockResolvedValueOnce(createResponse(500, { error: 'server_error' }))
|
|
87
|
+
.mockResolvedValueOnce(createResponse(200, { ok: true }));
|
|
88
|
+
|
|
89
|
+
const response = await fetchWithRetry(
|
|
90
|
+
'https://api.example.com/test',
|
|
91
|
+
{ method: 'POST' },
|
|
92
|
+
{ maxRetries: 1, baseDelay: 1000, timeout: 30000 },
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(response.status).toBe(200);
|
|
96
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
97
|
+
// [attempt1 timeout, retry delay, attempt2 timeout]
|
|
98
|
+
expect(allDelays[1]).toBe(1000);
|
|
99
|
+
|
|
100
|
+
randomSpy.mockRestore();
|
|
101
|
+
setTimeoutSpy.mockRestore();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('retries AbortError failures and then succeeds', async () => {
|
|
105
|
+
const allDelays: number[] = [];
|
|
106
|
+
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0);
|
|
107
|
+
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((callback: TimerHandler, delay?: number) => {
|
|
108
|
+
allDelays.push(Number(delay));
|
|
109
|
+
if (typeof callback === 'function') {
|
|
110
|
+
callback();
|
|
111
|
+
}
|
|
112
|
+
return 0 as unknown as ReturnType<typeof setTimeout>;
|
|
113
|
+
}) as typeof setTimeout);
|
|
114
|
+
|
|
115
|
+
const abortError = Object.assign(new Error('timeout'), { name: 'AbortError' });
|
|
116
|
+
|
|
117
|
+
fetchSpy
|
|
118
|
+
.mockRejectedValueOnce(abortError)
|
|
119
|
+
.mockResolvedValueOnce(createResponse(200, { ok: true }));
|
|
120
|
+
|
|
121
|
+
const response = await fetchWithRetry(
|
|
122
|
+
'https://api.example.com/test',
|
|
123
|
+
{ method: 'GET' },
|
|
124
|
+
{ maxRetries: 1, baseDelay: 500, timeout: 1234 },
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(response.status).toBe(200);
|
|
128
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
129
|
+
// [attempt1 timeout, retry delay, attempt2 timeout]
|
|
130
|
+
expect(allDelays[1]).toBe(500);
|
|
131
|
+
|
|
132
|
+
randomSpy.mockRestore();
|
|
133
|
+
setTimeoutSpy.mockRestore();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('throws immediately when an error is explicitly marked non-retryable', async () => {
|
|
137
|
+
const nonRetryableError = Object.assign(new Error('do not retry'), { isRetryable: false });
|
|
138
|
+
|
|
139
|
+
fetchSpy.mockRejectedValueOnce(nonRetryableError);
|
|
140
|
+
|
|
141
|
+
await expect(
|
|
142
|
+
fetchWithRetry('https://api.example.com/test', { method: 'GET' }, { maxRetries: 3 }),
|
|
143
|
+
).rejects.toThrow('do not retry');
|
|
144
|
+
|
|
145
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('retries unknown errors until max retries are exhausted', async () => {
|
|
149
|
+
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0);
|
|
150
|
+
const retryError = new Error('network down');
|
|
151
|
+
|
|
152
|
+
fetchSpy.mockRejectedValue(retryError);
|
|
153
|
+
|
|
154
|
+
await expect(
|
|
155
|
+
fetchWithRetry('https://api.example.com/test', { method: 'GET' }, { maxRetries: 1, baseDelay: 1, timeout: 1 }),
|
|
156
|
+
).rejects.toThrow('network down');
|
|
157
|
+
|
|
158
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
159
|
+
|
|
160
|
+
randomSpy.mockRestore();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('throws a fallback error when retries are disabled before entering the loop', async () => {
|
|
164
|
+
await expect(
|
|
165
|
+
fetchWithRetry('https://api.example.com/test', { method: 'GET' }, { maxRetries: -1 }),
|
|
166
|
+
).rejects.toThrow('Fetch failed after retries');
|
|
167
|
+
});
|
|
168
|
+
});
|