react-native-ai-hooks 0.3.0 → 0.4.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.
@@ -1,57 +1,12 @@
1
- import { useCallback, useRef, useState } from 'react';
2
-
3
- interface UseAIFormOptions {
4
- apiKey: string;
5
- model?: string;
6
- system?: string;
7
- maxTokens?: number;
8
- temperature?: number;
9
- }
10
-
11
- interface ValidateFieldInput {
12
- fieldName: string;
13
- value: string;
14
- formData?: Record<string, string>;
15
- validationRule?: string;
16
- }
17
-
18
- interface AutocompleteFieldInput {
19
- fieldName: string;
20
- value: string;
21
- formData?: Record<string, string>;
22
- instruction?: string;
23
- maxSuggestions?: number;
24
- }
25
-
26
- interface AIFieldValidation {
27
- isValid: boolean;
28
- feedback: string;
29
- suggestion?: string;
30
- }
1
+ import { useCallback, useRef, useState, useMemo } from 'react';
2
+ import type { UseAIFormOptions, UseAIFormReturn, FormValidationRequest, FormValidationResult } from '../types';
3
+ import { createProvider } from '../utils/providerFactory';
31
4
 
32
- interface UseAIFormReturn {
33
- validations: Record<string, AIFieldValidation>;
34
- autocomplete: Record<string, string[]>;
35
- isLoading: boolean;
36
- error: string | null;
37
- validateField: (input: ValidateFieldInput) => Promise<AIFieldValidation | null>;
38
- autocompleteField: (input: AutocompleteFieldInput) => Promise<string[] | null>;
39
- clearFormAI: () => void;
40
- cancelRequests: () => void;
41
- }
42
-
43
- function extractTextContent(data: unknown): string {
44
- const content = (data as { content?: Array<{ type?: string; text?: string }> })?.content;
45
- if (!Array.isArray(content)) {
46
- return '';
47
- }
48
-
49
- return content
50
- .filter(item => item?.type === 'text' && typeof item.text === 'string')
51
- .map(item => item.text as string)
52
- .join('\n')
53
- .trim();
54
- }
5
+ const DEFAULT_MODEL_MAP = {
6
+ anthropic: 'claude-sonnet-4-20250514',
7
+ openai: 'gpt-4',
8
+ gemini: 'gemini-pro',
9
+ };
55
10
 
