react-native-ai-hooks 0.2.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/useAICode.ts +206 -0
- package/src/hooks/useAIForm.ts +84 -202
- package/src/hooks/useAIStream.ts +104 -57
- package/src/hooks/useAISummarize.ts +158 -0
- package/src/hooks/useAITranslate.ts +207 -0
- package/src/hooks/useImageAnalysis.ts +126 -79
- package/src/index.ts +28 -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
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
type SummaryLength = 'short' | 'medium' | 'long';
|
|
4
|
+
|
|
5
|
+
interface UseAISummarizeOptions {
|
|
6
|
+
apiKey: string;
|
|
7
|
+
model?: string;
|
|
8
|
+
system?: string;
|
|
9
|
+
maxTokens?: number;
|
|
10
|
+
temperature?: number;
|
|
11
|
+
defaultLength?: SummaryLength;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface SummarizeInput {
|
|
15
|
+
text: string;
|
|
16
|
+
length?: SummaryLength;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface UseAISummarizeReturn {
|
|
20
|
+
summary: string;
|
|
21
|
+
length: SummaryLength;
|
|
22
|
+
isLoading: boolean;
|
|
23
|
+
error: string | null;
|
|
24
|
+
setLength: (length: SummaryLength) => void;
|
|
25
|
+
summarizeText: (input: SummarizeInput) => Promise<string | null>;
|
|
26
|
+
clearSummary: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ClaudeTextBlock {
|
|
30
|
+
type?: string;
|
|
31
|
+
text?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ClaudeApiResult {
|
|
35
|
+
content?: ClaudeTextBlock[];
|
|
36
|
+
error?: {
|
|
37
|
+
message?: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getClaudeTextContent(data: unknown): string {
|
|
42
|
+
const content = (data as ClaudeApiResult)?.content;
|
|
43
|
+
if (!Array.isArray(content)) {
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return content
|
|
48
|
+
.filter(item => item?.type === 'text' && typeof item.text === 'string')
|
|
49
|
+
.map(item => item.text as string)
|
|
50
|
+
.join('\n')
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function lengthInstruction(length: SummaryLength): string {
|
|
55
|
+
if (length === 'short') {
|
|
56
|
+
return 'Produce a concise summary in 2-4 bullet points.';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (length === 'long') {
|
|
60
|
+
return 'Produce a detailed summary with key points, context, and implications in 3-5 short paragraphs.';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return 'Produce a balanced summary in 1-2 paragraphs plus a short bullet list of key takeaways.';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useAISummarize(options: UseAISummarizeOptions): UseAISummarizeReturn {
|
|
67
|
+
const [summary, setSummary] = useState('');
|
|
68
|
+
const [length, setLength] = useState<SummaryLength>(options.defaultLength || 'medium');
|
|
69
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
70
|
+
const [error, setError] = useState<string | null>(null);
|
|
71
|
+
|
|
72
|
+
const isMountedRef = useRef(true);
|
|
73
|
+
|
|
74
|
+
const clearSummary = useCallback(() => {
|
|
75
|
+
setSummary('');
|
|
76
|
+
setError(null);
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
const summarizeText = useCallback(
|
|
80
|
+
async (input: SummarizeInput) => {
|
|
81
|
+
const text = input.text.trim();
|
|
82
|
+
const selectedLength = input.length || length;
|
|
83
|
+
|
|
84
|
+
if (!text) {
|
|
85
|
+
setError('No text provided for summarization.');
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!options.apiKey) {
|
|
90
|
+
setError('Missing Claude API key.');
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setIsLoading(true);
|
|
95
|
+
setError(null);
|
|
96
|
+
setLength(selectedLength);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const prompt = [
|
|
100
|
+
'Summarize the following text.',
|
|
101
|
+
lengthInstruction(selectedLength),
|
|
102
|
+
'Text:',
|
|
103
|
+
text,
|
|
104
|
+
].join('\n');
|
|
105
|
+
|
|
106
|
+
const apiResponse = await fetch('https://api.anthropic.com/v1/messages', {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: {
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
'x-api-key': options.apiKey,
|
|
111
|
+
'anthropic-version': '2023-06-01',
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
model: options.model || 'claude-sonnet-4-20250514',
|
|
115
|
+
max_tokens: options.maxTokens ?? 1200,
|
|
116
|
+
temperature: options.temperature ?? 0.3,
|
|
117
|
+
system:
|
|
118
|
+
options.system ||
|
|
119
|
+
'You are an expert summarization assistant. Keep summaries faithful to source text and avoid fabrications.',
|
|
120
|
+
messages: [{ role: 'user', content: prompt }],
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const data = (await apiResponse.json()) as ClaudeApiResult;
|
|
125
|
+
if (!apiResponse.ok) {
|
|
126
|
+
throw new Error(data?.error?.message || `Claude API error: ${apiResponse.status}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const result = getClaudeTextContent(data);
|
|
130
|
+
if (!result) {
|
|
131
|
+
throw new Error('No summary returned by Claude API.');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setSummary(result);
|
|
135
|
+
return result;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
const message = (err as Error).message || 'Failed to summarize text';
|
|
138
|
+
setError(message);
|
|
139
|
+
return null;
|
|
140
|
+
} finally {
|
|
141
|
+
if (isMountedRef.current) {
|
|
142
|
+
setIsLoading(false);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[length, options.apiKey, options.maxTokens, options.model, options.system, options.temperature],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
summary,
|
|
151
|
+
length,
|
|
152
|
+
isLoading,
|
|
153
|
+
error,
|
|
154
|
+
setLength,
|
|
155
|
+
summarizeText,
|
|
156
|
+
clearSummary,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface UseAITranslateOptions {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
model?: string;
|
|
6
|
+
system?: string;
|
|
7
|
+
maxTokens?: number;
|
|
8
|
+
temperature?: number;
|
|
9
|
+
initialTargetLanguage?: string;
|
|
10
|
+
autoTranslate?: boolean;
|
|
11
|
+
debounceMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TranslationResult {
|
|
15
|
+
translatedText: string;
|
|
16
|
+
detectedSourceLanguage: string;
|
|
17
|
+
targetLanguage: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface UseAITranslateReturn {
|
|
21
|
+
sourceText: string;
|
|
22
|
+
translatedText: string;
|
|
23
|
+
detectedSourceLanguage: string;
|
|
24
|
+
targetLanguage: string;
|
|
25
|
+
isTranslating: boolean;
|
|
26
|
+
error: string | null;
|
|
27
|
+
setSourceText: (text: string) => void;
|
|
28
|
+
setTargetLanguage: (language: string) => void;
|
|
29
|
+
translateText: (overrideText?: string) => Promise<TranslationResult | null>;
|
|
30
|
+
clearTranslation: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ClaudeTextBlock {
|
|
34
|
+
type?: string;
|
|
35
|
+
text?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ClaudeApiResult {
|
|
39
|
+
content?: ClaudeTextBlock[];
|
|
40
|
+
error?: {
|
|
41
|
+
message?: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getClaudeTextContent(data: unknown): string {
|
|
46
|
+
const content = (data as ClaudeApiResult)?.content;
|
|
47
|
+
if (!Array.isArray(content)) {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return content
|
|
52
|
+
.filter(item => item?.type === 'text' && typeof item.text === 'string')
|
|
53
|
+
.map(item => item.text as string)
|
|
54
|
+
.join('\n')
|
|
55
|
+
.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseJsonFromText<T>(text: string): T {
|
|
59
|
+
const trimmed = text.trim();
|
|
60
|
+
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
61
|
+
const candidate = fenced?.[1]?.trim() || trimmed;
|
|
62
|
+
return JSON.parse(candidate) as T;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useAITranslate(options: UseAITranslateOptions): UseAITranslateReturn {
|
|
66
|
+
const [sourceText, setSourceText] = useState('');
|
|
67
|
+
const [translatedText, setTranslatedText] = useState('');
|
|
68
|
+
const [detectedSourceLanguage, setDetectedSourceLanguage] = useState('');
|
|
69
|
+
const [targetLanguage, setTargetLanguage] = useState(options.initialTargetLanguage || 'English');
|
|
70
|
+
const [isTranslating, setIsTranslating] = useState(false);
|
|
71
|
+
const [error, setError] = useState<string | null>(null);
|
|
72
|
+
|
|
73
|
+
const isMountedRef = useRef(true);
|
|
74
|
+
|
|
75
|
+
const clearTranslation = useCallback(() => {
|
|
76
|
+
setSourceText('');
|
|
77
|
+
setTranslatedText('');
|
|
78
|
+
setDetectedSourceLanguage('');
|
|
79
|
+
setError(null);
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
const translateText = useCallback(
|
|
83
|
+
async (overrideText?: string) => {
|
|
84
|
+
const textToTranslate = (overrideText ?? sourceText).trim();
|
|
85
|
+
|
|
86
|
+
if (!textToTranslate) {
|
|
87
|
+
setError('No text to translate.');
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!options.apiKey) {
|
|
92
|
+
setError('Missing Claude API key.');
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setIsTranslating(true);
|
|
97
|
+
setError(null);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const prompt = [
|
|
101
|
+
'Detect source language and translate text.',
|
|
102
|
+
'Return JSON only using this schema:',
|
|
103
|
+
'{"detectedSourceLanguage":"string","targetLanguage":"string","translatedText":"string"}',
|
|
104
|
+
`Target language: ${targetLanguage}`,
|
|
105
|
+
'Text:',
|
|
106
|
+
textToTranslate,
|
|
107
|
+
].join('\n');
|
|
108
|
+
|
|
109
|
+
const apiResponse = await fetch('https://api.anthropic.com/v1/messages', {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: {
|
|
112
|
+
'Content-Type': 'application/json',
|
|
113
|
+
'x-api-key': options.apiKey,
|
|
114
|
+
'anthropic-version': '2023-06-01',
|
|
115
|
+
},
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
model: options.model || 'claude-sonnet-4-20250514',
|
|
118
|
+
max_tokens: options.maxTokens ?? 800,
|
|
119
|
+
temperature: options.temperature ?? 0.2,
|
|
120
|
+
system:
|
|
121
|
+
options.system ||
|
|
122
|
+
'You are a translation assistant. Always detect source language and translate accurately. Return valid JSON only.',
|
|
123
|
+
messages: [{ role: 'user', content: prompt }],
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const data = (await apiResponse.json()) as ClaudeApiResult;
|
|
128
|
+
if (!apiResponse.ok) {
|
|
129
|
+
throw new Error(data?.error?.message || `Claude API error: ${apiResponse.status}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const text = getClaudeTextContent(data);
|
|
133
|
+
if (!text) {
|
|
134
|
+
throw new Error('No translation returned by Claude API.');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const parsed = parseJsonFromText<{
|
|
138
|
+
translatedText?: string;
|
|
139
|
+
detectedSourceLanguage?: string;
|
|
140
|
+
targetLanguage?: string;
|
|
141
|
+
}>(text);
|
|
142
|
+
|
|
143
|
+
const result: TranslationResult = {
|
|
144
|
+
translatedText: parsed?.translatedText?.trim() || '',
|
|
145
|
+
detectedSourceLanguage: parsed?.detectedSourceLanguage?.trim() || 'Unknown',
|
|
146
|
+
targetLanguage: parsed?.targetLanguage?.trim() || targetLanguage,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (!result.translatedText) {
|
|
150
|
+
throw new Error('Invalid translation format returned by Claude API.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
setTranslatedText(result.translatedText);
|
|
154
|
+
setDetectedSourceLanguage(result.detectedSourceLanguage);
|
|
155
|
+
return result;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const message = (err as Error).message || 'Failed to translate text';
|
|
158
|
+
setError(message);
|
|
159
|
+
return null;
|
|
160
|
+
} finally {
|
|
161
|
+
if (isMountedRef.current) {
|
|
162
|
+
setIsTranslating(false);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
[options.apiKey, options.maxTokens, options.model, options.system, options.temperature, sourceText, targetLanguage],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
isMountedRef.current = true;
|
|
171
|
+
return () => {
|
|
172
|
+
isMountedRef.current = false;
|
|
173
|
+
};
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (options.autoTranslate === false) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const text = sourceText.trim();
|
|
182
|
+
if (!text) {
|
|
183
|
+
setTranslatedText('');
|
|
184
|
+
setDetectedSourceLanguage('');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const timer = setTimeout(() => {
|
|
189
|
+
translateText(text).catch(() => undefined);
|
|
190
|
+
}, options.debounceMs ?? 500);
|
|
191
|
+
|
|
192
|
+
return () => clearTimeout(timer);
|
|
193
|
+
}, [options.autoTranslate, options.debounceMs, sourceText, targetLanguage, translateText]);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
sourceText,
|
|
197
|
+
translatedText,
|
|
198
|
+
detectedSourceLanguage,
|
|
199
|
+
targetLanguage,
|
|
200
|
+
isTranslating,
|
|
201
|
+
error,
|
|
202
|
+
setSourceText,
|
|
203
|
+
setTargetLanguage,
|
|
204
|
+
translateText,
|
|
205
|
+
clearTranslation,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -1,47 +1,20 @@
|
|
|
1
|
-
import { useCallback, useState } from 'react';
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
}
|
|
1
|
+
import { useCallback, useState, useRef, useMemo } from 'react';
|
|
2
|
+
import type { UseImageAnalysisOptions, UseImageAnalysisReturn } from '../types';
|
|
3
|
+
import { createProvider } from '../utils/providerFactory';
|
|
16
4
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
clearDescription: () => void;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface ParsedImageData {
|
|
26
|
-
base64: string;
|
|
27
|
-
mediaType: string;
|
|
28
|
-
}
|
|
5
|
+
const DEFAULT_MODEL_MAP = {
|
|
6
|
+
anthropic: 'claude-sonnet-4-20250514',
|
|
7
|
+
openai: 'gpt-4-vision',
|
|
8
|
+
gemini: 'gemini-pro-vision',
|
|
9
|
+
};
|
|
29
10
|
|
|
30
11
|
const DATA_URI_REGEX = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/;
|
|
31
12
|
|
|
32
13
|
function getMediaTypeFromUri(uri: string): string {
|
|
33
14
|
const normalized = uri.toLowerCase();
|
|
34
|
-
|
|
35
|
-
if (normalized.includes('.
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
if (normalized.includes('.webp')) {
|
|
39
|
-
return 'image/webp';
|
|
40
|
-
}
|
|
41
|
-
if (normalized.includes('.gif')) {
|
|
42
|
-
return 'image/gif';
|
|
43
|
-
}
|
|
44
|
-
|
|
15
|
+
if (normalized.includes('.png')) return 'image/png';
|
|
16
|
+
if (normalized.includes('.webp')) return 'image/webp';
|
|
17
|
+
if (normalized.includes('.gif')) return 'image/gif';
|
|
45
18
|
return 'image/jpeg';
|
|
46
19
|
}
|
|
47
20
|
|
|
@@ -56,7 +29,7 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
|
56
29
|
}
|
|
57
30
|
|
|
58
31
|
if (typeof btoa !== 'function') {
|
|
59
|
-
throw new Error('Base64 conversion
|
|
32
|
+
throw new Error('Base64 conversion unavailable. Provide uriToBase64 in hook options.');
|
|
60
33
|
}
|
|
61
34
|
|
|
62
35
|
return btoa(binary);
|
|
@@ -77,21 +50,31 @@ export function useImageAnalysis(options: UseImageAnalysisOptions): UseImageAnal
|
|
|
77
50
|
const [isLoading, setIsLoading] = useState(false);
|
|
78
51
|
const [error, setError] = useState<string | null>(null);
|
|
79
52
|
|
|
53
|
+
const isMountedRef = useRef(true);
|
|
54
|
+
|
|
55
|
+
const providerConfig = useMemo(
|
|
56
|
+
() => ({
|
|
57
|
+
provider: (options.provider || 'anthropic') as 'anthropic' | 'openai' | 'gemini',
|
|
58
|
+
apiKey: options.apiKey,
|
|
59
|
+
model: options.model || DEFAULT_MODEL_MAP[options.provider || 'anthropic'],
|
|
60
|
+
baseUrl: options.baseUrl,
|
|
61
|
+
timeout: options.timeout,
|
|
62
|
+
maxRetries: options.maxRetries,
|
|
63
|
+
}),
|
|
64
|
+
[options],
|
|
65
|
+
);
|
|
66
|
+
|
|
80
67
|
const normalizeImage = useCallback(
|
|
81
|
-
async (image: string, mediaType?: string): Promise<
|
|
68
|
+
async (image: string, mediaType?: string): Promise<{ base64: string; mediaType: string }> => {
|
|
82
69
|
const dataUriMatch = image.match(DATA_URI_REGEX);
|
|
83
70
|
if (dataUriMatch) {
|
|
84
|
-
const matchedMediaType = dataUriMatch[1];
|
|
85
|
-
const base64Data = dataUriMatch[2];
|
|
86
71
|
return {
|
|
87
|
-
base64:
|
|
88
|
-
mediaType: mediaType ||
|
|
72
|
+
base64: dataUriMatch[2],
|
|
73
|
+
mediaType: mediaType || dataUriMatch[1],
|
|
89
74
|
};
|
|
90
75
|
}
|
|
91
76
|
|
|
92
|
-
const isLikelyUri = /^(https?:\/\/|file:\/\/|content:\/\/|ph:\/\/|assets-library:\/\/)/i.test(
|
|
93
|
-
image,
|
|
94
|
-
);
|
|
77
|
+
const isLikelyUri = /^(https?:\/\/|file:\/\/|content:\/\/|ph:\/\/|assets-library:\/\/)/i.test(image);
|
|
95
78
|
|
|
96
79
|
if (isLikelyUri) {
|
|
97
80
|
const toBase64 = options.uriToBase64 || defaultUriToBase64;
|
|
@@ -116,24 +99,39 @@ export function useImageAnalysis(options: UseImageAnalysisOptions): UseImageAnal
|
|
|
116
99
|
}, []);
|
|
117
100
|
|
|
118
101
|
const analyzeImage = useCallback(
|
|
119
|
-
async (
|
|
120
|
-
|
|
102
|
+
async (uri: string, prompt?: string) => {
|
|
103
|
+
if (!uri) {
|
|
104
|
+
setError('Image URI is required');
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!options.apiKey) {
|
|
109
|
+
setError('Missing API key');
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
121
113
|
setError(null);
|
|
114
|
+
setIsLoading(true);
|
|
122
115
|
|
|
123
116
|
try {
|
|
124
|
-
const
|
|
117
|
+
const imagData = await normalizeImage(uri);
|
|
118
|
+
const analysisPrompt = prompt || 'Describe this image in detail.';
|
|
125
119
|
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
120
|
+
const base_url = providerConfig.baseUrl || 'https://api.anthropic.com';
|
|
121
|
+
const headers: Record<string, string> = {
|
|
122
|
+
'Content-Type': 'application/json',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
let url = '';
|
|
126
|
+
let body: Record<string, unknown> = {};
|
|
127
|
+
|
|
128
|
+
if (providerConfig.provider === 'anthropic') {
|
|
129
|
+
url = `${base_url}/v1/messages`;
|
|
130
|
+
headers['x-api-key'] = options.apiKey;
|
|
131
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
132
|
+
body = {
|
|
133
|
+
model: providerConfig.model,
|
|
135
134
|
max_tokens: options.maxTokens ?? 1024,
|
|
136
|
-
system: options.system,
|
|
137
135
|
messages: [
|
|
138
136
|
{
|
|
139
137
|
role: 'user',
|
|
@@ -142,47 +140,96 @@ export function useImageAnalysis(options: UseImageAnalysisOptions): UseImageAnal
|
|
|
142
140
|
type: 'image',
|
|
143
141
|
source: {
|
|
144
142
|
type: 'base64',
|
|
145
|
-
media_type:
|
|
146
|
-
data:
|
|
143
|
+
media_type: imagData.mediaType,
|
|
144
|
+
data: imagData.base64,
|
|
147
145
|
},
|
|
148
146
|
},
|
|
149
147
|
{
|
|
150
148
|
type: 'text',
|
|
151
|
-
text:
|
|
149
|
+
text: analysisPrompt,
|
|
152
150
|
},
|
|
153
151
|
],
|
|
154
152
|
},
|
|
155
153
|
],
|
|
156
|
-
}
|
|
154
|
+
};
|
|
155
|
+
} else if (providerConfig.provider === 'openai') {
|
|
156
|
+
url = `${providerConfig.baseUrl || 'https://api.openai.com'}/v1/chat/completions`;
|
|
157
|
+
headers.Authorization = `Bearer ${options.apiKey}`;
|
|
158
|
+
body = {
|
|
159
|
+
model: providerConfig.model,
|
|
160
|
+
max_tokens: options.maxTokens ?? 1024,
|
|
161
|
+
messages: [
|
|
162
|
+
{
|
|
163
|
+
role: 'user',
|
|
164
|
+
content: [
|
|
165
|
+
{
|
|
166
|
+
type: 'image_url',
|
|
167
|
+
image_url: {
|
|
168
|
+
url: `data:${imagData.mediaType};base64,${imagData.base64}`,
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
type: 'text',
|
|
173
|
+
text: analysisPrompt,
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
} else {
|
|
180
|
+
throw new Error(`Image analysis not supported for provider: ${providerConfig.provider}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const response = await fetch(url, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers,
|
|
186
|
+
body: JSON.stringify(body),
|
|
157
187
|
});
|
|
158
188
|
|
|
159
|
-
if (!
|
|
160
|
-
const errorText = await
|
|
161
|
-
throw new Error(errorText || `
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
const errorText = await response.text();
|
|
191
|
+
throw new Error(errorText || `API error: ${response.status}`);
|
|
162
192
|
}
|
|
163
193
|
|
|
164
|
-
const data = await
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
)
|
|
194
|
+
const data = await response.json();
|
|
195
|
+
let resultText = '';
|
|
196
|
+
|
|
197
|
+
if (providerConfig.provider === 'anthropic') {
|
|
198
|
+
resultText = data?.content?.[0]?.text || '';
|
|
199
|
+
} else if (providerConfig.provider === 'openai') {
|
|
200
|
+
resultText = data?.choices?.[0]?.message?.content || '';
|
|
201
|
+
}
|
|
168
202
|
|
|
169
|
-
if (!
|
|
170
|
-
throw new Error('No description returned
|
|
203
|
+
if (!resultText) {
|
|
204
|
+
throw new Error('No description returned by vision API');
|
|
171
205
|
}
|
|
172
206
|
|
|
173
|
-
|
|
174
|
-
|
|
207
|
+
if (isMountedRef.current) {
|
|
208
|
+
setDescription(resultText);
|
|
209
|
+
}
|
|
210
|
+
return resultText;
|
|
175
211
|
} catch (err) {
|
|
176
|
-
|
|
177
|
-
|
|
212
|
+
if (isMountedRef.current) {
|
|
213
|
+
const message = err instanceof Error ? err.message : 'Failed to analyze image';
|
|
214
|
+
setError(message);
|
|
215
|
+
}
|
|
178
216
|
return null;
|
|
179
217
|
} finally {
|
|
180
|
-
|
|
218
|
+
if (isMountedRef.current) {
|
|
219
|
+
setIsLoading(false);
|
|
220
|
+
}
|
|
181
221
|
}
|
|
182
222
|
},
|
|
183
|
-
[normalizeImage, options],
|
|
223
|
+
[normalizeImage, options, providerConfig],
|
|
184
224
|
);
|
|
185
225
|
|
|
226
|
+
useState(() => {
|
|
227
|
+
isMountedRef.current = true;
|
|
228
|
+
return () => {
|
|
229
|
+
isMountedRef.current = false;
|
|
230
|
+
};
|
|
231
|
+
}, []);
|
|
232
|
+
|
|
186
233
|
return {
|
|
187
234
|
description,
|
|
188
235
|
isLoading,
|
package/src/index.ts
CHANGED
|
@@ -2,4 +2,31 @@ export { useAIChat } from './hooks/useAIChat';
|
|
|
2
2
|
export { useAIStream } from './hooks/useAIStream';
|
|
3
3
|
export { useImageAnalysis } from './hooks/useImageAnalysis';
|
|
4
4
|
export { useAIForm } from './hooks/useAIForm';
|
|
5
|
-
export { useAIVoice } from './hooks/useAIVoice';
|
|
5
|
+
export { useAIVoice } from './hooks/useAIVoice';
|
|
6
|
+
export { useAITranslate } from './hooks/useAITranslate';
|
|
7
|
+
export { useAISummarize } from './hooks/useAISummarize';
|
|
8
|
+
export { useAICode } from './hooks/useAICode';
|
|
9
|
+
|
|
10
|
+
// Type exports
|
|
11
|
+
export type {
|
|
12
|
+
Message,
|
|
13
|
+
AIProviderType,
|
|
14
|
+
ProviderConfig,
|
|
15
|
+
AIResponse,
|
|
16
|
+
AIRequestOptions,
|
|
17
|
+
UseAIChatOptions,
|
|
18
|
+
UseAIChatReturn,
|
|
19
|
+
UseAIStreamOptions,
|
|
20
|
+
UseAIStreamReturn,
|
|
21
|
+
UseImageAnalysisOptions,
|
|
22
|
+
UseImageAnalysisReturn,
|
|
23
|
+
UseAIFormOptions,
|
|
24
|
+
UseAIFormReturn,
|
|
25
|
+
FormValidationRequest,
|
|
26
|
+
FormValidationResult,
|
|
27
|
+
} from './types';
|
|
28
|
+
|
|
29
|
+
// Utility exports for advanced use cases
|
|
30
|
+
export { createProvider, ProviderFactory } from './utils/providerFactory';
|
|
31
|
+
export { fetchWithRetry } from './utils/fetchWithRetry';
|
|
32
|
+
export type { AIResponse, AIRequestOptions } from './utils/providerFactory';
|