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.
- package/ARCHITECTURE_GUIDE.md +467 -0
- package/IMPLEMENTATION_COMPLETE.md +349 -0
- package/README.md +10 -0
- package/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/package.json +1 -1
- package/src/ARCHITECTURE.md +301 -0
- package/src/hooks/useAIChat.ts +103 -51
- package/src/hooks/useAIForm.ts +84 -202
- package/src/hooks/useAIStream.ts +104 -57
- package/src/hooks/useImageAnalysis.ts +126 -79
- package/src/index.ts +25 -1
- package/src/types/index.ts +178 -4
- package/src/utils/fetchWithRetry.ts +98 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/providerFactory.ts +265 -0
package/src/hooks/useAIForm.ts
CHANGED
|
@@ -1,57 +1,12 @@
|
|
|
1
|
-
import { useCallback, useRef, useState } from 'react';
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 [
|
|
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
|
|
70
|
-
const
|
|
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
|
|
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
|
|
81
|
-
|
|
82
|
-
setAutocomplete({});
|
|
40
|
+
const clearValidation = useCallback(() => {
|
|
41
|
+
setValidationResult(null);
|
|
83
42
|
setError(null);
|
|
84
43
|
}, []);
|
|
85
44
|
|
|
86
|
-
const
|
|
87
|
-
async (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
190
|
-
'
|
|
191
|
-
|
|
192
|
-
`
|
|
193
|
-
`
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
200
|
-
|
|
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 (
|
|
203
|
-
|
|
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 (
|
|
221
|
-
|
|
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
|
-
|
|
229
|
-
|
|
104
|
+
if (isMountedRef.current) {
|
|
105
|
+
setIsLoading(false);
|
|
106
|
+
}
|
|
230
107
|
}
|
|
231
108
|
},
|
|
232
|
-
[
|
|
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
|
-
|
|
237
|
-
autocomplete,
|
|
121
|
+
validationResult,
|
|
238
122
|
isLoading,
|
|
239
123
|
error,
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
clearFormAI,
|
|
243
|
-
cancelRequests,
|
|
124
|
+
validateForm,
|
|
125
|
+
clearValidation,
|
|
244
126
|
};
|
|
245
127
|
}
|
package/src/hooks/useAIStream.ts
CHANGED
|
@@ -1,32 +1,37 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
29
|
+
const abort = useCallback(() => {
|
|
27
30
|
abortControllerRef.current?.abort();
|
|
28
31
|
abortControllerRef.current = null;
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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 || `
|
|
102
|
+
throw new Error(errorText || `API error: ${apiResponse.status}`);
|
|
69
103
|
}
|
|
70
104
|
|
|
71
105
|
if (!apiResponse.body) {
|
|
72
|
-
throw new Error('Streaming
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
154
|
+
// Ignore malformed stream chunks
|
|
113
155
|
}
|
|
114
156
|
}
|
|
115
157
|
}
|
|
116
158
|
} catch (err) {
|
|
117
|
-
if ((err as Error).name !== 'AbortError') {
|
|
118
|
-
|
|
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
|
-
|
|
165
|
+
if (isMountedRef.current) {
|
|
166
|
+
setIsLoading(false);
|
|
167
|
+
}
|
|
123
168
|
}
|
|
124
169
|
},
|
|
125
|
-
[
|
|
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
|
-
|
|
186
|
+
abort,
|
|
140
187
|
clearResponse,
|
|
141
188
|
};
|
|
142
189
|
}
|