56
11
  function parseJsonFromText<T>(text: string): T {
57
12
  const trimmed = text.trim();
@@ -61,185 +16,112 @@ function parseJsonFromText<T>(text: string): T {
61
16
  }
62
17
 
63
18
  export function useAIForm(options: UseAIFormOptions): UseAIFormReturn {
64
- const [validations, setValidations] = useState<Record<string, AIFieldValidation>>({});
65
- const [autocomplete, setAutocomplete] = useState<Record<string, string[]>>({});
66
- const [error, setError] = useState<string | null>(null);
19
+ const [validationResult, setValidationResult] = useState<FormValidationResult | null>(null);
67
20
  const [isLoading, setIsLoading] = useState(false);
21
+ const [error, setError] = useState<string | null>(null);
68
22
 
69
- const validationAbortRef = useRef<AbortController | null>(null);
70
- const autocompleteAbortRef = useRef<AbortController | null>(null);
23
+ const abortControllerRef = useRef<AbortController | null>(null);
24
+ const isMountedRef = useRef(true);
25
+
26
+ const providerConfig = useMemo(
27
+ () => ({
28
+ provider: (options.provider || 'anthropic') as 'anthropic' | 'openai' | 'gemini',
29
+ apiKey: options.apiKey,
30
+ model: options.model || DEFAULT_MODEL_MAP[options.provider || 'anthropic'],
31
+ baseUrl: options.baseUrl,
32
+ timeout: options.timeout,
33
+ maxRetries: options.maxRetries,
34
+ }),
35
+ [options],
36
+ );
71
37
 
72
- const cancelRequests = useCallback(() => {
73
- validationAbortRef.current?.abort();
74
- autocompleteAbortRef.current?.abort();
75
- validationAbortRef.current = null;
76
- autocompleteAbortRef.current = null;
77
- setIsLoading(false);
78
- }, []);
38
+ const provider = useMemo(() => createProvider(providerConfig), [providerConfig]);
79
39
 
80
- const clearFormAI = useCallback(() => {
81
- setValidations({});
82
- setAutocomplete({});
40
+ const clearValidation = useCallback(() => {
41
+ setValidationResult(null);
83
42
  setError(null);
84
43
  }, []);
85
44
 
86
- const sendClaudeRequest = useCallback(
87
- async (prompt: string, signal: AbortSignal) => {
88
- const response = await fetch('https://api.anthropic.com/v1/messages', {
89
- method: 'POST',
90
- headers: {
91
- 'Content-Type': 'application/json',
92
- 'x-api-key': options.apiKey,
93
- 'anthropic-version': '2023-06-01',
94
- },
95
- body: JSON.stringify({
96
- model: options.model || 'claude-sonnet-4-20250514',
97
- max_tokens: options.maxTokens ?? 800,
98
- temperature: options.temperature ?? 0.2,
99
- system:
100
- options.system ||
101
- 'You are a form assistant. Return precise, concise, JSON-only responses with no markdown unless explicitly requested.',
102
- messages: [{ role: 'user', content: prompt }],
103
- }),
104
- signal,
105
- });
106
-
107
- if (!response.ok) {
108
- const errorText = await response.text();
109
- throw new Error(errorText || `Claude API error: ${response.status}`);
45
+ const validateForm = useCallback(
46
+ async (input: FormValidationRequest): Promise<FormValidationResult | null> => {
47
+ if (!input.formData || Object.keys(input.formData).length === 0) {
48
+ setError('Form data is empty');
49
+ return null;
110
50
  }
111
51
 
112
- return response.json();
113
- },
114
- [options],
115
- );
116
-
117
- const validateField = useCallback(
118
- async (input: ValidateFieldInput) => {
119
- validationAbortRef.current?.abort();
120
- const controller = new AbortController();
121
- validationAbortRef.current = controller;
122
-
123
- setIsLoading(true);
124
- setError(null);
125
-
126
- try {
127
- const prompt = [
128
- 'Validate this single form field and return JSON only.',
129
- 'Schema: {"isValid": boolean, "feedback": string, "suggestion": string}',
130
- `Field: ${input.fieldName}`,
131
- `Value: ${input.value}`,
132
- `Rule: ${input.validationRule || 'Use common real-world validation best practices.'}`,
133
- `Other form values: ${JSON.stringify(input.formData || {})}`,
134
- 'If valid, feedback should be short and positive. suggestion can be empty string.',
135
- ].join('\n');
136
-
137
- const data = await sendClaudeRequest(prompt, controller.signal);
138
- const text = extractTextContent(data);
139
-
140
- if (!text) {
141
- throw new Error('No validation content returned from Claude API.');
142
- }
143
-
144
- const parsed = parseJsonFromText<AIFieldValidation>(text);
145
-
146
- if (typeof parsed?.isValid !== 'boolean' || typeof parsed?.feedback !== 'string') {
147
- throw new Error('Invalid validation response format returned by Claude API.');
148
- }
149
-
150
- const result: AIFieldValidation = {
151
- isValid: parsed.isValid,
152
- feedback: parsed.feedback,
153
- suggestion: typeof parsed.suggestion === 'string' ? parsed.suggestion : undefined,
154
- };
155
-
156
- setValidations(prev => ({
157
- ...prev,
158
- [input.fieldName]: result,
159
- }));
160
-
161
- return result;
162
- } catch (err) {
163
- if ((err as Error).name === 'AbortError') {
164
- return null;
165
- }
166
-
167
- const message = (err as Error).message || 'Failed to validate field';
168
- setError(message);
52
+ if (!options.apiKey) {
53
+ setError('Missing API key');
169
54
  return null;
170
- } finally {
171
- validationAbortRef.current = null;
172
- setIsLoading(false);
173
55
  }
174
- },
175
- [sendClaudeRequest],
176
- );
177
-
178
- const autocompleteField = useCallback(
179
- async (input: AutocompleteFieldInput) => {
180
- autocompleteAbortRef.current?.abort();
181
- const controller = new AbortController();
182
- autocompleteAbortRef.current = controller;
183
56
 
184
- setIsLoading(true);
185
57
  setError(null);
58
+ setIsLoading(true);
186
59
 
187
60
  try {
61
+ const schemaText = input.validationSchema ? JSON.stringify(input.validationSchema) : 'Use common validation best practices';
188
62
  const prompt = [
189
- 'Generate form autocomplete suggestions for one field and return JSON only.',
190
- 'Schema: {"suggestions": string[]}',
191
- `Field: ${input.fieldName}`,
192
- `Current value: ${input.value}`,
193
- `Max suggestions: ${input.maxSuggestions ?? 5}`,
194
- `Instruction: ${input.instruction || 'Provide useful, natural completions for this field value.'}`,
195
- `Other form values: ${JSON.stringify(input.formData || {})}`,
196
- 'Suggestions should be unique, concise, and ordered best-first.',
63
+ 'Validate the following form data.',
64
+ 'Return ONLY valid JSON with this schema: {"errors": {"fieldName": "errorMessage"}}',
65
+ 'If all fields are valid, return: {"errors": {}}',
66
+ `Validation schema: ${schemaText}`,
67
+ `Custom instructions: ${input.customInstructions || 'None'}`,
68
+ 'Form data:',
69
+ JSON.stringify(input.formData),
197
70
  ].join('\n');
198
71
 
199
- const data = await sendClaudeRequest(prompt, controller.signal);
200
- const text = extractTextContent(data);
72
+ const aiResponse = await provider.makeRequest({
73
+ prompt,
74
+ options: {
75
+ system:
76
+ options.system ||
77
+ 'You are a form validation assistant. Return ONLY valid JSON responses, no markdown or explanations.',
78
+ temperature: options.temperature ?? 0.2,
79
+ maxTokens: options.maxTokens ?? 800,
80
+ },
81
+ });
82
+
83
+ const parsed = parseJsonFromText<{ errors?: Record<string, string> }>(aiResponse.text);
84
+ const errors = parsed?.errors || {};
85
+ const isValid = Object.keys(errors).length === 0;
86
+
87
+ const result: FormValidationResult = {
88
+ isValid,
89
+ errors,
90
+ raw: parsed,
91
+ };
201
92
 
202
- if (!text) {
203
- throw new Error('No autocomplete content returned from Claude API.');
93
+ if (isMountedRef.current) {
94
+ setValidationResult(result);
204
95
  }
205
-
206
- const parsed = parseJsonFromText<{ suggestions?: unknown }>(text);
207
- const suggestions = Array.isArray(parsed?.suggestions)
208
- ? parsed.suggestions.filter((item): item is string => typeof item === 'string')
209
- : [];
210
-
211
- const limitedSuggestions = suggestions.slice(0, input.maxSuggestions ?? 5);
212
-
213
- setAutocomplete(prev => ({
214
- ...prev,
215
- [input.fieldName]: limitedSuggestions,
216
- }));
217
-
218
- return limitedSuggestions;
96
+ return result;
219
97
  } catch (err) {
220
- if ((err as Error).name === 'AbortError') {
221
- return null;
98
+ if (isMountedRef.current) {
99
+ const message = err instanceof Error ? err.message : 'Failed to validate form';
100
+ setError(message);
222
101
  }
223
-
224
- const message = (err as Error).message || 'Failed to generate autocomplete suggestions';
225
- setError(message);
226
102
  return null;
227
103
  } finally {
228
- autocompleteAbortRef.current = null;
229
- setIsLoading(false);
104
+ if (isMountedRef.current) {
105
+ setIsLoading(false);
106
+ }
230
107
  }
231
108
  },
232
- [sendClaudeRequest],
109
+ [provider, options],
233
110
  );
234
111
 
112
+ useState(() => {
113
+ isMountedRef.current = true;
114
+ return () => {
115
+ isMountedRef.current = false;
116
+ abortControllerRef.current?.abort();
117
+ };
118
+ }, []);
119
+
235
120
  return {
236
- validations,
237
- autocomplete,
121
+ validationResult,
238
122
  isLoading,
239
123
  error,
240
- validateField,
241
- autocompleteField,
242
- clearFormAI,
243
- cancelRequests,
124
+ validateForm,
125
+ clearValidation,
244
126
  };
245
127
  }
@@ -1,32 +1,37 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react';
2
-
3
- interface UseAIStreamOptions {
4
- apiKey: string;
5
- model?: string;
6
- system?: string;
7
- maxTokens?: number;
8
- temperature?: number;
9
- }
10
-
11
- interface UseAIStreamReturn {
12
- response: string;
13
- isLoading: boolean;
14
- error: string | null;
15
- streamResponse: (prompt: string) => Promise<void>;
16
- abortStream: () => void;
17
- clearResponse: () => void;
18
- }
1
+ import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
2
+ import type { UseAIStreamOptions, UseAIStreamReturn } from '../types';
3
+ import { fetchWithRetry } from '../utils/fetchWithRetry';
4
+
5
+ const DEFAULT_MODEL_MAP = {
6
+ anthropic: 'claude-sonnet-4-20250514',
7
+ openai: 'gpt-4',
8
+ gemini: 'gemini-pro',
9
+ };
19
10
 
20
11
  export function useAIStream(options: UseAIStreamOptions): UseAIStreamReturn {
21
12
  const [response, setResponse] = useState('');
22
13
  const [isLoading, setIsLoading] = useState(false);
23
14
  const [error, setError] = useState<string | null>(null);
15
+
24
16
  const abortControllerRef = useRef<AbortController | null>(null);
17
+ const isMountedRef = useRef(true);
18
+
19
+ const providerConfig = useMemo(
20
+ () => ({
21
+ provider: options.provider || 'anthropic',
22
+ apiKey: options.apiKey,
23
+ model: options.model || DEFAULT_MODEL_MAP[options.provider || 'anthropic'],
24
+ baseUrl: options.baseUrl,
25
+ }),
26
+ [options],
27
+ );
25
28
 
26
- const abortStream = useCallback(() => {
29
+ const abort = useCallback(() => {
27
30
  abortControllerRef.current?.abort();
28
31
  abortControllerRef.current = null;
29
- setIsLoading(false);
32
+ if (isMountedRef.current) {
33
+ setIsLoading(false);
34
+ }
30
35
  }, []);
31
36
 
32
37
  const clearResponse = useCallback(() => {
@@ -36,7 +41,7 @@ export function useAIStream(options: UseAIStreamOptions): UseAIStreamReturn {
36
41
 
37
42
  const streamResponse = useCallback(
38
43
  async (prompt: string) => {
39
- abortStream();
44
+ abort();
40
45
  setResponse('');
41
46
  setError(null);
42
47
  setIsLoading(true);
@@ -45,43 +50,71 @@ export function useAIStream(options: UseAIStreamOptions): UseAIStreamReturn {
45
50
  abortControllerRef.current = controller;
46
51
 
47
52
  try {
48
- const apiResponse = await fetch('https://api.anthropic.com/v1/messages', {
49
- method: 'POST',
50
- headers: {
51
- 'Content-Type': 'application/json',
52
- 'x-api-key': options.apiKey,
53
- 'anthropic-version': '2023-06-01',
54
- },
55
- body: JSON.stringify({
56
- model: options.model || 'claude-sonnet-4-20250514',
53
+ let url = '';
54
+ let body: Record<string, unknown> = {};
55
+ let headers: Record<string, string> = {
56
+ 'Content-Type': 'application/json',
57
+ };
58
+
59
+ if (providerConfig.provider === 'anthropic') {
60
+ url = `${providerConfig.baseUrl || 'https://api.anthropic.com'}/v1/messages`;
61
+ headers['x-api-key'] = options.apiKey;
62
+ headers['anthropic-version'] = '2023-06-01';
63
+ body = {
64
+ model: providerConfig.model,
65
+ max_tokens: options.maxTokens ?? 1024,
66
+ temperature: options.temperature ?? 0.7,
67
+ stream: true,
68
+ system: options.system,
69
+ messages: [{ role: 'user', content: prompt }],
70
+ };
71
+ } else if (providerConfig.provider === 'openai') {
72
+ url = `${providerConfig.baseUrl || 'https://api.openai.com'}/v1/chat/completions`;
73
+ headers.Authorization = `Bearer ${options.apiKey}`;
74
+ body = {
75
+ model: providerConfig.model,
57
76
  max_tokens: options.maxTokens ?? 1024,
58
77
  temperature: options.temperature ?? 0.7,
59
78
  stream: true,
60
79
  system: options.system,
61
80
  messages: [{ role: 'user', content: prompt }],
62
- }),
63
- signal: controller.signal,
64
- });
81
+ };
82
+ } else {
83
+ throw new Error(`Streaming not supported for provider: ${providerConfig.provider}`);
84
+ }
85
+
86
+ const apiResponse = await fetchWithRetry(
87
+ url,
88
+ {
89
+ method: 'POST',
90
+ headers,
91
+ body: JSON.stringify(body),
92
+ signal: controller.signal,
93
+ },
94
+ {
95
+ timeout: options.timeout || 30000,
96
+ maxRetries: options.maxRetries ?? 3,
97
+ },
98
+ );
65
99
 
66
100
  if (!apiResponse.ok) {
67
101
  const errorText = await apiResponse.text();
68
- throw new Error(errorText || `Claude API error: ${apiResponse.status}`);
102
+ throw new Error(errorText || `API error: ${apiResponse.status}`);
69
103
  }
70
104
 
71
105
  if (!apiResponse.body) {
72
- throw new Error('Streaming is not supported in this environment.');
106
+ throw new Error('Streaming not supported in this environment');
73
107
  }
74
108
 
75
109
  const reader = apiResponse.body.getReader();
76
110
  const decoder = new TextDecoder();
77
111
  let buffer = '';
78
112
 
113
+ // eslint-disable-next-line no-constant-condition
79
114
  while (true) {
80
115
  const { done, value } = await reader.read();
81
116
 
82
- if (done) {
83
- break;
84
- }
117
+ if (done) break;
85
118
 
86
119
  buffer += decoder.decode(value, { stream: true });
87
120
  const lines = buffer.split('\n');
@@ -89,44 +122,58 @@ export function useAIStream(options: UseAIStreamOptions): UseAIStreamReturn {
89
122
 
90
123
  for (const rawLine of lines) {
91
124
  const line = rawLine.trim();
92
-
93
- if (!line || !line.startsWith('data:')) {
94
- continue;
95
- }
125
+ if (!line || !line.startsWith('data:')) continue;
96
126
 
97
127
  const payload = line.slice(5).trim();
98
- if (!payload || payload === '[DONE]') {
99
- continue;
100
- }
128
+ if (!payload || payload === '[DONE]') continue;
101
129
 
102
130
  try {
103
131
  const parsed = JSON.parse(payload);
104
- if (
105
- parsed.type === 'content_block_delta' &&
106
- parsed.delta?.type === 'text_delta' &&
107
- typeof parsed.delta.text === 'string'
108
- ) {
109
- setResponse(prev => prev + parsed.delta.text);
132
+
133
+ if (providerConfig.provider === 'anthropic') {
134
+ if (
135
+ parsed.type === 'content_block_delta' &&
136
+ parsed.delta?.type === 'text_delta' &&
137
+ typeof parsed.delta.text === 'string'
138
+ ) {
139
+ if (isMountedRef.current) {
140
+ setResponse((prev: string) => prev + parsed.delta.text);
141
+ }
142
+ }
143
+ } else if (providerConfig.provider === 'openai') {
144
+ if (
145
+ parsed.choices?.[0]?.delta?.content &&
146
+ typeof parsed.choices[0].delta.content === 'string'
147
+ ) {
148
+ if (isMountedRef.current) {
149
+ setResponse((prev: string) => prev + parsed.choices[0].delta.content);
150
+ }
151
+ }
110
152
  }
111
153
  } catch {
112
- // Ignore malformed stream chunks and keep consuming the stream.
154
+ // Ignore malformed stream chunks
113
155
  }
114
156
  }
115
157
  }
116
158
  } catch (err) {
117
- if ((err as Error).name !== 'AbortError') {
118
- setError((err as Error).message || 'Failed to stream response');
159
+ if (isMountedRef.current && (err as Error).name !== 'AbortError') {
160
+ const message = err instanceof Error ? err.message : 'Failed to stream response';
161
+ setError(message);
119
162
  }
120
163
  } finally {
121
164
  abortControllerRef.current = null;
122
- setIsLoading(false);
165
+ if (isMountedRef.current) {
166
+ setIsLoading(false);
167
+ }
123
168
  }
124
169
  },
125
- [abortStream, options],
170
+ [abort, options, providerConfig],
126
171
  );
127
172
 
128
173
  useEffect(() => {
174
+ isMountedRef.current = true;
129
175
  return () => {
176
+ isMountedRef.current = false;
130
177
  abortControllerRef.current?.abort();
131
178
  };
132
179
  }, []);
@@ -136,7 +183,7 @@ export function useAIStream(options: UseAIStreamOptions): UseAIStreamReturn {
136
183
  isLoading,
137
184
  error,
138
185
  streamResponse,
139
- abortStream,
186
+ abort,
140
187
  clearResponse,
141
188
  };
142
189
  }