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.
Files changed (37) hide show
  1. package/.github/workflows/ci.yml +34 -0
  2. package/CONTRIBUTING.md +122 -0
  3. package/README.md +73 -20
  4. package/docs/ARCHITECTURE.md +301 -0
  5. package/docs/ARCHITECTURE_GUIDE.md +467 -0
  6. package/docs/IMPLEMENTATION_COMPLETE.md +349 -0
  7. package/docs/README.md +17 -0
  8. package/docs/TECHNICAL_SPECIFICATION.md +748 -0
  9. package/example/App.tsx +95 -0
  10. package/example/README.md +27 -0
  11. package/example/index.js +5 -0
  12. package/example/package.json +22 -0
  13. package/example/src/components/ProviderPicker.tsx +62 -0
  14. package/example/src/context/APIKeysContext.tsx +96 -0
  15. package/example/src/screens/ChatScreen.tsx +205 -0
  16. package/example/src/screens/SettingsScreen.tsx +124 -0
  17. package/example/tsconfig.json +7 -0
  18. package/jest.config.cjs +7 -0
  19. package/jest.setup.ts +28 -0
  20. package/package.json +17 -3
  21. package/src/hooks/__tests__/useAIForm.test.ts +345 -0
  22. package/src/hooks/__tests__/useAIStream.test.ts +427 -0
  23. package/src/hooks/useAIChat.ts +111 -51
  24. package/src/hooks/useAICode.ts +8 -0
  25. package/src/hooks/useAIForm.ts +92 -202
  26. package/src/hooks/useAIStream.ts +114 -58
  27. package/src/hooks/useAISummarize.ts +8 -0
  28. package/src/hooks/useAITranslate.ts +9 -0
  29. package/src/hooks/useAIVoice.ts +8 -0
  30. package/src/hooks/useImageAnalysis.ts +134 -79
  31. package/src/index.ts +25 -1
  32. package/src/types/index.ts +178 -4
  33. package/src/utils/__tests__/fetchWithRetry.test.ts +168 -0
  34. package/src/utils/__tests__/providerFactory.test.ts +493 -0
  35. package/src/utils/fetchWithRetry.ts +100 -0
  36. package/src/utils/index.ts +8 -0
  37. package/src/utils/providerFactory.ts +288 -0
