|
1
|
import React, { useState, useCallback } from "react"; |
|
2
|
import { |
|
3
|
View, |
|
4
|
Text, |
|
5
|
TextInput, |
|
6
|
TouchableOpacity, |
|
7
|
FlatList, |
|
8
|
ActivityIndicator, |
|
9
|
SafeAreaView, |
|
10
|
KeyboardAvoidingView, |
|
11
|
Platform, |
|
12
|
Alert, |
|
13
|
StyleSheet, |
|
14
|
} from "react-native"; |
|
15
|
import { |
|
16
|
OndeChatEngine, |
|
17
|
userMessage, |
|
18
|
type ChatMessage, |
|
19
|
type EngineStatus, |
|
20
|
} from "@ondeinference/react-native"; |
|
21
|
|
|
22
|
// ── Onde app credentials ───────────────────────────────────────────────── |
|
23
|
// Register your app at https://ondeinference.com to get these. |
|
24
|
// The dashboard lets you assign a model to your app — the SDK fetches |
|
25
|
// it automatically. If no model is assigned, the platform default is used. |
|
26
|
const ONDE_APP_ID = ""; |
|
27
|
const ONDE_APP_SECRET = ""; |
|
28
|
|
|
29
|
interface DisplayMessage { |
|
30
|
id: string; |
|
31
|
role: "user" | "assistant"; |
|
32
|
content: string; |
|
33
|
duration?: string; |
|
34
|
} |
|
35
|
|
|
36
|
const STATUS_COLORS: Record<EngineStatus, string> = { |
|
37
|
Unloaded: "#999", |
|
38
|
Loading: "#FF9500", |
|
39
|
Ready: "#34C759", |
|
40
|
Generating: "#007AFF", |
|
41
|
Error: "#FF3B30", |
|
42
|
}; |
|
43
|
|
|
44
|
const App: React.FC = () => { |
|
45
|
const [messages, setMessages] = useState<DisplayMessage[]>([]); |
|
46
|
const [inputText, setInputText] = useState(""); |
|
47
|
const [engineStatus, setEngineStatus] = useState<EngineStatus>("Unloaded"); |
|
48
|
const [modelName, setModelName] = useState<string | null>(null); |
|
49
|
const [approxMemory, setApproxMemory] = useState<string | null>(null); |
|
50
|
const [isLoading, setIsLoading] = useState(false); |
|
51
|
const [isGenerating, setIsGenerating] = useState(false); |
|
52
|
|
|
53
|
const normalizeEngineStatus = useCallback((status: string): EngineStatus => { |
|
54
|
switch (status.toLowerCase()) { |
|
55
|
case "loading": |
|
56
|
return "Loading"; |
|
57
|
case "ready": |
|
58
|
return "Ready"; |
|
59
|
case "generating": |
|
60
|
return "Generating"; |
|
61
|
case "error": |
|
62
|
return "Error"; |
|
63
|
case "unloaded": |
|
64
|
default: |
|
65
|
return "Unloaded"; |
|
66
|
} |
|
67
|
}, []); |
|
68
|
|
|
69
|
const refreshStatus = useCallback(async () => { |
|
70
|
try { |
|
71
|
const info = await OndeChatEngine.info(); |
|
72
|
setEngineStatus(normalizeEngineStatus(info.status)); |
|
73
|
setModelName(info.modelName ?? null); |
|
74
|
setApproxMemory(info.approxMemory ?? null); |
|
75
|
} catch { |
|
76
|
/* status refresh is best-effort */ |
|
77
|
} |
|
78
|
}, [normalizeEngineStatus]); |
|
79
|
|
|
80
|
const handleLoadModel = useCallback(async () => { |
|
81
|
setIsLoading(true); |
|
82
|
setEngineStatus("Loading"); |
|
83
|
try { |
|
84
|
let seconds: number; |
|
85
|
if (ONDE_APP_ID && ONDE_APP_SECRET) { |
|
86
|
seconds = await OndeChatEngine.loadAssignedModel( |
|
87
|
ONDE_APP_ID, |
|
88
|
ONDE_APP_SECRET, |
|
89
|
"You are a helpful, concise assistant.", |
|
90
|
); |
|
91
|
} else { |
|
92
|
seconds = await OndeChatEngine.loadDefaultModel( |
|
93
|
"You are a helpful, concise assistant.", |
|
94
|
); |
|
95
|
} |
|
96
|
await refreshStatus(); |
|
97
|
Alert.alert("Model Loaded", `Ready in ${seconds.toFixed(1)}s`); |
|
98
|
} catch (error: unknown) { |
|
99
|
const message = error instanceof Error ? error.message : "Unknown error"; |
|
100
|
Alert.alert("Load Failed", message); |
|
101
|
setEngineStatus("Unloaded"); |
|
102
|
} finally { |
|
103
|
setIsLoading(false); |
|
104
|
} |
|
105
|
}, [refreshStatus]); |
|
106
|
|
|
107
|
const handleUnloadModel = useCallback(async () => { |
|
108
|
try { |
|
109
|
const name = await OndeChatEngine.unloadModel(); |
|
110
|
setMessages([]); |
|
111
|
setEngineStatus("Unloaded"); |
|
112
|
setModelName(null); |
|
113
|
setApproxMemory(null); |
|
114
|
if (name) { |
|
115
|
Alert.alert("Unloaded", `${name} has been unloaded.`); |
|
116
|
} |
|
117
|
} catch (error: unknown) { |
|
118
|
const message = error instanceof Error ? error.message : "Unknown error"; |
|
119
|
Alert.alert("Unload Failed", message); |
|
120
|
} |
|
121
|
}, []); |
|
122
|
|
|
123
|
const handleSendMessage = useCallback(async () => { |
|
124
|
const text = inputText.trim(); |
|
125
|
if (!text || !OndeChatEngine.isLoaded()) return; |
|
126
|
|
|
127
|
const userMsg: DisplayMessage = { |
|
128
|
id: Date.now().toString(), |
|
129
|
role: "user", |
|
130
|
content: text, |
|
131
|
}; |
|
132
|
setMessages((previous) => [...previous, userMsg]); |
|
133
|
setInputText(""); |
|
134
|
setIsGenerating(true); |
|
135
|
setEngineStatus("Generating"); |
|
136
|
|
|
137
|
try { |
|
138
|
const result = await OndeChatEngine.sendMessage(text); |
|
139
|
const assistantMsg: DisplayMessage = { |
|
140
|
id: (Date.now() + 1).toString(), |
|
141
|
role: "assistant", |
|
142
|
content: result.text, |
|
143
|
duration: result.durationDisplay, |
|
144
|
}; |
|
145
|
setMessages((previous) => [...previous, assistantMsg]); |
|
146
|
} catch (error: unknown) { |
|
147
|
const message = error instanceof Error ? error.message : "Unknown error"; |
|
148
|
Alert.alert("Inference Error", message); |
|
149
|
} finally { |
|
150
|
setIsGenerating(false); |
|
151
|
await refreshStatus(); |
|
152
|
} |
|
153
|
}, [inputText, refreshStatus]); |
|
154
|
|
|
155
|
const handleClearHistory = useCallback(() => { |
|
156
|
const removed = OndeChatEngine.clearHistory(); |
|
157
|
setMessages([]); |
|
158
|
Alert.alert("History Cleared", `Removed ${removed} message(s).`); |
|
159
|
}, []); |
|
160
|
|
|
161
|
const renderMessage = useCallback(({ item }: { item: DisplayMessage }) => { |
|
162
|
const isUser = item.role === "user"; |
|
163
|
return ( |
|
164
|
<View |
|
165
|
style={[ |
|
166
|
styles.messageBubble, |
|
167
|
isUser ? styles.userBubble : styles.assistantBubble, |
|
168
|
]} |
|
169
|
> |
|
170
|
<Text style={isUser ? styles.userText : styles.assistantText}> |
|
171
|
{item.content} |
|
172
|
</Text> |
|
173
|
{item.duration && ( |
|
174
|
<Text style={styles.durationText}>{item.duration}</Text> |
|
175
|
)} |
|
176
|
</View> |
|
177
|
); |
|
178
|
}, []); |
|
179
|
|
|
180
|
const isLoaded = engineStatus === "Ready" || engineStatus === "Generating"; |
|
181
|
const canSend = isLoaded && inputText.trim().length > 0 && !isGenerating; |
|
182
|
|
|
183
|
return ( |
|
184
|
<SafeAreaView style={styles.container}> |
|
185
|
<KeyboardAvoidingView |
|
186
|
style={styles.flex} |
|
187
|
behavior={Platform.OS === "ios" ? "padding" : undefined} |
|
188
|
> |
|
189
|
{/* Header */} |
|
190
|
<View style={styles.header}> |
|
191
|
<View style={styles.headerRow}> |
|
192
|
<Text style={styles.title}>Onde</Text> |
|
193
|
<View style={styles.statusBadge}> |
|
194
|
<View |
|
195
|
style={[ |
|
196
|
styles.statusDot, |
|
197
|
{ backgroundColor: STATUS_COLORS[engineStatus] }, |
|
198
|
]} |
|
199
|
/> |
|
200
|
<Text style={styles.statusText}>{engineStatus}</Text> |
|
201
|
</View> |
|
202
|
</View> |
|
203
|
{modelName && ( |
|
204
|
<Text style={styles.modelInfo}> |
|
205
|
{modelName} |
|
206
|
{approxMemory ? ` · ${approxMemory}` : ""} |
|
207
|
</Text> |
|
208
|
)} |
|
209
|
</View> |
|
210
|
|
|
211
|
{/* Message List */} |
|
212
|
<FlatList |
|
213
|
data={messages} |
|
214
|
renderItem={renderMessage} |
|
215
|
keyExtractor={(item) => item.id} |
|
216
|
style={styles.messageList} |
|
217
|
contentContainerStyle={styles.messageListContent} |
|
218
|
keyboardShouldPersistTaps="handled" |
|
219
|
/> |
|
220
|
|
|
221
|
{/* Generating Indicator */} |
|
222
|
{isGenerating && ( |
|
223
|
<View style={styles.generatingRow}> |
|
224
|
<ActivityIndicator size="small" color="#007AFF" /> |
|
225
|
<Text style={styles.generatingText}>Generating…</Text> |
|
226
|
</View> |
|
227
|
)} |
|
228
|
|
|
229
|
{/* Bottom Controls */} |
|
230
|
<View style={styles.bottomArea}> |
|
231
|
<TouchableOpacity |
|
232
|
style={[styles.loadButton, isLoaded && styles.unloadButton]} |
|
233
|
onPress={isLoaded ? handleUnloadModel : handleLoadModel} |
|
234
|
disabled={isLoading} |
|
235
|
> |
|
236
|
{isLoading ? ( |
|
237
|
<ActivityIndicator size="small" color="#fff" /> |
|
238
|
) : ( |
|
239
|
<Text style={styles.loadButtonText}> |
|
240
|
{isLoaded ? "Unload" : "Load Model"} |
|
241
|
</Text> |
|
242
|
)} |
|
243
|
</TouchableOpacity> |
|
244
|
|
|
245
|
<View style={styles.inputRow}> |
|
246
|
<TextInput |
|
247
|
style={styles.textInput} |
|
248
|
value={inputText} |
|
249
|
onChangeText={setInputText} |
|
250
|
placeholder={isLoaded ? "Type a message…" : "Load a model first"} |
|
251
|
placeholderTextColor="#999" |
|
252
|
editable={isLoaded} |
|
253
|
returnKeyType="send" |
|
254
|
onSubmitEditing={canSend ? handleSendMessage : undefined} |
|
255
|
/> |
|
256
|
<TouchableOpacity |
|
257
|
style={[styles.sendButton, !canSend && styles.sendButtonDisabled]} |
|
258
|
onPress={handleSendMessage} |
|
259
|
disabled={!canSend} |
|
260
|
> |
|
261
|
<Text style={styles.sendButtonText}>Send</Text> |
|
262
|
</TouchableOpacity> |
|
263
|
</View> |
|
264
|
|
|
265
|
{messages.length > 0 && ( |
|
266
|
<TouchableOpacity |
|
267
|
style={styles.clearButton} |
|
268
|
onPress={handleClearHistory} |
|
269
|
> |
|
270
|
<Text style={styles.clearButtonText}>Clear History</Text> |
|
271
|
</TouchableOpacity> |
|
272
|
)} |
|
273
|
</View> |
|
274
|
</KeyboardAvoidingView> |
|
275
|
</SafeAreaView> |
|
276
|
); |
|
277
|
}; |
|
278
|
|
|
279
|
const styles = StyleSheet.create({ |
|
280
|
container: { flex: 1, backgroundColor: "#fff" }, |
|
281
|
flex: { flex: 1 }, |
|
282
|
|
|
283
|
header: { |
|
284
|
paddingHorizontal: 16, |
|
285
|
paddingVertical: 12, |
|
286
|
borderBottomWidth: StyleSheet.hairlineWidth, |
|
287
|
borderBottomColor: "#ddd", |
|
288
|
}, |
|
289
|
headerRow: { |
|
290
|
flexDirection: "row", |
|
291
|
justifyContent: "space-between", |
|
292
|
alignItems: "center", |
|
293
|
}, |
|
294
|
title: { fontSize: 20, fontWeight: "700", color: "#000" }, |
|
295
|
statusBadge: { flexDirection: "row", alignItems: "center", gap: 6 }, |
|
296
|
statusDot: { width: 8, height: 8, borderRadius: 4 }, |
|
297
|
statusText: { fontSize: 13, color: "#666" }, |
|
298
|
modelInfo: { fontSize: 12, color: "#999", marginTop: 4 }, |
|
299
|
|
|
300
|
messageList: { flex: 1 }, |
|
301
|
messageListContent: { padding: 16, gap: 10 }, |
|
302
|
messageBubble: { |
|
303
|
maxWidth: "80%", |
|
304
|
paddingHorizontal: 14, |
|
305
|
paddingVertical: 10, |
|
306
|
borderRadius: 16, |
|
307
|
}, |
|
308
|
userBubble: { |
|
309
|
alignSelf: "flex-end", |
|
310
|
backgroundColor: "#007AFF", |
|
311
|
borderBottomRightRadius: 4, |
|
312
|
}, |
|
313
|
assistantBubble: { |
|
314
|
alignSelf: "flex-start", |
|
315
|
backgroundColor: "#F0F0F0", |
|
316
|
borderBottomLeftRadius: 4, |
|
317
|
}, |
|
318
|
userText: { color: "#fff", fontSize: 15 }, |
|
319
|
assistantText: { color: "#000", fontSize: 15 }, |
|
320
|
durationText: { fontSize: 11, color: "#888", marginTop: 4 }, |
|
321
|
|
|
322
|
generatingRow: { |
|
323
|
flexDirection: "row", |
|
324
|
alignItems: "center", |
|
325
|
gap: 8, |
|
326
|
paddingHorizontal: 16, |
|
327
|
paddingVertical: 6, |
|
328
|
}, |
|
329
|
generatingText: { fontSize: 13, color: "#007AFF" }, |
|
330
|
|
|
331
|
bottomArea: { |
|
332
|
padding: 12, |
|
333
|
borderTopWidth: StyleSheet.hairlineWidth, |
|
334
|
borderTopColor: "#ddd", |
|
335
|
gap: 10, |
|
336
|
}, |
|
337
|
loadButton: { |
|
338
|
backgroundColor: "#007AFF", |
|
339
|
paddingVertical: 12, |
|
340
|
borderRadius: 10, |
|
341
|
alignItems: "center", |
|
342
|
}, |
|
343
|
unloadButton: { backgroundColor: "#FF3B30" }, |
|
344
|
loadButtonText: { color: "#fff", fontSize: 16, fontWeight: "600" }, |
|
345
|
|
|
346
|
inputRow: { flexDirection: "row", gap: 8 }, |
|
347
|
textInput: { |
|
348
|
flex: 1, |
|
349
|
borderWidth: StyleSheet.hairlineWidth, |
|
350
|
borderColor: "#ccc", |
|
351
|
borderRadius: 10, |
|
352
|
paddingHorizontal: 14, |
|
353
|
paddingVertical: 10, |
|
354
|
fontSize: 15, |
|
355
|
color: "#000", |
|
356
|
backgroundColor: "#FAFAFA", |
|
357
|
}, |
|
358
|
sendButton: { |
|
359
|
backgroundColor: "#007AFF", |
|
360
|
paddingHorizontal: 18, |
|
361
|
borderRadius: 10, |
|
362
|
justifyContent: "center", |
|
363
|
}, |
|
364
|
sendButtonDisabled: { backgroundColor: "#B0D4FF" }, |
|
365
|
sendButtonText: { color: "#fff", fontSize: 15, fontWeight: "600" }, |
|
366
|
|
|
367
|
clearButton: { alignSelf: "center", paddingVertical: 4 }, |
|
368
|
clearButtonText: { fontSize: 13, color: "#999" }, |
|
369
|
}); |
|
370
|
|
|
371
|
export default App; |