Table of Contents
はじめに
こんにちは、普段はWebフロントエンドやモバイルアプリ開発を担当している浦田です。
最近Expo SDKのバージョン52がリリースされました。
色々と新機能や変更があったんですが、自分が待望していたストリーミングに対応した expo/fetch
APIがExpo SDK 52から登場しましたので、実際にAIチャットアプリを実装して確かめてみます。
バックエンドをHonoでAIチャットアプリ実装
まずはバックエンドをHonoで、OpenAI APIを呼び出しAIチャットを実現しようと思います。
Expoプロジェクト作成
まずはExpoプロジェクトを作成します。
npm run reset-project
で不要なボイラープレートは削除します。
npx create-expo-app@latest expo-streming-with-hono
cd expo-streming-with-hono
npm run reset-project # 不要なボイラープレートを削除
Warning
ここで注意が必要なのは --template blank-typescript
などで作成してしまうとストリーミングが動きませんでした。
特にドキュメントに明記されていないようでしたが、おそらくExpo Router上ではないと動かないのではないかと推測します。
Honoプロジェクト作成
次にバックエンドとしてHonoを使用します。
特にモノレポ構成とかにする必要はないので、Expoと別プロジェクトでもそのままExpoのプロジェクト内に作るでもどちらでもOKです。
今回はExpoプロジェクトにbackendというディレクトリでHonoプロジェクトを作ります。
npm create hono@latest backend
cd backend
今回はCloudflare Workers想定でプロジェクトを作成しました。
Hono側に必要なパッケージをインストール
OpenAIのAPIを使用したいので、openai
をインストールします。
npm install openai
Honoでバックエンド処理実装
Honoではストリーミングがサポートされているので、簡単に実装できました。
import { Hono } from "hono";
import OpenAI from "openai";
import { streamSSE } from "hono/streaming";
type Bindings = {
OPENAI_API_KEY: string;
};
const app = new Hono<{ Bindings: Bindings }>();
app.post("/", async (c) => {
const openai = new OpenAI({
apiKey: c.env.OPENAI_API_KEY,
});
const json = await c.req.json<OpenAI.ChatCompletionMessageParam[]>();
const chatStream = await openai.chat.completions.create({
model: "gpt-4o",
messages: json,
stream: true,
});
return streamSSE(c, async (stream) => {
for await (const chunk of chatStream) {
const message = chunk.choices[0]?.delta?.content || "";
await stream.write(message);
}
});
});
export default app;
今回はCloudflare Workers想定でセットアップしたので、.dev.varsを作成してローカルで動かすための環境変数を設定します。
OPENAI_API_KEY=xxxxxxxxxx
npm run dev
でサーバーを起動したら、バックエンドの準備OKです。
Expoアプリ側で呼び出す処理の実装
いよいよ本題の expo/fetch
APIを使用してストリーミングを扱う処理を書いていきます。
app/index.tsx:
import { useState } from "react";
import { SafeAreaView, ScrollView, Text, TextInput, View } from "react-native";
import { fetch } from "expo/fetch";
const API_URL = "http://localhost:8787"; // Hono側のURL
export default function Index() {
const [input, setInput] = useState("");
const [messages, setMessages] = useState([
{
role: "system",
content: "Hello, how can I help you?",
},
]);
async function sendMessage(newMessages: { role: string; content: string }[]) {
try {
setMessages([
...newMessages,
{
role: "system",
content: "Typing...",
},
]);
const res = await fetch(API_URL, {
method: "POST",
headers: {
"Accept": "text/event-stream",
},
body: JSON.stringify(newMessages),
});
const reader = res.body?.getReader();
if (!reader) return;
let decoder = new TextDecoder();
let content = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
content += text;
setMessages((messages) => [
...messages.slice(0, -1),
{
role: "system",
content: content,
},
]);
}
} catch (error) {
console.error(error);
}
}
return <SafeAreaView style={{ height: "100%" }}>
<View
style={{
height: "95%",
display: "flex",
flexDirection: "column",
paddingHorizontal: 8,
}}
>
<ScrollView style={{ flex: 1 }}>
{messages.map((m, index) => (
<View key={index} style={{ marginVertical: 8 }}>
<View>
<Text style={{ fontWeight: 700 }}>{m.role}</Text>
<Text>{m.content}</Text>
</View>
</View>
))}
</ScrollView>
<View style={{ marginTop: 8 }}>
<TextInput
style={{ backgroundColor: "white", padding: 8 }}
placeholder="Say something..."
value={input}
onChange={(e) =>
setInput(e.nativeEvent.text)
}
onSubmitEditing={(e) => {
const newMessages = [...messages, { role: "user", content: input }];
sendMessage(newMessages);
setInput("");
}}
autoFocus={true}
/>
</View>
</View>
</SafeAreaView>
}
UI部分は特に解説しませんが、sendMessage
でHonoへのリクエスト処理、ストリーミングのハンドリング、メッセージの状態をセットする処理を書いています。
fetchは標準のものではなく、expo/fetch
から忘れずインポートして使いましょう。
API_URLは適宜自分の環境に合わせて変更してください。
結果
あとはHonoサーバーを起動した状態で、Expo側で npm run start
してシミュレータで動かしてみると以下のように動きました。
バックエンドをNext.jsでAIチャットアプリ実装
なんとVercelの出しているAI SDKでも expo/fetch
を使用した実装方法がドキュメントに載っていたので、そちらもやってみたいと思います。
Expoプロジェクト作成
先ほどのHonoと同じように新しくExpoプロジェクトを作成します。
npx create-expo-app@latest expo-streming-with-next
cd expo-streming-with-next
npm run reset-project # 不要なボイラープレートを削除
Next.jsプロジェクトを作成
今回もそのままExpoプロジェクト内にbackendディレクトリの名前で作成します。
npx create-next-app@latest backend
cd backend
Next.js側に必要なパッケージのインストール
ここでAI SDKをインストールします。
npm install @ai-sdk/openai ai
Next.jsでバックエンド処理実装
Route Handlerを用意して、バックエンド処理を実装します。
AI SDKのおかげで記述量が少なくなりました。
app/api/chat/route.ts:
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages,
});
return result.toDataStreamResponse();
}
環境変数の設定も忘れずに行います。
.env.local:
OPENAI_API_KEY=xxxxxxxxxx
これでバックエンドの準備ができました。npm run dev
でサーバーを起動しておきます。
Expoアプリ側に必要なパッケージをインストール
Expo側にもAI SDKをインストールして使います。
cd .. # プロジェクトルートへ移動
npm install @ai-sdk/react
Expoアプリ側から呼び出す処理を実装
UI側の処理はほぼ公式ドキュメントのままで動きました。URLを変えたぐらいです。
app/index.tsx:
import { useChat } from "@ai-sdk/react";
import { fetch as expoFetch } from "expo/fetch";
import { View, TextInput, ScrollView, Text, SafeAreaView } from "react-native";
const API_URL = "http://localhost:3000";
export default function App() {
const { messages, error, handleInputChange, input, handleSubmit } = useChat({
fetch: expoFetch as unknown as typeof globalThis.fetch,
api: API_URL + "/api/chat",
onError: (error) => console.error(error, "ERROR"),
});
if (error) return <Text>{error.message}</Text>;
return (
<SafeAreaView style={{ height: "100%" }}>
<View
style={{
height: "95%",
display: "flex",
flexDirection: "column",
paddingHorizontal: 8,
}}
>
<ScrollView style={{ flex: 1 }}>
{messages.map((m) => (
<View key={m.id} style={{ marginVertical: 8 }}>
<View>
<Text style={{ fontWeight: 700 }}>{m.role}</Text>
<Text>{m.content}</Text>
</View>
</View>
))}
</ScrollView>
<View style={{ marginTop: 8 }}>
<TextInput
style={{ backgroundColor: "white", padding: 8 }}
placeholder="Say something..."
value={input}
onChange={(e) =>
handleInputChange({
...e,
target: {
...e.target,
value: e.nativeEvent.text,
},
} as unknown as React.ChangeEvent<HTMLInputElement>)
}
onSubmitEditing={(e) => {
handleSubmit(e);
e.preventDefault();
}}
autoFocus={true}
/>
</View>
</View>
</SafeAreaView>
);
}
すごいですね。先ほどメッセージの状態管理やストリーミングのハンドリング処理などは全てAI SDKが提供する useChat
に集約されました。
結果
こちらもNext.jsサーバーを起動した状態で、Expo側で npm run start
してシミュレータで動かしてみると以下のように動きました。
さいごに
これまではReact Nativeでストリーミングを扱う際は標準のfetch APIで対応されていなかったので、実現したい場合はWebView上でストリーミングfetchを行い、postMessage経由などで結果を受け取る方法や、ネイティブ側(Swift/Kotlin)でfetch処理するなどまわりくどいことをする必要がありました。
expo/fetch
が使えるようになったことで、全てReact Nativeの世界で完結できるようになり、今回のようなAIチャットアプリなどが実装しやすくなったと思います。また、VercelのAI SDKも使うとさらに実装が楽になりました。
Expo Router上でしか動かないなど、制約はありそうですが、WinterCG仕様に対応しているとのこともあり、Expoプロジェクトならこちらを採用していくのが今後は良さそうです!