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
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, useEffect } 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();
|
|
@@ -60,186 +15,121 @@ function parseJsonFromText<T>(text: string): T {
|
|
|
60
15
|
return JSON.parse(candidate) as T;
|
|
61
16
|
}
|
|
62
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Validates form payloads using an AI model and returns structured validation errors.
|
|
20
|
+
*
|
|
21
|
+
* @param options Validation configuration including provider, API key, model, transport
|
|
22
|
+
* settings, and generation controls used when composing validation prompts.
|
|
23
|
+
* @returns Form validation state containing the latest validation result,
|
|
24
|
+
* loading/error indicators, and actions to validate data or clear prior results.
|
|
25
|
+
*/
|
|
63
26
|
export function useAIForm(options: UseAIFormOptions): UseAIFormReturn {
|
|
64
|
-
const [
|
|
65
|
-
const [autocomplete, setAutocomplete] = useState<Record<string, string[]>>({});
|
|
66
|
-
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
const [validationResult, setValidationResult] = useState<FormValidationResult | null>(null);
|
|
67
28
|
const [isLoading, setIsLoading] = useState(false);
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
68
30
|
|
|
69
|
-
const
|
|
70
|
-
const
|
|
31
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
32
|
+
const isMountedRef = useRef(true);
|
|
33
|
+
|
|
34
|
+
const providerConfig = useMemo(
|
|
35
|
+
() => ({
|
|
36
|
+
provider: (options.provider || 'anthropic') as 'anthropic' | 'openai' | 'gemini',
|
|
37
|
+
apiKey: options.apiKey,
|
|
38
|
+
model: options.model || DEFAULT_MODEL_MAP[options.provider || 'anthropic'],
|
|
39
|
+
baseUrl: options.baseUrl,
|
|
40
|
+
timeout: options.timeout,
|
|
41
|
+
maxRetries: options.maxRetries,
|
|
42
|
+
}),
|
|
43
|
+
[options],
|
|
44
|
+
);
|
|
71
45
|
|
|
72
|
-
const
|
|
73
|
-
validationAbortRef.current?.abort();
|
|
74
|
-
autocompleteAbortRef.current?.abort();
|
|
75
|
-
validationAbortRef.current = null;
|
|
76
|
-
autocompleteAbortRef.current = null;
|
|
77
|
-
setIsLoading(false);
|
|
78
|
-
}, []);
|
|
46
|
+
const provider = useMemo(() => createProvider(providerConfig), [providerConfig]);
|
|
79
47
|
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
setAutocomplete({});
|
|
48
|
+
const clearValidation = useCallback(() => {
|
|
49
|
+
setValidationResult(null);
|
|
83
50
|
setError(null);
|
|
84
51
|
}, []);
|
|
85
52
|
|
|
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}`);
|
|
53
|
+
const validateForm = useCallback(
|
|
54
|
+
async (input: FormValidationRequest): Promise<FormValidationResult | null> => {
|
|
55
|
+
if (!input.formData || Object.keys(input.formData).length === 0) {
|
|
56
|
+
setError('Form data is empty');
|
|
57
|
+
return null;
|
|
110
58
|
}
|
|
111
59
|
|
|
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);
|
|
60
|
+
if (!options.apiKey) {
|
|
61
|
+
setError('Missing API key');
|
|
169
62
|
return null;
|
|
170
|
-
} finally {
|
|
171
|
-
validationAbortRef.current = null;
|
|
172
|
-
setIsLoading(false);
|
|
173
63
|
}
|
|
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
64
|
|
|
184
|
-
setIsLoading(true);
|
|
185
65
|
setError(null);
|
|
66
|
+
setIsLoading(true);
|
|
186
67
|
|
|
187
68
|
try {
|
|
69
|
+
const schemaText = input.validationSchema ? JSON.stringify(input.validationSchema) : 'Use common validation best practices';
|
|
188
70
|
const prompt = [
|
|
189
|
-
'
|
|
190
|
-
'
|
|
191
|
-
|
|
192
|
-
`
|
|
193
|
-
`
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
'Suggestions should be unique, concise, and ordered best-first.',
|
|
71
|
+
'Validate the following form data.',
|
|
72
|
+
'Return ONLY valid JSON with this schema: {"errors": {"fieldName": "errorMessage"}}',
|
|
73
|
+
'If all fields are valid, return: {"errors": {}}',
|
|
74
|
+
`Validation schema: ${schemaText}`,
|
|
75
|
+
`Custom instructions: ${input.customInstructions || 'None'}`,
|
|
76
|
+
'Form data:',
|
|
77
|
+
JSON.stringify(input.formData),
|
|
197
78
|
].join('\n');
|
|
198
79
|
|
|
199
|
-
const
|
|
200
|
-
|
|
80
|
+
const aiResponse = await provider.makeRequest({
|
|
81
|
+
prompt,
|
|
82
|
+
options: {
|
|
83
|
+
system:
|
|
84
|
+
options.system ||
|
|
85
|
+
'You are a form validation assistant. Return ONLY valid JSON responses, no markdown or explanations.',
|
|
86
|
+
temperature: options.temperature ?? 0.2,
|
|
87
|
+
maxTokens: options.maxTokens ?? 800,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const parsed = parseJsonFromText<{ errors?: Record<string, string> }>(aiResponse.text);
|
|
92
|
+
const errors = parsed?.errors || {};
|
|
93
|
+
const isValid = Object.keys(errors).length === 0;
|
|
94
|
+
|
|
95
|
+
const result: FormValidationResult = {
|
|
96
|
+
isValid,
|
|
97
|
+
errors,
|
|
98
|
+
raw: parsed,
|
|
99
|
+
};
|
|
201
100
|
|
|
202
|
-
if (
|
|
203
|
-
|
|
101
|
+
if (isMountedRef.current) {
|
|
102
|
+
setValidationResult(result);
|
|
204
103
|
}
|
|
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;
|
|
104
|
+
return result;
|
|
219
105
|
} catch (err) {
|
|
220
|
-
if (
|
|
221
|
-
|
|
106
|
+
if (isMountedRef.current) {
|
|
107
|
+
const message = err instanceof Error ? err.message : 'Failed to validate form';
|
|
108
|
+
setError(message);
|
|
222
109
|
}
|
|
223
|
-
|
|
224
|
-
const message = (err as Error).message || 'Failed to generate autocomplete suggestions';
|
|
225
|
-
setError(message);
|
|
226
110
|
return null;
|
|
227
111
|
} finally {
|
|
228
|
-
|
|
229
|
-
|
|
112
|
+
if (isMountedRef.current) {
|
|
113
|
+
setIsLoading(false);
|
|
114
|
+
}
|
|
230
115
|
}
|
|
231
116
|
},
|
|
232
|
-
[
|
|
117
|
+
[provider, options],
|
|
233
118
|
);
|
|
234
119
|
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
isMountedRef.current = true;
|
|
122
|
+
return () => {
|
|
123
|
+
isMountedRef.current = false;
|
|
124
|
+
abortControllerRef.current?.abort();
|
|
125
|
+
};
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
235
128
|
return {
|
|
236
|
-
|
|
237
|
-
autocomplete,
|
|
129
|
+
validationResult,
|
|
238
130
|
isLoading,
|
|
239
131
|
error,
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
clearFormAI,
|
|
243
|
-
cancelRequests,
|
|
132
|
+
validateForm,
|
|
133
|
+
clearValidation,
|
|
244
134
|
};
|
|
245
135
|
}
|
package/src/hooks/useAIStream.ts
CHANGED
|
@@ -1,32 +1,46 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Streams AI responses token-by-token and exposes incremental output state.
|
|
13
|
+
*
|
|
14
|
+
* @param options Stream configuration including provider, API key, model, base URL,
|
|
15
|
+
* timeout/retry controls, and generation settings such as system prompt, temperature,
|
|
16
|
+
* and max tokens.
|
|
17
|
+
* @returns Stream state with accumulated response text, loading/error flags, and actions
|
|
18
|
+
* to start streaming, abort an in-flight stream, or clear the current response.
|
|
19
|
+
*/
|
|
20
20
|
export function useAIStream(options: UseAIStreamOptions): UseAIStreamReturn {
|
|
21
21
|
const [response, setResponse] = useState('');
|
|
22
22
|
const [isLoading, setIsLoading] = useState(false);
|
|
23
23
|
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
|
|
24
25
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
26
|
+
const isMountedRef = useRef(true);
|
|
27
|
+
|
|
28
|
+
const providerConfig = useMemo(
|
|
29
|
+
() => ({
|
|
30
|
+
provider: options.provider || 'anthropic',
|
|
31
|
+
apiKey: options.apiKey,
|
|
32
|
+
model: options.model || DEFAULT_MODEL_MAP[options.provider || 'anthropic'],
|
|
33
|
+
baseUrl: options.baseUrl,
|
|
34
|
+
}),
|
|
35
|
+
[options],
|
|
36
|
+
);
|
|
25
37
|
|
|
26
|
-
const
|
|
38
|
+
const abort = useCallback(() => {
|
|
27
39
|
abortControllerRef.current?.abort();
|
|
28
40
|
abortControllerRef.current = null;
|
|
29
|
-
|
|
41
|
+
if (isMountedRef.current) {
|
|
42
|
+
setIsLoading(false);
|
|
43
|
+
}
|
|
30
44
|
}, []);
|
|
31
45
|
|
|
32
46
|
const clearResponse = useCallback(() => {
|
|
@@ -36,7 +50,7 @@ export function useAIStream(options: UseAIStreamOptions): UseAIStreamReturn {
|
|
|
36
50
|
|
|
37
51
|
const streamResponse = useCallback(
|
|
38
52
|
async (prompt: string) => {
|
|
39
|
-
|
|
53
|
+
abort();
|
|
40
54
|
setResponse('');
|
|
41
55
|
setError(null);
|
|
42
56
|
setIsLoading(true);
|
|
@@ -45,43 +59,71 @@ export function useAIStream(options: UseAIStreamOptions): UseAIStreamReturn {
|
|
|
45
59
|
abortControllerRef.current = controller;
|
|
46
60
|
|
|
47
61
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
let url = '';
|
|
63
|
+
let body: Record<string, unknown> = {};
|
|
64
|
+
let headers: Record<string, string> = {
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (providerConfig.provider === 'anthropic') {
|
|
69
|
+
url = `${providerConfig.baseUrl || 'https://api.anthropic.com'}/v1/messages`;
|
|
70
|
+
headers['x-api-key'] = options.apiKey;
|
|
71
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
72
|
+
body = {
|
|
73
|
+
model: providerConfig.model,
|
|
74
|
+
max_tokens: options.maxTokens ?? 1024,
|
|
75
|
+
temperature: options.temperature ?? 0.7,
|
|
76
|
+
stream: true,
|
|
77
|
+
system: options.system,
|
|
78
|
+
messages: [{ role: 'user', content: prompt }],
|
|
79
|
+
};
|
|
80
|
+
} else if (providerConfig.provider === 'openai') {
|
|
81
|
+
url = `${providerConfig.baseUrl || 'https://api.openai.com'}/v1/chat/completions`;
|
|
82
|
+
headers.Authorization = `Bearer ${options.apiKey}`;
|
|
83
|
+
body = {
|
|
84
|
+
model: providerConfig.model,
|
|
57
85
|
max_tokens: options.maxTokens ?? 1024,
|
|
58
86
|
temperature: options.temperature ?? 0.7,
|
|
59
87
|
stream: true,
|
|
60
88
|
system: options.system,
|
|
61
89
|
messages: [{ role: 'user', content: prompt }],
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
90
|
+
};
|
|
91
|
+
} else {
|
|
92
|
+
throw new Error(`Streaming not supported for provider: ${providerConfig.provider}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const apiResponse = await fetchWithRetry(
|
|
96
|
+
url,
|
|
97
|
+
{
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers,
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
signal: controller.signal,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
timeout: options.timeout || 30000,
|
|
105
|
+
maxRetries: options.maxRetries ?? 3,
|
|
106
|
+
},
|
|
107
|
+
);
|
|
65
108
|
|
|
66
109
|
if (!apiResponse.ok) {
|
|
67
110
|
const errorText = await apiResponse.text();
|
|
68
|
-
throw new Error(errorText || `
|
|
111
|
+
throw new Error(errorText || `API error: ${apiResponse.status}`);
|
|
69
112
|
}
|
|
70
113
|
|
|
71
114
|
if (!apiResponse.body) {
|
|
72
|
-
throw new Error('Streaming
|
|
115
|
+
throw new Error('Streaming not supported in this environment');
|
|
73
116
|
}
|
|
74
117
|
|
|
75
118
|
const reader = apiResponse.body.getReader();
|
|
76
119
|
const decoder = new TextDecoder();
|
|
77
120
|
let buffer = '';
|
|
78
121
|
|
|
122
|
+
// eslint-disable-next-line no-constant-condition
|
|
79
123
|
while (true) {
|
|
80
124
|
const { done, value } = await reader.read();
|
|
81
125
|
|
|
82
|
-
if (done)
|
|
83
|
-
break;
|
|
84
|
-
}
|
|
126
|
+
if (done) break;
|
|
85
127
|
|
|
86
128
|
buffer += decoder.decode(value, { stream: true });
|
|
87
129
|
const lines = buffer.split('\n');
|
|
@@ -89,44 +131,58 @@ export function useAIStream(options: UseAIStreamOptions): UseAIStreamReturn {
|
|
|
89
131
|
|
|
90
132
|
for (const rawLine of lines) {
|
|
91
133
|
const line = rawLine.trim();
|
|
92
|
-
|
|
93
|
-
if (!line || !line.startsWith('data:')) {
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
134
|
+
if (!line || !line.startsWith('data:')) continue;
|
|
96
135
|
|
|
97
136
|
const payload = line.slice(5).trim();
|
|
98
|
-
if (!payload || payload === '[DONE]')
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
137
|
+
if (!payload || payload === '[DONE]') continue;
|
|
101
138
|
|
|
102
139
|
try {
|
|
103
140
|
const parsed = JSON.parse(payload);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
141
|
+
|
|
142
|
+
if (providerConfig.provider === 'anthropic') {
|
|
143
|
+
if (
|
|
144
|
+
parsed.type === 'content_block_delta' &&
|
|
145
|
+
parsed.delta?.type === 'text_delta' &&
|
|
146
|
+
typeof parsed.delta.text === 'string'
|
|
147
|
+
) {
|
|
148
|
+
if (isMountedRef.current) {
|
|
149
|
+
setResponse((prev: string) => prev + parsed.delta.text);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} else if (providerConfig.provider === 'openai') {
|
|
153
|
+
if (
|
|
154
|
+
parsed.choices?.[0]?.delta?.content &&
|
|
155
|
+
typeof parsed.choices[0].delta.content === 'string'
|
|
156
|
+
) {
|
|
157
|
+
if (isMountedRef.current) {
|
|
158
|
+
setResponse((prev: string) => prev + parsed.choices[0].delta.content);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
110
161
|
}
|
|
111
162
|
} catch {
|
|
112
|
-
// Ignore malformed stream chunks
|
|
163
|
+
// Ignore malformed stream chunks
|
|
113
164
|
}
|
|
114
165
|
}
|
|
115
166
|
}
|
|
116
167
|
} catch (err) {
|
|
117
|
-
if ((err as Error).name !== 'AbortError') {
|
|
118
|
-
|
|
168
|
+
if (isMountedRef.current && (err as Error).name !== 'AbortError') {
|
|
169
|
+
const message = err instanceof Error ? err.message : 'Failed to stream response';
|
|
170
|
+
setError(message);
|
|
119
171
|
}
|
|
120
172
|
} finally {
|
|
121
173
|
abortControllerRef.current = null;
|
|
122
|
-
|
|
174
|
+
if (isMountedRef.current) {
|
|
175
|
+
setIsLoading(false);
|
|
176
|
+
}
|
|
123
177
|
}
|
|
124
178
|
},
|
|
125
|
-
[
|
|
179
|
+
[abort, options, providerConfig],
|
|
126
180
|
);
|
|
127
181
|
|
|
128
182
|
useEffect(() => {
|
|
183
|
+
isMountedRef.current = true;
|
|
129
184
|
return () => {
|
|
185
|
+
isMountedRef.current = false;
|
|
130
186
|
abortControllerRef.current?.abort();
|
|
131
187
|
};
|
|
132
188
|
}, []);
|
|
@@ -136,7 +192,7 @@ export function useAIStream(options: UseAIStreamOptions): UseAIStreamReturn {
|
|
|
136
192
|
isLoading,
|
|
137
193
|
error,
|
|
138
194
|
streamResponse,
|
|
139
|
-
|
|
195
|
+
abort,
|
|
140
196
|
clearResponse,
|
|
141
197
|
};
|
|
142
198
|
}
|
|
@@ -63,6 +63,14 @@ function lengthInstruction(length: SummaryLength): string {
|
|
|
63
63
|
return 'Produce a balanced summary in 1-2 paragraphs plus a short bullet list of key takeaways.';
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Summarizes long-form text with selectable output length (short, medium, or long).
|
|
68
|
+
*
|
|
69
|
+
* @param options Summarization configuration including API key, model/system behavior,
|
|
70
|
+
* token/temperature controls, and default summary length.
|
|
71
|
+
* @returns Summarization state with current summary text, selected length,
|
|
72
|
+
* loading/error indicators, and actions to set length, generate summaries, or clear output.
|
|
73
|
+
*/
|
|
66
74
|
export function useAISummarize(options: UseAISummarizeOptions): UseAISummarizeReturn {
|
|
67
75
|
const [summary, setSummary] = useState('');
|
|
68
76
|
const [length, setLength] = useState<SummaryLength>(options.defaultLength || 'medium');
|
|
@@ -62,6 +62,15 @@ function parseJsonFromText<T>(text: string): T {
|
|
|
62
62
|
return JSON.parse(candidate) as T;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Translates source text into a target language with optional automatic debounced translation.
|
|
67
|
+
*
|
|
68
|
+
* @param options Translation configuration including API key, model/system settings,
|
|
69
|
+
* temperature/token controls, initial target language, and auto-translate behavior.
|
|
70
|
+
* @returns Translation state with source/translated text, detected source language,
|
|
71
|
+
* target language, status/error flags, and actions to update text/language, translate,
|
|
72
|
+
* or reset translation state.
|
|
73
|
+
*/
|
|
65
74
|
export function useAITranslate(options: UseAITranslateOptions): UseAITranslateReturn {
|
|
66
75
|
const [sourceText, setSourceText] = useState('');
|
|
67
76
|
const [translatedText, setTranslatedText] = useState('');
|
package/src/hooks/useAIVoice.ts
CHANGED
|
@@ -58,6 +58,14 @@ function getClaudeTextContent(data: unknown): string {
|
|
|
58
58
|
.trim();
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Handles speech-to-text capture and optional AI response generation from transcribed speech.
|
|
63
|
+
*
|
|
64
|
+
* @param options Voice hook configuration including API key, model settings, recognition
|
|
65
|
+
* language, and whether transcription should be auto-sent after recording stops.
|
|
66
|
+
* @returns Voice interaction state with transcription/response values, recording and loading
|
|
67
|
+
* indicators, error state, and actions to start/stop recording, send transcription, and clear state.
|
|
68
|
+
*/
|
|
61
69
|
export function useAIVoice(options: UseAIVoiceOptions): UseAIVoiceReturn {
|
|
62
70
|
const [transcription, setTranscription] = useState('');
|
|
63
71
|
const [response, setResponse] = useState('');
|