react-native-ai-hooks 0.1.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/README.md +39 -0
- package/package.json +17 -0
- package/src/hooks/useAIChat.ts +66 -0
- package/src/hooks/useAIForm.ts +245 -0
- package/src/hooks/useAIStream.ts +142 -0
- package/src/hooks/useImageAnalysis.ts +193 -0
- package/src/index.ts +4 -0
- package/src/types/index.ts +15 -0
- package/tsconfig.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# react-native-ai-hooks
|
|
2
|
+
|
|
3
|
+
AI hooks for React Native — add Claude, OpenAI & Gemini to your app in minutes.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install react-native-ai-hooks
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Hooks
|
|
12
|
+
|
|
13
|
+
- `useAIChat()` — multi-turn chat with streaming
|
|
14
|
+
- `useAIStream()` — real-time token streaming
|
|
15
|
+
- `useImageAnalysis()` — camera/gallery → AI description
|
|
16
|
+
- `useAIForm()` — AI-powered form validation
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { useAIChat } from 'react-native-ai-hooks';
|
|
22
|
+
|
|
23
|
+
const { messages, sendMessage, isLoading } = useAIChat({
|
|
24
|
+
apiKey: 'your-api-key',
|
|
25
|
+
provider: 'claude',
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Providers
|
|
30
|
+
|
|
31
|
+
| Provider | Status |
|
|
32
|
+
|----------|--------|
|
|
33
|
+
| Claude (Anthropic) | ✅ |
|
|
34
|
+
| OpenAI | ✅ |
|
|
35
|
+
| Gemini | 🔜 |
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-native-ai-hooks",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI hooks for React Native — useAIChat, useAIStream, useImageAnalysis. Works with Claude, OpenAI & Gemini.",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"keywords": ["react-native", "ai", "claude", "openai", "hooks", "expo"],
|
|
8
|
+
"author": "nikapkh",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"typescript": "^5.0.0"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"react": ">=18.0.0",
|
|
15
|
+
"react-native": ">=0.70.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
interface Message {
|
|
4
|
+
role: 'user' | 'assistant';
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface UseAIChatOptions {
|
|
9
|
+
apiKey: string;
|
|
10
|
+
provider?: 'claude' | 'openai';
|
|
11
|
+
model?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface UseAIChatReturn {
|
|
15
|
+
messages: Message[];
|
|
16
|
+
isLoading: boolean;
|
|
17
|
+
error: string | null;
|
|
18
|
+
sendMessage: (content: string) => Promise<void>;
|
|
19
|
+
clearMessages: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useAIChat(options: UseAIChatOptions): UseAIChatReturn {
|
|
23
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
24
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
|
|
27
|
+
const sendMessage = useCallback(async (content: string) => {
|
|
28
|
+
setIsLoading(true);
|
|
29
|
+
setError(null);
|
|
30
|
+
|
|
31
|
+
const userMessage: Message = { role: 'user', content };
|
|
32
|
+
setMessages(prev => [...prev, userMessage]);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
'x-api-key': options.apiKey,
|
|
40
|
+
'anthropic-version': '2023-06-01',
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
model: options.model || 'claude-sonnet-4-20250514',
|
|
44
|
+
max_tokens: 1024,
|
|
45
|
+
messages: [...messages, userMessage],
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const data = await response.json();
|
|
50
|
+
const assistantMessage: Message = {
|
|
51
|
+
role: 'assistant',
|
|
52
|
+
content: data.content[0].text,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
setMessages(prev => [...prev, assistantMessage]);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
setError('Failed to send message');
|
|
58
|
+
} finally {
|
|
59
|
+
setIsLoading(false);
|
|
60
|
+
}
|
|
61
|
+
}, [messages, options]);
|
|
62
|
+
|
|
63
|
+
const clearMessages = useCallback(() => setMessages([]), []);
|
|
64
|
+
|
|
65
|
+
return { messages, isLoading, error, sendMessage, clearMessages };
|
|
66
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
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
|
+
}
|
|
31
|
+
|
|
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
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseJsonFromText<T>(text: string): T {
|
|
57
|
+
const trimmed = text.trim();
|
|
58
|
+
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
59
|
+
const candidate = fenced?.[1]?.trim() || trimmed;
|
|
60
|
+
return JSON.parse(candidate) as T;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
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);
|
|
67
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
68
|
+
|
|
69
|
+
const validationAbortRef = useRef<AbortController | null>(null);
|
|
70
|
+
const autocompleteAbortRef = useRef<AbortController | null>(null);
|
|
71
|
+
|
|
72
|
+
const cancelRequests = useCallback(() => {
|
|
73
|
+
validationAbortRef.current?.abort();
|
|
74
|
+
autocompleteAbortRef.current?.abort();
|
|
75
|
+
validationAbortRef.current = null;
|
|
76
|
+
autocompleteAbortRef.current = null;
|
|
77
|
+
setIsLoading(false);
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
const clearFormAI = useCallback(() => {
|
|
81
|
+
setValidations({});
|
|
82
|
+
setAutocomplete({});
|
|
83
|
+
setError(null);
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
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}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
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);
|
|
169
|
+
return null;
|
|
170
|
+
} finally {
|
|
171
|
+
validationAbortRef.current = null;
|
|
172
|
+
setIsLoading(false);
|
|
173
|
+
}
|
|
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
|
+
|
|
184
|
+
setIsLoading(true);
|
|
185
|
+
setError(null);
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
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.',
|
|
197
|
+
].join('\n');
|
|
198
|
+
|
|
199
|
+
const data = await sendClaudeRequest(prompt, controller.signal);
|
|
200
|
+
const text = extractTextContent(data);
|
|
201
|
+
|
|
202
|
+
if (!text) {
|
|
203
|
+
throw new Error('No autocomplete content returned from Claude API.');
|
|
204
|
+
}
|
|
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;
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if ((err as Error).name === 'AbortError') {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const message = (err as Error).message || 'Failed to generate autocomplete suggestions';
|
|
225
|
+
setError(message);
|
|
226
|
+
return null;
|
|
227
|
+
} finally {
|
|
228
|
+
autocompleteAbortRef.current = null;
|
|
229
|
+
setIsLoading(false);
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
[sendClaudeRequest],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
validations,
|
|
237
|
+
autocomplete,
|
|
238
|
+
isLoading,
|
|
239
|
+
error,
|
|
240
|
+
validateField,
|
|
241
|
+
autocompleteField,
|
|
242
|
+
clearFormAI,
|
|
243
|
+
cancelRequests,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useAIStream(options: UseAIStreamOptions): UseAIStreamReturn {
|
|
21
|
+
const [response, setResponse] = useState('');
|
|
22
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
25
|
+
|
|
26
|
+
const abortStream = useCallback(() => {
|
|
27
|
+
abortControllerRef.current?.abort();
|
|
28
|
+
abortControllerRef.current = null;
|
|
29
|
+
setIsLoading(false);
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
const clearResponse = useCallback(() => {
|
|
33
|
+
setResponse('');
|
|
34
|
+
setError(null);
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const streamResponse = useCallback(
|
|
38
|
+
async (prompt: string) => {
|
|
39
|
+
abortStream();
|
|
40
|
+
setResponse('');
|
|
41
|
+
setError(null);
|
|
42
|
+
setIsLoading(true);
|
|
43
|
+
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
abortControllerRef.current = controller;
|
|
46
|
+
|
|
47
|
+
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',
|
|
57
|
+
max_tokens: options.maxTokens ?? 1024,
|
|
58
|
+
temperature: options.temperature ?? 0.7,
|
|
59
|
+
stream: true,
|
|
60
|
+
system: options.system,
|
|
61
|
+
messages: [{ role: 'user', content: prompt }],
|
|
62
|
+
}),
|
|
63
|
+
signal: controller.signal,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!apiResponse.ok) {
|
|
67
|
+
const errorText = await apiResponse.text();
|
|
68
|
+
throw new Error(errorText || `Claude API error: ${apiResponse.status}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!apiResponse.body) {
|
|
72
|
+
throw new Error('Streaming is not supported in this environment.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const reader = apiResponse.body.getReader();
|
|
76
|
+
const decoder = new TextDecoder();
|
|
77
|
+
let buffer = '';
|
|
78
|
+
|
|
79
|
+
while (true) {
|
|
80
|
+
const { done, value } = await reader.read();
|
|
81
|
+
|
|
82
|
+
if (done) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
buffer += decoder.decode(value, { stream: true });
|
|
87
|
+
const lines = buffer.split('\n');
|
|
88
|
+
buffer = lines.pop() || '';
|
|
89
|
+
|
|
90
|
+
for (const rawLine of lines) {
|
|
91
|
+
const line = rawLine.trim();
|
|
92
|
+
|
|
93
|
+
if (!line || !line.startsWith('data:')) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const payload = line.slice(5).trim();
|
|
98
|
+
if (!payload || payload === '[DONE]') {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
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);
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Ignore malformed stream chunks and keep consuming the stream.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if ((err as Error).name !== 'AbortError') {
|
|
118
|
+
setError((err as Error).message || 'Failed to stream response');
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
abortControllerRef.current = null;
|
|
122
|
+
setIsLoading(false);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
[abortStream, options],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
return () => {
|
|
130
|
+
abortControllerRef.current?.abort();
|
|
131
|
+
};
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
response,
|
|
136
|
+
isLoading,
|
|
137
|
+
error,
|
|
138
|
+
streamResponse,
|
|
139
|
+
abortStream,
|
|
140
|
+
clearResponse,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface UseImageAnalysisOptions {
|
|
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
|
+
}
|
|
16
|
+
|
|
17
|
+
interface UseImageAnalysisReturn {
|
|
18
|
+
description: string;
|
|
19
|
+
isLoading: boolean;
|
|
20
|
+
error: string | null;
|
|
21
|
+
analyzeImage: (input: AnalyzeImageInput) => Promise<string | null>;
|
|
22
|
+
clearDescription: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ParsedImageData {
|
|
26
|
+
base64: string;
|
|
27
|
+
mediaType: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DATA_URI_REGEX = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/;
|
|
31
|
+
|
|
32
|
+
function getMediaTypeFromUri(uri: string): string {
|
|
33
|
+
const normalized = uri.toLowerCase();
|
|
34
|
+
|
|
35
|
+
if (normalized.includes('.png')) {
|
|
36
|
+
return 'image/png';
|
|
37
|
+
}
|
|
38
|
+
if (normalized.includes('.webp')) {
|
|
39
|
+
return 'image/webp';
|
|
40
|
+
}
|
|
41
|
+
if (normalized.includes('.gif')) {
|
|
42
|
+
return 'image/gif';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return 'image/jpeg';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
49
|
+
let binary = '';
|
|
50
|
+
const bytes = new Uint8Array(buffer);
|
|
51
|
+
const chunkSize = 0x8000;
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
54
|
+
const chunk = bytes.subarray(i, i + chunkSize);
|
|
55
|
+
binary += String.fromCharCode(...chunk);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (typeof btoa !== 'function') {
|
|
59
|
+
throw new Error('Base64 conversion is unavailable. Provide uriToBase64 in hook options.');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return btoa(binary);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function defaultUriToBase64(uri: string): Promise<string> {
|
|
66
|
+
const response = await fetch(uri);
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
throw new Error(`Failed to read image from URI: ${response.status}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const imageBuffer = await response.arrayBuffer();
|
|
72
|
+
return arrayBufferToBase64(imageBuffer);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function useImageAnalysis(options: UseImageAnalysisOptions): UseImageAnalysisReturn {
|
|
76
|
+
const [description, setDescription] = useState('');
|
|
77
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
78
|
+
const [error, setError] = useState<string | null>(null);
|
|
79
|
+
|
|
80
|
+
const normalizeImage = useCallback(
|
|
81
|
+
async (image: string, mediaType?: string): Promise<ParsedImageData> => {
|
|
82
|
+
const dataUriMatch = image.match(DATA_URI_REGEX);
|
|
83
|
+
if (dataUriMatch) {
|
|
84
|
+
const matchedMediaType = dataUriMatch[1];
|
|
85
|
+
const base64Data = dataUriMatch[2];
|
|
86
|
+
return {
|
|
87
|
+
base64: base64Data,
|
|
88
|
+
mediaType: mediaType || matchedMediaType,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const isLikelyUri = /^(https?:\/\/|file:\/\/|content:\/\/|ph:\/\/|assets-library:\/\/)/i.test(
|
|
93
|
+
image,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (isLikelyUri) {
|
|
97
|
+
const toBase64 = options.uriToBase64 || defaultUriToBase64;
|
|
98
|
+
const base64 = await toBase64(image);
|
|
99
|
+
return {
|
|
100
|
+
base64,
|
|
101
|
+
mediaType: mediaType || getMediaTypeFromUri(image),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
base64: image,
|
|
107
|
+
mediaType: mediaType || 'image/jpeg',
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
[options.uriToBase64],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const clearDescription = useCallback(() => {
|
|
114
|
+
setDescription('');
|
|
115
|
+
setError(null);
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const analyzeImage = useCallback(
|
|
119
|
+
async (input: AnalyzeImageInput) => {
|
|
120
|
+
setIsLoading(true);
|
|
121
|
+
setError(null);
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const parsedImage = await normalizeImage(input.image, input.mediaType);
|
|
125
|
+
|
|
126
|
+
const apiResponse = await fetch('https://api.anthropic.com/v1/messages', {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: {
|
|
129
|
+
'Content-Type': 'application/json',
|
|
130
|
+
'x-api-key': options.apiKey,
|
|
131
|
+
'anthropic-version': '2023-06-01',
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
model: options.model || 'claude-sonnet-4-20250514',
|
|
135
|
+
max_tokens: options.maxTokens ?? 1024,
|
|
136
|
+
system: options.system,
|
|
137
|
+
messages: [
|
|
138
|
+
{
|
|
139
|
+
role: 'user',
|
|
140
|
+
content: [
|
|
141
|
+
{
|
|
142
|
+
type: 'image',
|
|
143
|
+
source: {
|
|
144
|
+
type: 'base64',
|
|
145
|
+
media_type: parsedImage.mediaType,
|
|
146
|
+
data: parsedImage.base64,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
type: 'text',
|
|
151
|
+
text: input.prompt || 'Describe this image in detail.',
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (!apiResponse.ok) {
|
|
160
|
+
const errorText = await apiResponse.text();
|
|
161
|
+
throw new Error(errorText || `Claude API error: ${apiResponse.status}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const data = await apiResponse.json();
|
|
165
|
+
const textResult = data?.content?.find?.(
|
|
166
|
+
(item: { type?: string; text?: string }) => item?.type === 'text',
|
|
167
|
+
)?.text;
|
|
168
|
+
|
|
169
|
+
if (!textResult) {
|
|
170
|
+
throw new Error('No description returned from Claude vision API.');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
setDescription(textResult);
|
|
174
|
+
return textResult;
|
|
175
|
+
} catch (err) {
|
|
176
|
+
const message = (err as Error).message || 'Failed to analyze image';
|
|
177
|
+
setError(message);
|
|
178
|
+
return null;
|
|
179
|
+
} finally {
|
|
180
|
+
setIsLoading(false);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
[normalizeImage, options],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
description,
|
|
188
|
+
isLoading,
|
|
189
|
+
error,
|
|
190
|
+
analyzeImage,
|
|
191
|
+
clearDescription,
|
|
192
|
+
};
|
|
193
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface Message {
|
|
2
|
+
role: 'user' | 'assistant';
|
|
3
|
+
content: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface AIProvider {
|
|
7
|
+
apiKey: string;
|
|
8
|
+
provider?: 'claude' | 'openai' | 'gemini';
|
|
9
|
+
model?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AIResponse {
|
|
13
|
+
content: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Visit https://aka.ms/tsconfig to read more about this file
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
// File Layout
|
|
5
|
+
// "rootDir": "./src",
|
|
6
|
+
// "outDir": "./dist",
|
|
7
|
+
|
|
8
|
+
// Environment Settings
|
|
9
|
+
// See also https://aka.ms/tsconfig/module
|
|
10
|
+
"module": "nodenext",
|
|
11
|
+
"target": "esnext",
|
|
12
|
+
"types": [],
|
|
13
|
+
// For nodejs:
|
|
14
|
+
// "lib": ["esnext"],
|
|
15
|
+
// "types": ["node"],
|
|
16
|
+
// and npm install -D @types/node
|
|
17
|
+
|
|
18
|
+
// Other Outputs
|
|
19
|
+
"sourceMap": true,
|
|
20
|
+
"declaration": true,
|
|
21
|
+
"declarationMap": true,
|
|
22
|
+
|
|
23
|
+
// Stricter Typechecking Options
|
|
24
|
+
"noUncheckedIndexedAccess": true,
|
|
25
|
+
"exactOptionalPropertyTypes": true,
|
|
26
|
+
|
|
27
|
+
// Style Options
|
|
28
|
+
// "noImplicitReturns": true,
|
|
29
|
+
// "noImplicitOverride": true,
|
|
30
|
+
// "noUnusedLocals": true,
|
|
31
|
+
// "noUnusedParameters": true,
|
|
32
|
+
// "noFallthroughCasesInSwitch": true,
|
|
33
|
+
// "noPropertyAccessFromIndexSignature": true,
|
|
34
|
+
|
|
35
|
+
// Recommended Options
|
|
36
|
+
"strict": true,
|
|
37
|
+
"jsx": "react-jsx",
|
|
38
|
+
"verbatimModuleSyntax": true,
|
|
39
|
+
"isolatedModules": true,
|
|
40
|
+
"noUncheckedSideEffectImports": true,
|
|
41
|
+
"moduleDetection": "force",
|
|
42
|
+
"skipLibCheck": true,
|
|
43
|
+
}
|
|
44
|
+
}
|