Overview
There're so many good Ai features and app ideas out there, but teams are limited by their precious time, and it's easy for other features with more understood requirements to get prioritized instead.
This is a tragedy, because it's actually really easy to build Ai apps when you've got the right tools.
Let's take a look at how we can build a Language Learning app with Easybeam and React Native in 7 minutes (no joke), and demystify the whole process of Ai apps a bit.
Background
If you're somewhat technical, it's relatively simple to make a quick API request to open AI,
curl "https://api.openai.com/v1/chat/completions"
\ -H "Content-Type: application/json"
\ -H "Authorization: Bearer $OPENAI_API_KEY"
\ -d '{ "model": "gpt-4o-mini", "messages": [ { "role": "system", "content": "You are a helpful assistant." }, { "role": "user", "content": "Write a haiku that explains the concept of recursion." } ] }'
Looks easy enough right?
Well, it is.
But you know what's not easy? Building an actual Ai feature in an application, if you want to,
Stream Ai results to a user as they're produced
Observe the Ai content your users are receiving
Manage and update your prompts without having to push code
Fortunately we can use Easybeam to do the heavy lifting here, and we can just focus on shipping our feature, alright we've wasted 1 of our 7 minutes so let's get to it!
App Design
Let's take a quick perspective of how our app will work.
As a user we'll have a list of lessons to select from, and then we select a lesson to chat with. After selecting a lesson, we'll use its data to enrich a prompt with our favorite Ai-provider. At the end of the lesson we'll leave a review of 1-5 stars, and go back to our lesson list.
A lesson will be a json object with the following,
{
"id": "fr-motion-1",
"language": "french",
"content": "Verbs of motion, present tense, past tense. When to use imperfect vs passé composé, in present, past and future. Conjugation of these forms, with focus on more complex situations."
}
And of course, we'll be using Easybeam to handle the Ai here.
Code
To get Easybeam installed for react native, it's as easy as:
npm install easybeam-react-native
(check out React Native repo for more info, or explore our other SDKs here.)
Now for setting up our UI, that'll take a few more lines of code.:
Lesson List
import React, { useEffect, useState } from "react";
import { View, Text, TouchableOpacity, ScrollView } from "react-native";
import { Lesson } from "../Lesson";
import { useNavigation } from "@react-navigation/native";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { RootStackParamList } from "../../App";
import lessonsData from "../assets/lessons.json";
type LessonListScreenNavigationProp = NativeStackNavigationProp<
RootStackParamList,
"LessonList"
>;
const LessonListScreen: React.FC = () => {
const [lessons, setLessons] = useState<Lesson[]>([]);
const navigation = useNavigation<LessonListScreenNavigationProp>();
useEffect(() => {
setLessons(lessonsData);
}, []);
return (
<View className="flex-1 bg-gray-100">
<ScrollView className="px-4 mt-4">
{lessons.length === 0 ? (
<Text className="text-xl text-gray-500 text-center mt-10">
Loading...
</Text>
) : (
lessons.map((lesson) => (
<TouchableOpacity
key={lesson.id}
onPress={() => navigation.navigate("LessonChat", { lesson })}
className="bg-white rounded-lg shadow-md p-4 mb-4"
>
<Text className="text-xl font-semibold text-gray-800">
{lesson.title}
</Text>
<Text
className="text-gray-600 mt-2"
numberOfLines={1}
ellipsizeMode="tail"
>
{lesson.description}
</Text>
</TouchableOpacity>
))
)}
</ScrollView>
</View>
);
};
export default LessonListScreen;
Here we're loading the lessons from a local JSON
file, but you could easily swap in your own server in the useEffect.
Lesson Chat
import React, { useState, useRef, useEffect, useLayoutEffect } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
ScrollView,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from "react-native";
import { useRoute, RouteProp, useNavigation } from "@react-navigation/native";
import { RootStackParamList } from "../../App";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Easybeam, ChatMessage, PortalResponse } from "easybeam-react-native";
import { EB_TOKEN } from "@env";
import { getAuth } from "firebase/auth";
type LessonChatScreenRouteProp = RouteProp<RootStackParamList, "LessonChat">;
type LessonChatScreenNavigationProp = NativeStackNavigationProp<
RootStackParamList,
"LessonChat"
>;
const Message: React.FC<{ message: ChatMessage }> = ({ message }) => (
<View
className={`mb-4 ${message.role === "USER" ? "items-end" : "items-start"}`}
>
<View
className={`max-w-[80%] p-4 rounded-lg ${
message.role === "USER" ? "bg-blue-500" : "bg-gray-300"
}`}
>
<Text
className={`text-base ${
message.role === "USER" ? "text-white" : "text-gray-800"
}`}
>
{message.content}
</Text>
</View>
</View>
);
const RatingComponent: React.FC<{
rating: number | null;
setRating: (rating: number) => void;
}> = ({ rating, setRating }) => (
<View className="mb-4">
<Text className="text-lg font-semibold mb-2 text-center">
{rating === null ? "Please rate this lesson:" : "Thank you for rating!"}
</Text>
<View className="flex-row justify-center">
{[1, 2, 3, 4, 5].map((star) => (
<TouchableOpacity
key={star}
onPress={() => setRating(star)}
className="mx-1"
>
<Text
className={`text-3xl ${
star <= (rating || 0) ? "text-yellow-500" : "text-gray-400"
}`}
>
★
</Text>
</TouchableOpacity>
))}
</View>
</View>
);
const LessonChatScreen: React.FC = () => {
const route = useRoute<LessonChatScreenRouteProp>();
const { lesson } = route.params;
const navigation = useNavigation<LessonChatScreenNavigationProp>();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const scrollViewRef = useRef<ScrollView>(null);
const easybeam = new Easybeam({ token: EB_TOKEN });
const [rating, setRating] = useState<number | null>(null);
const [chatId, setChatId] = useState<string>();
const startAIResponse = async (
currentMessages: ChatMessage[],
newMessage?: ChatMessage
) => {
setIsStreaming(true);
const filledVariables = {
lesson: lesson.content,
language: lesson.language,
};
try {
await easybeam.streamPortal(
"PORTALID",
getAuth().currentUser?.uid,
filledVariables,
newMessage ? [...currentMessages, newMessage] : [...currentMessages],
onNewMessage,
onClose,
onError
);
} catch (error) {
console.error("Error starting chat stream:", error);
setIsStreaming(false);
}
};
const handleSend = async () => {
if (inputText.trim() !== "") {
const newMessage: ChatMessage = {
id: Date.now().toString(),
content: inputText,
role: "USER",
createdAt: new Date().toISOString(),
};
setMessages((prevMessages) => [...prevMessages, newMessage]);
setInputText("");
scrollViewRef.current?.scrollToEnd({ animated: true });
await startAIResponse([...messages, newMessage], newMessage);
}
};
const onNewMessage = (newResponse: PortalResponse) => {
setChatId(newResponse.chatId);
setMessages((prevMessages) => {
const lastMessage = prevMessages[prevMessages.length - 1];
return lastMessage && lastMessage.role === "AI"
? [...prevMessages.slice(0, -1), newResponse.newMessage]
: [...prevMessages, newResponse.newMessage];
});
};
const handleReset = async () => {
await AsyncStorage.removeItem(`lesson_messages_${lesson.id}`);
setMessages([]);
startAIResponse([], undefined);
};
useLayoutEffect(() => {
navigation.setOptions({
headerTitle: "Lesson",
headerRight: () => (
<TouchableOpacity onPress={handleReset} style={{ marginRight: 15 }}>
<Text style={{ color: "red", fontWeight: "bold" }}>Restart</Text>
</TouchableOpacity>
),
});
}, [navigation]);
useEffect(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, [messages]);
const onClose = () => setIsStreaming(false);
const onError = (error: Error) => {
console.error("Chat stream error:", error.message);
setIsStreaming(false);
};
useEffect(() => {
const loadMessages = async () => {
try {
const storedMessages = await AsyncStorage.getItem(
`lesson_messages_${lesson.id}`
);
if (storedMessages && storedMessages.length < 0) {
setMessages(JSON.parse(storedMessages));
} else {
await startAIResponse([], undefined);
}
} catch (error) {
console.error("Failed to load messages:", error);
}
};
loadMessages();
}, [lesson.id]);
useEffect(() => {
const saveMessages = async () => {
try {
await AsyncStorage.setItem(
`lesson_messages_${lesson.id}`,
JSON.stringify(messages)
);
} catch (error) {
console.error("Failed to save messages:", error);
}
};
saveMessages();
}, [messages, lesson.id]);
const handleReviewNavigation = async () => {
await AsyncStorage.removeItem(`lesson_messages_${lesson.id}`);
await easybeam.review(
chatId!,
getAuth().currentUser?.uid,
rating ?? undefined,
undefined
);
navigation.pop(1);
};
const sendable = inputText.trim() !== "" && !isStreaming;
const finished = messages.find(
(m) =>
m.content.includes("Great, that's enough for today!") && m.role == "AI"
);
return (
<View className="flex-1 bg-gray-100">
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
keyboardVerticalOffset={Platform.OS === "ios" ? 100 : 0}
>
<View className="flex-1 pt-2">
<ScrollView
ref={scrollViewRef}
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 16 }}
keyboardShouldPersistTaps="handled"
>
{messages.map((message) => (
<Message key={message.id} message={message} />
))}
{isStreaming && (
<View className="mb-4 items-start">
<View className="max-w-[80%] p-4 rounded-lg bg-gray-300 flex-row items-center">
<ActivityIndicator size="small" color="#0000ff" />
<Text className="text-base text-gray-800 ml-2">
Thinking...
</Text>
</View>
</View>
)}
</ScrollView>
<View className="bg-white p-4 border-t border-gray-200 full">
{finished ? (
<View className="flex-col items-center w-full px-6">
<RatingComponent rating={rating} setRating={setRating} />
<TouchableOpacity
onPress={handleReviewNavigation}
className={`p-3 rounded-full w-full ${
rating === null ? "bg-gray-400" : "bg-green-600"
}`}
disabled={rating === null}
>
<Text className="text-white font-semibold text-center">
Go to review
</Text>
</TouchableOpacity>
</View>
) : (
<View className="flex-row items-center">
<TextInput
className="flex-1 border border-gray-300 rounded-full px-4 py-2 mr-2 bg-white"
placeholder="Type your message..."
value={inputText}
onChangeText={setInputText}
onSubmitEditing={handleSend}
returnKeyType="send"
/>
<TouchableOpacity
onPress={handleSend}
className={`p-3 rounded-full ${
!sendable ? "bg-gray-400" : "bg-blue-500"
}`}
disabled={!sendable}
>
<Text className="text-white font-semibold">Send</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
</KeyboardAvoidingView>
</View>
Okay that was a big blob of code, so let's call out some important parts,
After a user clicks send, we stream in the newest message by using Easybeam's stream portal
method,
await easybeam.streamPortal(
"PORTALID",
getAuth().currentUser?.uid,
filledVariables,
newMessage ? [...currentMessages, newMessage] : [...currentMessages],
onNewMessage,
onClose,
onError
);
We're checking if they're done with the lesson via a set phrase in the Ai response (we'll come back to this in the next section).
const finished = messages.find(
(m) =>
m.content.includes("Great, that's enough for today!") && m.role == "AI"
);
We're prompt the user for a 1-5 star review when the lesson's finished.
const handleReviewNavigation = async () => {
await AsyncStorage.removeItem(`lesson_messages_${lesson.id}`);
await easybeam.review(
chatId!,
getAuth().currentUser?.uid,
rating ?? undefined,
undefined
);
navigation.pop(1);
};
The Prompt & Portal
Now to make sure our Ai delivers high-quality outputs, I'll connect our app to Easybeam.
First, I'll navigate to the Portals section.
Then, I'll hop into the portal I created for the language app.
Good Stuff! The whole prompt is:
You're an expert language teacher. Your job is to create short exercises based on a lesson plan.
Create exercises that require the student to practice what's listed in the lesson. Correct them when they're wrong. Give them to the student one at a time.
Exercises can be,
- Short sentences English -> @language
-- Write a short sentence in english and ask them to translate it to the target language. Use vocab and concepts from the lesson as much as possible.
- Short sentences @language -> English
-- Write a short sentence in the target language and ask them to translate it to the english. Use vocab and concepts from the lesson as much as possible.
- Flash Card
-- Ask them the meaning of a word from the lesson.
Each message should only contain a single exercise of the above listed. Do not respond with anything other than the exercise. Don't give anything away about how to solve the exercise.
After 5-10 messages end the lesson with "Great, that's enough for today!".
Do not ask them if they're ready. Always respond with an exercise. If they ask for clarification help them, but return to the exercise.
TODAY'S LESSON: @lesson
It's really important to be specific when writing your prompts. The more guidance you can give the machines the better quality output you'll get!
Let's double check the output first, and play around with previewing
Pretty good! Anthropic's Claud 3.5 is their default model on Easybeam, and does a darn good job as a language teacher, and doesn't break the bank too.
Now let's publish this version!
Putting it all together
Now let's take our portal id and put it in our code where we're calling the portal:
await easybeam.streamPortal(
"B6wliK",
getAuth().currentUser?.uid,
filledVariables,
newMessage ? [...currentMessages, newMessage] : [...currentMessages],
onNewMessage,
onClose,
onError
);
Okie dokie, now let's build this sucker!
Okay, here's our lesson screen:
Now here's the lesson:
Which I'll leave a review for:
c'est incroyable !
We built our Ai feature in 7ish minutes, with all our ai infrastructure in two lines of code.
Observability
Now to keep things sharp, we can double check out output in the logs.
Great, everything looks in order there, now let's check out the analytics.
Everything looks pretty good, but I'm not sure what model will give the best quality output.
Fortunately, we have the…
Test Center
First, let's go back to the portal and duplicate this version, and select different Ai providers and models, so we've got some stuff to test.
Now I'll make a test for German and French:
Now I can run these 2 tests against 3 versions and compare the 6 results:
Hummm, Gemini's phrasing is a bit awkward here…
Looks like Anthropic is the best language teacher after all!
Now I can keep up to date with which model performs the best, and update the Ai powering my language app with the click of a button.
7 minute well spent?
Absolutely.
—
Have any more questions? Just shoot us a message on intercom (best), or reach out to hello@easybeam.ai