package/package.json CHANGED
@@ -1,17 +1,31 @@
1
1
  {
2
2
  "name": "react-native-ai-hooks",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "AI hooks for React Native — useAIChat, useAIStream, useImageAnalysis. Works with Claude, OpenAI & Gemini.",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
- "keywords": ["react-native", "ai", "claude", "openai", "hooks", "expo"],
7
+ "scripts": {
8
+ "test": "jest"
9
+ },
10
+ "keywords": [
11
+ "react-native",
12
+ "ai",
13
+ "claude",
14
+ "openai",
15
+ "hooks",
16
+ "expo"
17
+ ],
8
18
  "author": "nikapkh",
9
19
  "license": "MIT",
10
20
  "devDependencies": {
21
+ "@types/jest": "^29.5.14",
22
+ "jest": "^29.7.0",
23
+ "react-test-renderer": "^19.2.0",
24
+ "ts-jest": "^29.4.9",
11
25
  "typescript": "^5.0.0"
12
26
  },
13
27
  "peerDependencies": {
14
28
  "react": ">=18.0.0",
15
29
  "react-native": ">=0.70.0"
16
30
  }
17
- }
31
+ }
@@ -0,0 +1,345 @@
1
+ import React from 'react';
2
+ import { act, create } from 'react-test-renderer';
3
+ import { useAIForm } from '../useAIForm';
4
+ import { createProvider } from '../../utils/providerFactory';
5
+
6
+ jest.mock('../../utils/providerFactory', () => ({
7
+ createProvider: jest.fn(),
8
+ }));
9
+
10
+ type HookRenderResult<T> = {
11
+ result: {
12
+ readonly current: T;
13
+ };
14
+ unmount: () => void;
15
+ };
16
+
17
+ function renderHook<T>(hook: () => T): HookRenderResult<T> {
18
+ let hookValue: T | undefined;
19
+ let renderer: ReturnType<typeof create> | undefined;
20
+
21
+ function TestComponent() {
22
+ hookValue = hook();
23
+ return null;
24
+ }
25
+
26
+ act(() => {
27
+ renderer = create(React.createElement(TestComponent));
28
+ });
29
+
30
+ return {
31
+ result: {
32
+ get current(): T {
33
+ if (hookValue === undefined) {
34
+ throw new Error('Hook value is not available yet');
35
+ }
36
+ return hookValue;
37
+ },
38
+ },
39
+ unmount: () => {
40
+ if (!renderer) {
41
+ return;
42
+ }
43
+ act(() => {
44
+ renderer.unmount();
45
+ });
46
+ },
47
+ };
48
+ }
49
+
50
+ describe('useAIForm', () => {
51
+ const mockedCreateProvider = createProvider as jest.MockedFunction<typeof createProvider>;
52
+
53
+ beforeEach(() => {
54
+ mockedCreateProvider.mockReset();
55
+ });
56
+
57
+ it('populates validation errors from AI JSON response', async () => {
58
+ const makeRequest = jest.fn().mockResolvedValue({
59
+ text: '{"errors":{"email":"Invalid email","password":"Must be at least 8 characters"}}',
60
+ raw: {},
61
+ });
62
+
63
+ mockedCreateProvider.mockReturnValue({ makeRequest } as unknown as ReturnType<typeof createProvider>);
64
+
65
+ const { result, unmount } = renderHook(() =>
66
+ useAIForm({
67
+ provider: 'openai',
68
+ apiKey: 'test-key',
69
+ }),
70
+ );
71
+
72
+ let validationResult: Awaited<ReturnType<typeof result.current.validateForm>> | undefined;
73
+
74
+ await act(async () => {
75
+ validationResult = await result.current.validateForm({
76
+ formData: { email: 'not-an-email', password: '123' },
77
+ validationSchema: { email: 'email', password: 'minLength:8' },
78
+ });
79
+ });
80
+
81
+ expect(validationResult).toEqual({
82
+ isValid: false,
83
+ errors: {
84
+ email: 'Invalid email',
85
+ password: 'Must be at least 8 characters',
86
+ },
87
+ raw: {
88
+ errors: {
89
+ email: 'Invalid email',
90
+ password: 'Must be at least 8 characters',
91
+ },
92
+ },
93
+ });
94
+
95
+ expect(result.current.validationResult).toEqual(validationResult);
96
+ expect(result.current.error).toBeNull();
97
+ expect(result.current.isLoading).toBe(false);
98
+
99
+ expect(makeRequest).toHaveBeenCalledTimes(1);
100
+ const providerRequest = makeRequest.mock.calls[0][0] as { prompt: string; options: Record<string, unknown> };
101
+ expect(providerRequest.prompt).toContain('Validation schema: {"email":"email","password":"minLength:8"}');
102
+ expect(providerRequest.prompt).toContain('"email":"not-an-email"');
103
+ expect(providerRequest.options).toEqual(
104
+ expect.objectContaining({
105
+ temperature: 0.2,
106
+ maxTokens: 800,
107
+ }),
108
+ );
109
+
110
+ unmount();
111
+ });
112
+
113
+ it('supports fenced JSON responses and parses errors correctly', async () => {
114
+ const makeRequest = jest.fn().mockResolvedValue({
115
+ text: '```json\n{"errors":{"name":"Name is required"}}\n```',
116
+ raw: {},
117
+ });
118
+
119
+ mockedCreateProvider.mockReturnValue({ makeRequest } as unknown as ReturnType<typeof createProvider>);
120
+
121
+ const { result, unmount } = renderHook(() =>
122
+ useAIForm({
123
+ provider: 'anthropic',
124
+ apiKey: 'anthropic-key',
125
+ }),
126
+ );
127
+
128
+ let validationResult: Awaited<ReturnType<typeof result.current.validateForm>> | undefined;
129
+
130
+ await act(async () => {
131
+ validationResult = await result.current.validateForm({
132
+ formData: { name: '' },
133
+ });
134
+ });
135
+
136
+ expect(validationResult).toEqual({
137
+ isValid: false,
138
+ errors: {
139
+ name: 'Name is required',
140
+ },
141
+ raw: {
142
+ errors: {
143
+ name: 'Name is required',
144
+ },
145
+ },
146
+ });
147
+ expect(result.current.validationResult).toEqual(validationResult);
148
+ expect(result.current.error).toBeNull();
149
+
150
+ unmount();
151
+ });
152
+
153
+ it('returns null and sets error when form data is empty', async () => {
154
+ const makeRequest = jest.fn();
155
+ mockedCreateProvider.mockReturnValue({ makeRequest } as unknown as ReturnType<typeof createProvider>);
156
+
157
+ const { result, unmount } = renderHook(() =>
158
+ useAIForm({
159
+ provider: 'openai',
160
+ apiKey: 'test-key',
161
+ }),
162
+ );
163
+
164
+ let validationResult: Awaited<ReturnType<typeof result.current.validateForm>> | undefined;
165
+ await act(async () => {
166
+ validationResult = await result.current.validateForm({
167
+ formData: {},
168
+ });
169
+ });
170
+
171
+ expect(validationResult).toBeNull();
172
+ expect(result.current.error).toBe('Form data is empty');
173
+ expect(makeRequest).not.toHaveBeenCalled();
174
+
175
+ unmount();
176
+ });
177
+
178
+ it('returns null and sets error when API key is missing', async () => {
179
+ const makeRequest = jest.fn();
180
+ mockedCreateProvider.mockReturnValue({ makeRequest } as unknown as ReturnType<typeof createProvider>);
181
+
182
+ const { result, unmount } = renderHook(() =>
183
+ useAIForm({
184
+ provider: 'openai',
185
+ apiKey: '',
186
+ }),
187
+ );
188
+
189
+ let validationResult: Awaited<ReturnType<typeof result.current.validateForm>> | undefined;
190
+ await act(async () => {
191
+ validationResult = await result.current.validateForm({
192
+ formData: { email: 'x@example.com' },
193
+ });
194
+ });
195
+
196
+ expect(validationResult).toBeNull();
197
+ expect(result.current.error).toBe('Missing API key');
198
+ expect(makeRequest).not.toHaveBeenCalled();
199
+
200
+ unmount();
201
+ });
202
+
203
+ it('captures provider/parsing failures in error state', async () => {
204
+ const makeRequest = jest.fn().mockRejectedValue(new Error('Provider failed'));
205
+ mockedCreateProvider.mockReturnValue({ makeRequest } as unknown as ReturnType<typeof createProvider>);
206
+
207
+ const { result, unmount } = renderHook(() =>
208
+ useAIForm({
209
+ provider: 'openai',
210
+ apiKey: 'test-key',
211
+ }),
212
+ );
213
+
214
+ let validationResult: Awaited<ReturnType<typeof result.current.validateForm>> | undefined;
215
+ await act(async () => {
216
+ validationResult = await result.current.validateForm({
217
+ formData: { email: 'bad' },
218
+ });
219
+ });
220
+
221
+ expect(validationResult).toBeNull();
222
+ expect(result.current.error).toBe('Provider failed');
223
+ expect(result.current.isLoading).toBe(false);
224
+
225
+ unmount();
226
+ });
227
+
228
+ it('clears prior validation result and error when clearValidation is called', async () => {
229
+ const makeRequest = jest
230
+ .fn()
231
+ .mockResolvedValueOnce({
232
+ text: '{"errors":{"email":"Invalid email"}}',
233
+ raw: {},
234
+ })
235
+ .mockRejectedValueOnce(new Error('Second call failed'));
236
+
237
+ mockedCreateProvider.mockReturnValue({ makeRequest } as unknown as ReturnType<typeof createProvider>);
238
+
239
+ const { result, unmount } = renderHook(() =>
240
+ useAIForm({
241
+ provider: 'openai',
242
+ apiKey: 'test-key',
243
+ }),
244
+ );
245
+
246
+ await act(async () => {
247
+ await result.current.validateForm({
248
+ formData: { email: 'bad' },
249
+ });
250
+ });
251
+
252
+ expect(result.current.validationResult).not.toBeNull();
253
+
254
+ await act(async () => {
255
+ await result.current.validateForm({
256
+ formData: { email: 'bad-again' },
257
+ });
258
+ });
259
+
260
+ expect(result.current.error).toBe('Second call failed');
261
+
262
+ act(() => {
263
+ result.current.clearValidation();
264
+ });
265
+
266
+ expect(result.current.validationResult).toBeNull();
267
+ expect(result.current.error).toBeNull();
268
+
269
+ unmount();
270
+ });
271
+
272
+ it('builds provider config with defaults when provider/model are omitted', () => {
273
+ const makeRequest = jest.fn();
274
+ mockedCreateProvider.mockReturnValue({ makeRequest } as unknown as ReturnType<typeof createProvider>);
275
+
276
+ const { unmount } = renderHook(() =>
277
+ useAIForm({
278
+ apiKey: 'default-key',
279
+ }),
280
+ );
281
+
282
+ expect(mockedCreateProvider).toHaveBeenCalledWith(
283
+ expect.objectContaining({
284
+ provider: 'anthropic',
285
+ model: 'claude-sonnet-4-20250514',
286
+ apiKey: 'default-key',
287
+ }),
288
+ );
289
+
290
+ unmount();
291
+ });
292
+
293
+ it('treats missing errors object in AI response as a valid form result', async () => {
294
+ const makeRequest = jest.fn().mockResolvedValue({
295
+ text: '{}',
296
+ raw: {},
297
+ });
298
+
299
+ mockedCreateProvider.mockReturnValue({ makeRequest } as unknown as ReturnType<typeof createProvider>);
300
+
301
+ const { result, unmount } = renderHook(() =>
302
+ useAIForm({
303
+ apiKey: 'test-key',
304
+ }),
305
+ );
306
+
307
+ let validationResult: Awaited<ReturnType<typeof result.current.validateForm>> | undefined;
308
+ await act(async () => {
309
+ validationResult = await result.current.validateForm({
310
+ formData: { name: 'Jane' },
311
+ });
312
+ });
313
+
314
+ expect(validationResult).toEqual({
315
+ isValid: true,
316
+ errors: {},
317
+ raw: {},
318
+ });
319
+ expect(result.current.error).toBeNull();
320
+
321
+ unmount();
322
+ });
323
+
324
+ it('uses fallback error message when provider throws a non-Error value', async () => {
325
+ const makeRequest = jest.fn().mockRejectedValue('bad response');
326
+ mockedCreateProvider.mockReturnValue({ makeRequest } as unknown as ReturnType<typeof createProvider>);
327
+
328
+ const { result, unmount } = renderHook(() =>
329
+ useAIForm({
330
+ apiKey: 'test-key',
331
+ }),
332
+ );
333
+
334
+ await act(async () => {
335
+ await result.current.validateForm({
336
+ formData: { email: 'x@example.com' },
337
+ });
338
+ });
339
+
340
+ expect(result.current.error).toBe('Failed to validate form');
341
+ expect(result.current.isLoading).toBe(false);
342
+
343
+ unmount();
344
+ });
345
+ });