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
package/example/App.tsx
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { SafeAreaView, StatusBar, StyleSheet, Text, View, Pressable } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { APIKeysProvider } from './src/context/APIKeysContext';
|
|
5
|
+
import { ChatScreen } from './src/screens/ChatScreen';
|
|
6
|
+
import { SettingsScreen } from './src/screens/SettingsScreen';
|
|
7
|
+
|
|
8
|
+
type ScreenName = 'chat' | 'settings';
|
|
9
|
+
|
|
10
|
+
function ExampleAppShell() {
|
|
11
|
+
const [screen, setScreen] = useState<ScreenName>('chat');
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<SafeAreaView style={styles.safeArea}>
|
|
15
|
+
<StatusBar barStyle="dark-content" />
|
|
16
|
+
|
|
17
|
+
<View style={styles.topBar}>
|
|
18
|
+
<Text style={styles.brand}>react-native-ai-hooks</Text>
|
|
19
|
+
<View style={styles.segmentRow}>
|
|
20
|
+
<Pressable
|
|
21
|
+
accessibilityRole="button"
|
|
22
|
+
onPress={() => setScreen('chat')}
|
|
23
|
+
style={[styles.segmentButton, screen === 'chat' && styles.segmentButtonActive]}
|
|
24
|
+
>
|
|
25
|
+
<Text style={[styles.segmentText, screen === 'chat' && styles.segmentTextActive]}>Chat</Text>
|
|
26
|
+
</Pressable>
|
|
27
|
+
|
|
28
|
+
<Pressable
|
|
29
|
+
accessibilityRole="button"
|
|
30
|
+
onPress={() => setScreen('settings')}
|
|
31
|
+
style={[styles.segmentButton, screen === 'settings' && styles.segmentButtonActive]}
|
|
32
|
+
>
|
|
33
|
+
<Text style={[styles.segmentText, screen === 'settings' && styles.segmentTextActive]}>Settings</Text>
|
|
34
|
+
</Pressable>
|
|
35
|
+
</View>
|
|
36
|
+
</View>
|
|
37
|
+
|
|
38
|
+
<View style={styles.content}>{screen === 'chat' ? <ChatScreen /> : <SettingsScreen />}</View>
|
|
39
|
+
</SafeAreaView>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function App() {
|
|
44
|
+
return (
|
|
45
|
+
<APIKeysProvider>
|
|
46
|
+
<ExampleAppShell />
|
|
47
|
+
</APIKeysProvider>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const styles = StyleSheet.create({
|
|
52
|
+
safeArea: {
|
|
53
|
+
flex: 1,
|
|
54
|
+
backgroundColor: '#f8fafc',
|
|
55
|
+
},
|
|
56
|
+
topBar: {
|
|
57
|
+
paddingHorizontal: 20,
|
|
58
|
+
paddingTop: 10,
|
|
59
|
+
paddingBottom: 8,
|
|
60
|
+
borderBottomWidth: 1,
|
|
61
|
+
borderBottomColor: '#e2e8f0',
|
|
62
|
+
backgroundColor: '#ffffff',
|
|
63
|
+
},
|
|
64
|
+
brand: {
|
|
65
|
+
fontSize: 18,
|
|
66
|
+
fontWeight: '800',
|
|
67
|
+
color: '#0f172a',
|
|
68
|
+
marginBottom: 10,
|
|
69
|
+
},
|
|
70
|
+
segmentRow: {
|
|
71
|
+
flexDirection: 'row',
|
|
72
|
+
backgroundColor: '#f1f5f9',
|
|
73
|
+
borderRadius: 999,
|
|
74
|
+
padding: 4,
|
|
75
|
+
alignSelf: 'flex-start',
|
|
76
|
+
},
|
|
77
|
+
segmentButton: {
|
|
78
|
+
borderRadius: 999,
|
|
79
|
+
paddingHorizontal: 14,
|
|
80
|
+
paddingVertical: 8,
|
|
81
|
+
},
|
|
82
|
+
segmentButtonActive: {
|
|
83
|
+
backgroundColor: '#0f172a',
|
|
84
|
+
},
|
|
85
|
+
segmentText: {
|
|
86
|
+
color: '#334155',
|
|
87
|
+
fontWeight: '700',
|
|
88
|
+
},
|
|
89
|
+
segmentTextActive: {
|
|
90
|
+
color: '#ffffff',
|
|
91
|
+
},
|
|
92
|
+
content: {
|
|
93
|
+
flex: 1,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Example App
|
|
2
|
+
|
|
3
|
+
This Expo app demonstrates secure API key handling and hook usage.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Settings screen with local key storage using AsyncStorage
|
|
8
|
+
- Global context for Anthropic/OpenAI/Gemini keys
|
|
9
|
+
- Chat screen using useAIStream
|
|
10
|
+
- Friendly warning when no API key is available
|
|
11
|
+
|
|
12
|
+
## Run
|
|
13
|
+
|
|
14
|
+
1. Install dependencies
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
cd example
|
|
18
|
+
npm install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
2. Start app
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm run start
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
3. Open Settings, save at least one key, return to Chat.
|
package/example/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-native-ai-hooks-example",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "expo start",
|
|
8
|
+
"android": "expo start --android",
|
|
9
|
+
"ios": "expo start --ios",
|
|
10
|
+
"web": "expo start --web"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@react-native-async-storage/async-storage": "^1.24.0",
|
|
14
|
+
"expo": "~52.0.30",
|
|
15
|
+
"react": "18.3.1",
|
|
16
|
+
"react-native": "0.76.6",
|
|
17
|
+
"react-native-ai-hooks": "file:.."
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"typescript": "^5.7.2"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import type { SupportedProvider } from '../context/APIKeysContext';
|
|
5
|
+
|
|
6
|
+
type ProviderPickerProps = {
|
|
7
|
+
value: SupportedProvider;
|
|
8
|
+
onChange: (provider: SupportedProvider) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const options: Array<{ label: string; value: SupportedProvider }> = [
|
|
12
|
+
{ label: 'Anthropic', value: 'anthropic' },
|
|
13
|
+
{ label: 'OpenAI', value: 'openai' },
|
|
14
|
+
{ label: 'Gemini', value: 'gemini' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function ProviderPicker({ value, onChange }: ProviderPickerProps) {
|
|
18
|
+
return (
|
|
19
|
+
<View style={styles.row}>
|
|
20
|
+
{options.map(option => {
|
|
21
|
+
const active = value === option.value;
|
|
22
|
+
return (
|
|
23
|
+
<Pressable
|
|
24
|
+
accessibilityRole="button"
|
|
25
|
+
key={option.value}
|
|
26
|
+
onPress={() => onChange(option.value)}
|
|
27
|
+
style={[styles.pill, active && styles.pillActive]}
|
|
28
|
+
>
|
|
29
|
+
<Text style={[styles.pillText, active && styles.pillTextActive]}>{option.label}</Text>
|
|
30
|
+
</Pressable>
|
|
31
|
+
);
|
|
32
|
+
})}
|
|
33
|
+
</View>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const styles = StyleSheet.create({
|
|
38
|
+
row: {
|
|
39
|
+
flexDirection: 'row',
|
|
40
|
+
gap: 10,
|
|
41
|
+
marginBottom: 8,
|
|
42
|
+
},
|
|
43
|
+
pill: {
|
|
44
|
+
borderWidth: 1,
|
|
45
|
+
borderColor: '#d2dae6',
|
|
46
|
+
backgroundColor: '#ffffff',
|
|
47
|
+
paddingHorizontal: 14,
|
|
48
|
+
paddingVertical: 8,
|
|
49
|
+
borderRadius: 999,
|
|
50
|
+
},
|
|
51
|
+
pillActive: {
|
|
52
|
+
backgroundColor: '#0f172a',
|
|
53
|
+
borderColor: '#0f172a',
|
|
54
|
+
},
|
|
55
|
+
pillText: {
|
|
56
|
+
color: '#334155',
|
|
57
|
+
fontWeight: '600',
|
|
58
|
+
},
|
|
59
|
+
pillTextActive: {
|
|
60
|
+
color: '#ffffff',
|
|
61
|
+
},
|
|
62
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
|
+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
export type SupportedProvider = 'anthropic' | 'openai' | 'gemini';
|
|
5
|
+
|
|
6
|
+
type APIKeysState = {
|
|
7
|
+
anthropic: string;
|
|
8
|
+
openai: string;
|
|
9
|
+
gemini: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type APIKeysContextValue = {
|
|
13
|
+
keys: APIKeysState;
|
|
14
|
+
activeProvider: SupportedProvider;
|
|
15
|
+
isReady: boolean;
|
|
16
|
+
setActiveProvider: (provider: SupportedProvider) => void;
|
|
17
|
+
updateKey: (provider: SupportedProvider, value: string) => void;
|
|
18
|
+
saveKeys: () => Promise<void>;
|
|
19
|
+
getActiveKey: () => string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const STORAGE_KEY = 'react_native_ai_hooks_example_api_keys_v1';
|
|
23
|
+
|
|
24
|
+
const defaultKeys: APIKeysState = {
|
|
25
|
+
anthropic: '',
|
|
26
|
+
openai: '',
|
|
27
|
+
gemini: '',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const APIKeysContext = createContext<APIKeysContextValue | null>(null);
|
|
31
|
+
|
|
32
|
+
export function APIKeysProvider({ children }: { children: React.ReactNode }) {
|
|
33
|
+
const [keys, setKeys] = useState<APIKeysState>(defaultKeys);
|
|
34
|
+
const [activeProvider, setActiveProvider] = useState<SupportedProvider>('anthropic');
|
|
35
|
+
const [isReady, setIsReady] = useState(false);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const loadKeys = async () => {
|
|
39
|
+
try {
|
|
40
|
+
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
|
41
|
+
if (raw) {
|
|
42
|
+
const parsed = JSON.parse(raw) as Partial<APIKeysState>;
|
|
43
|
+
setKeys({
|
|
44
|
+
anthropic: parsed.anthropic ?? '',
|
|
45
|
+
openai: parsed.openai ?? '',
|
|
46
|
+
gemini: parsed.gemini ?? '',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Keep defaults if storage is unavailable or payload is invalid.
|
|
51
|
+
} finally {
|
|
52
|
+
setIsReady(true);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
loadKeys().catch(() => setIsReady(true));
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const updateKey = useCallback((provider: SupportedProvider, value: string) => {
|
|
60
|
+
setKeys(prev => ({
|
|
61
|
+
...prev,
|
|
62
|
+
[provider]: value.trim(),
|
|
63
|
+
}));
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const saveKeys = useCallback(async () => {
|
|
67
|
+
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keys));
|
|
68
|
+
}, [keys]);
|
|
69
|
+
|
|
70
|
+
const getActiveKey = useCallback(() => {
|
|
71
|
+
return keys[activeProvider] ?? '';
|
|
72
|
+
}, [activeProvider, keys]);
|
|
73
|
+
|
|
74
|
+
const value = useMemo<APIKeysContextValue>(
|
|
75
|
+
() => ({
|
|
76
|
+
keys,
|
|
77
|
+
activeProvider,
|
|
78
|
+
isReady,
|
|
79
|
+
setActiveProvider,
|
|
80
|
+
updateKey,
|
|
81
|
+
saveKeys,
|
|
82
|
+
getActiveKey,
|
|
83
|
+
}),
|
|
84
|
+
[activeProvider, getActiveKey, isReady, keys, saveKeys, updateKey],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return <APIKeysContext.Provider value={value}>{children}</APIKeysContext.Provider>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function useAPIKeys() {
|
|
91
|
+
const context = useContext(APIKeysContext);
|
|
92
|
+
if (!context) {
|
|
93
|
+
throw new Error('useAPIKeys must be used inside APIKeysProvider');
|
|
94
|
+
}
|
|
95
|
+
return context;
|
|
96
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
KeyboardAvoidingView,
|
|
5
|
+
Platform,
|
|
6
|
+
Pressable,
|
|
7
|
+
ScrollView,
|
|
8
|
+
StyleSheet,
|
|
9
|
+
Text,
|
|
10
|
+
TextInput,
|
|
11
|
+
View,
|
|
12
|
+
} from 'react-native';
|
|
13
|
+
import { useAIStream } from 'react-native-ai-hooks';
|
|
14
|
+
|
|
15
|
+
import { ProviderPicker } from '../components/ProviderPicker';
|
|
16
|
+
import { useAPIKeys } from '../context/APIKeysContext';
|
|
17
|
+
|
|
18
|
+
const NO_KEY_WARNING = 'Please enter your API key in Settings to start chatting';
|
|
19
|
+
|
|
20
|
+
const modelMap: Record<'anthropic' | 'openai' | 'gemini', string> = {
|
|
21
|
+
anthropic: 'claude-sonnet-4-20250514',
|
|
22
|
+
openai: 'gpt-4o-mini',
|
|
23
|
+
gemini: 'gemini-1.5-flash',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function ChatScreen() {
|
|
27
|
+
const { activeProvider, setActiveProvider, getActiveKey } = useAPIKeys();
|
|
28
|
+
const [prompt, setPrompt] = useState('');
|
|
29
|
+
|
|
30
|
+
const activeApiKey = getActiveKey();
|
|
31
|
+
const hasApiKey = activeApiKey.length > 0;
|
|
32
|
+
|
|
33
|
+
const streamOptions = useMemo(
|
|
34
|
+
() => ({
|
|
35
|
+
apiKey: activeApiKey,
|
|
36
|
+
provider: activeProvider,
|
|
37
|
+
model: modelMap[activeProvider],
|
|
38
|
+
temperature: 0.6,
|
|
39
|
+
maxTokens: 700,
|
|
40
|
+
}),
|
|
41
|
+
[activeApiKey, activeProvider],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const { response, isLoading, error, streamResponse, abort, clearResponse } = useAIStream(streamOptions);
|
|
45
|
+
|
|
46
|
+
const onSend = async () => {
|
|
47
|
+
if (!hasApiKey || !prompt.trim()) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await streamResponse(prompt.trim());
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<KeyboardAvoidingView
|
|
55
|
+
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
56
|
+
style={styles.flex}
|
|
57
|
+
>
|
|
58
|
+
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
|
|
59
|
+
<Text style={styles.header}>Stream Chat</Text>
|
|
60
|
+
<Text style={styles.subHeader}>Live token streaming demo using your saved API keys.</Text>
|
|
61
|
+
|
|
62
|
+
<ProviderPicker onChange={setActiveProvider} value={activeProvider} />
|
|
63
|
+
|
|
64
|
+
{!hasApiKey && (
|
|
65
|
+
<View style={styles.warningBox}>
|
|
66
|
+
<Text style={styles.warningText}>{NO_KEY_WARNING}</Text>
|
|
67
|
+
</View>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
<View style={styles.outputCard}>
|
|
71
|
+
<Text style={styles.outputLabel}>Assistant Output</Text>
|
|
72
|
+
{isLoading ? <ActivityIndicator color="#0f172a" style={styles.loader} /> : null}
|
|
73
|
+
<Text style={styles.outputText}>{response || 'Start a prompt to see streamed output here.'}</Text>
|
|
74
|
+
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
|
75
|
+
</View>
|
|
76
|
+
|
|
77
|
+
<TextInput
|
|
78
|
+
multiline
|
|
79
|
+
onChangeText={setPrompt}
|
|
80
|
+
placeholder="Ask anything..."
|
|
81
|
+
placeholderTextColor="#94a3b8"
|
|
82
|
+
style={styles.input}
|
|
83
|
+
value={prompt}
|
|
84
|
+
/>
|
|
85
|
+
|
|
86
|
+
<View style={styles.buttonRow}>
|
|
87
|
+
<Pressable
|
|
88
|
+
disabled={!hasApiKey || isLoading || !prompt.trim()}
|
|
89
|
+
onPress={onSend}
|
|
90
|
+
style={[styles.buttonPrimary, (!hasApiKey || isLoading || !prompt.trim()) && styles.buttonDisabled]}
|
|
91
|
+
>
|
|
92
|
+
<Text style={styles.buttonText}>Send</Text>
|
|
93
|
+
</Pressable>
|
|
94
|
+
|
|
95
|
+
<Pressable disabled={!isLoading} onPress={abort} style={[styles.buttonGhost, !isLoading && styles.buttonDisabledGhost]}>
|
|
96
|
+
<Text style={styles.buttonGhostText}>Stop</Text>
|
|
97
|
+
</Pressable>
|
|
98
|
+
|
|
99
|
+
<Pressable onPress={clearResponse} style={styles.buttonGhost}>
|
|
100
|
+
<Text style={styles.buttonGhostText}>Clear</Text>
|
|
101
|
+
</Pressable>
|
|
102
|
+
</View>
|
|
103
|
+
</ScrollView>
|
|
104
|
+
</KeyboardAvoidingView>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const styles = StyleSheet.create({
|
|
109
|
+
flex: {
|
|
110
|
+
flex: 1,
|
|
111
|
+
},
|
|
112
|
+
container: {
|
|
113
|
+
padding: 20,
|
|
114
|
+
gap: 14,
|
|
115
|
+
},
|
|
116
|
+
header: {
|
|
117
|
+
fontSize: 30,
|
|
118
|
+
fontWeight: '800',
|
|
119
|
+
color: '#0f172a',
|
|
120
|
+
},
|
|
121
|
+
subHeader: {
|
|
122
|
+
color: '#475569',
|
|
123
|
+
lineHeight: 20,
|
|
124
|
+
marginBottom: 6,
|
|
125
|
+
},
|
|
126
|
+
warningBox: {
|
|
127
|
+
backgroundColor: '#fff7ed',
|
|
128
|
+
borderColor: '#fdba74',
|
|
129
|
+
borderWidth: 1,
|
|
130
|
+
borderRadius: 12,
|
|
131
|
+
padding: 12,
|
|
132
|
+
},
|
|
133
|
+
warningText: {
|
|
134
|
+
color: '#9a3412',
|
|
135
|
+
fontWeight: '600',
|
|
136
|
+
},
|
|
137
|
+
outputCard: {
|
|
138
|
+
backgroundColor: '#ffffff',
|
|
139
|
+
borderWidth: 1,
|
|
140
|
+
borderColor: '#d5dde9',
|
|
141
|
+
borderRadius: 16,
|
|
142
|
+
padding: 14,
|
|
143
|
+
minHeight: 180,
|
|
144
|
+
},
|
|
145
|
+
outputLabel: {
|
|
146
|
+
fontWeight: '700',
|
|
147
|
+
color: '#0f172a',
|
|
148
|
+
marginBottom: 8,
|
|
149
|
+
},
|
|
150
|
+
loader: {
|
|
151
|
+
marginBottom: 8,
|
|
152
|
+
},
|
|
153
|
+
outputText: {
|
|
154
|
+
color: '#111827',
|
|
155
|
+
lineHeight: 22,
|
|
156
|
+
},
|
|
157
|
+
errorText: {
|
|
158
|
+
color: '#b91c1c',
|
|
159
|
+
marginTop: 12,
|
|
160
|
+
fontWeight: '600',
|
|
161
|
+
},
|
|
162
|
+
input: {
|
|
163
|
+
borderWidth: 1,
|
|
164
|
+
borderColor: '#d5dde9',
|
|
165
|
+
borderRadius: 14,
|
|
166
|
+
backgroundColor: '#ffffff',
|
|
167
|
+
paddingHorizontal: 14,
|
|
168
|
+
paddingVertical: 12,
|
|
169
|
+
minHeight: 96,
|
|
170
|
+
textAlignVertical: 'top',
|
|
171
|
+
color: '#111827',
|
|
172
|
+
},
|
|
173
|
+
buttonRow: {
|
|
174
|
+
flexDirection: 'row',
|
|
175
|
+
gap: 10,
|
|
176
|
+
},
|
|
177
|
+
buttonPrimary: {
|
|
178
|
+
backgroundColor: '#0f172a',
|
|
179
|
+
borderRadius: 12,
|
|
180
|
+
paddingHorizontal: 18,
|
|
181
|
+
paddingVertical: 12,
|
|
182
|
+
},
|
|
183
|
+
buttonText: {
|
|
184
|
+
color: '#ffffff',
|
|
185
|
+
fontWeight: '700',
|
|
186
|
+
},
|
|
187
|
+
buttonGhost: {
|
|
188
|
+
borderColor: '#cbd5e1',
|
|
189
|
+
borderWidth: 1,
|
|
190
|
+
borderRadius: 12,
|
|
191
|
+
paddingHorizontal: 16,
|
|
192
|
+
paddingVertical: 12,
|
|
193
|
+
backgroundColor: '#ffffff',
|
|
194
|
+
},
|
|
195
|
+
buttonGhostText: {
|
|
196
|
+
color: '#334155',
|
|
197
|
+
fontWeight: '700',
|
|
198
|
+
},
|
|
199
|
+
buttonDisabled: {
|
|
200
|
+
opacity: 0.45,
|
|
201
|
+
},
|
|
202
|
+
buttonDisabledGhost: {
|
|
203
|
+
opacity: 0.5,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { Alert, ScrollView, StyleSheet, Text, TextInput, View, Pressable } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { useAPIKeys } from '../context/APIKeysContext';
|
|
5
|
+
|
|
6
|
+
function getTitle(provider: 'anthropic' | 'openai' | 'gemini') {
|
|
7
|
+
if (provider === 'anthropic') {
|
|
8
|
+
return 'Anthropic API Key';
|
|
9
|
+
}
|
|
10
|
+
if (provider === 'openai') {
|
|
11
|
+
return 'OpenAI API Key';
|
|
12
|
+
}
|
|
13
|
+
return 'Gemini API Key';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function SettingsScreen() {
|
|
17
|
+
const { keys, updateKey, saveKeys } = useAPIKeys();
|
|
18
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
19
|
+
|
|
20
|
+
const providers = useMemo<Array<'anthropic' | 'openai' | 'gemini'>>(
|
|
21
|
+
() => ['anthropic', 'openai', 'gemini'],
|
|
22
|
+
[],
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const onSave = async () => {
|
|
26
|
+
try {
|
|
27
|
+
setIsSaving(true);
|
|
28
|
+
await saveKeys();
|
|
29
|
+
Alert.alert('Saved', 'Your API keys were saved locally on this device.');
|
|
30
|
+
} catch {
|
|
31
|
+
Alert.alert('Error', 'Could not save keys. Please try again.');
|
|
32
|
+
} finally {
|
|
33
|
+
setIsSaving(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<ScrollView contentContainerStyle={styles.container}>
|
|
39
|
+
<Text style={styles.header}>Settings</Text>
|
|
40
|
+
<Text style={styles.subHeader}>
|
|
41
|
+
Add your API keys below. They are stored locally using AsyncStorage.
|
|
42
|
+
</Text>
|
|
43
|
+
|
|
44
|
+
{providers.map(provider => (
|
|
45
|
+
<View style={styles.inputGroup} key={provider}>
|
|
46
|
+
<Text style={styles.label}>{getTitle(provider)}</Text>
|
|
47
|
+
<TextInput
|
|
48
|
+
autoCapitalize="none"
|
|
49
|
+
autoCorrect={false}
|
|
50
|
+
keyboardType="default"
|
|
51
|
+
onChangeText={value => updateKey(provider, value)}
|
|
52
|
+
placeholder={`Enter ${provider} key`}
|
|
53
|
+
placeholderTextColor="#94a3b8"
|
|
54
|
+
style={styles.input}
|
|
55
|
+
value={keys[provider]}
|
|
56
|
+
/>
|
|
57
|
+
</View>
|
|
58
|
+
))}
|
|
59
|
+
|
|
60
|
+
<Pressable onPress={onSave} style={[styles.saveButton, isSaving && styles.saveButtonDisabled]}>
|
|
61
|
+
<Text style={styles.saveButtonText}>{isSaving ? 'Saving...' : 'Save Keys'}</Text>
|
|
62
|
+
</Pressable>
|
|
63
|
+
|
|
64
|
+
<Text style={styles.note}>
|
|
65
|
+
Security tip: For production apps, prefer routing requests through your own backend proxy.
|
|
66
|
+
</Text>
|
|
67
|
+
</ScrollView>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const styles = StyleSheet.create({
|
|
72
|
+
container: {
|
|
73
|
+
padding: 20,
|
|
74
|
+
gap: 12,
|
|
75
|
+
},
|
|
76
|
+
header: {
|
|
77
|
+
fontSize: 30,
|
|
78
|
+
fontWeight: '800',
|
|
79
|
+
color: '#0f172a',
|
|
80
|
+
},
|
|
81
|
+
subHeader: {
|
|
82
|
+
color: '#475569',
|
|
83
|
+
lineHeight: 20,
|
|
84
|
+
marginBottom: 12,
|
|
85
|
+
},
|
|
86
|
+
inputGroup: {
|
|
87
|
+
gap: 8,
|
|
88
|
+
},
|
|
89
|
+
label: {
|
|
90
|
+
fontSize: 14,
|
|
91
|
+
color: '#0f172a',
|
|
92
|
+
fontWeight: '700',
|
|
93
|
+
},
|
|
94
|
+
input: {
|
|
95
|
+
borderWidth: 1,
|
|
96
|
+
borderColor: '#d5dde9',
|
|
97
|
+
borderRadius: 14,
|
|
98
|
+
paddingHorizontal: 14,
|
|
99
|
+
paddingVertical: 12,
|
|
100
|
+
color: '#111827',
|
|
101
|
+
backgroundColor: '#ffffff',
|
|
102
|
+
},
|
|
103
|
+
saveButton: {
|
|
104
|
+
backgroundColor: '#0f172a',
|
|
105
|
+
borderRadius: 14,
|
|
106
|
+
alignItems: 'center',
|
|
107
|
+
paddingVertical: 14,
|
|
108
|
+
marginTop: 8,
|
|
109
|
+
},
|
|
110
|
+
saveButtonDisabled: {
|
|
111
|
+
opacity: 0.7,
|
|
112
|
+
},
|
|
113
|
+
saveButtonText: {
|
|
114
|
+
color: '#ffffff',
|
|
115
|
+
fontWeight: '700',
|
|
116
|
+
fontSize: 16,
|
|
117
|
+
},
|
|
118
|
+
note: {
|
|
119
|
+
color: '#64748b',
|
|
120
|
+
marginTop: 10,
|
|
121
|
+
fontSize: 12,
|
|
122
|
+
lineHeight: 18,
|
|
123
|
+
},
|
|
124
|
+
});
|
package/package.json
CHANGED