🐨

ReactNativeでGPTからのレスポンスをストリーミングで受け取る方法

2024/06/23に公開

ストリーミングを正式にサポートしているライブラリや方法がない...

メジャーな二つの方法はどちらも使えませんでした。

  • Axiosはクライアント側でのストリーミング形式をサポートしていない
  • fetchだと可能だが、出来るのはWebだけでReactNativeでは出来ない模様(普通のReact.jsと使われているJSのエンジンの違いによるものとのこと)

実際に試してみましたがエラーになりました。


他に方法はないのか探してみたところ一つだけありました。
react-native-communityから出しているreact-native-fetch-apiを使うことでストリーミングで取得ができるようです。
しかし3年以上更新されていない(今後もメンテはなさそう)ため極力使いたくないところですが、これしか方法がありません....

https://github.com/react-native-community/fetch

UI実装

1. 環境構築

プロジェクト作成

npx react-native init <your project name> --template react-native-template-typescript

プロジェクトフォルダに移動

cd <your project name>

アプリ起動

npx react-native run-ios


以下のようにアプリが起動されればOK


2. 見た目を作成

送るメッセージを入力するためのTextFormとチャット一覧を表示するUIを作ります。

その前に、メッセージの識別のために一意のidを作れるようにしたいのでuuidを導入します。

yarn add react-native-uuid


App.tsx
/**
 * Sample React Native App
 * https://github.com/facebook/react-native
 *
 * @format
 */

import React, {useState} from 'react';
import {
  KeyboardAvoidingView,
  SafeAreaView,
  ScrollView,
  StyleSheet,
  Text,
  TextInput,
  useColorScheme,
  View,
} from 'react-native';

import {Colors} from 'react-native/Libraries/NewAppScreen';
import uuid from 'react-native-uuid';

type ChatType = {
  id: string;
  role: 'ai' | 'user';
  message: string;
};

function ChatView({role, message}: Omit<ChatType, 'id'>): React.JSX.Element {
  const isDarkMode = useColorScheme() === 'dark';
  return (
    <View style={styles.sectionContainer}>
      <Text
        style={[
          {
            color: isDarkMode ? Colors.white : Colors.black,
          },
        ]}>
        {role}
      </Text>
      <Text
        style={[
          {
            color: isDarkMode ? Colors.light : Colors.dark,
          },
        ]}>
        {message}
      </Text>
    </View>
  );
}

function App(): React.JSX.Element {
  const [inputText, setInputText] = useState<string>('');
  const [chatList, setChatList] = useState<Array<ChatType>>([]);

  const handleChangeInputText = (text: string) => {
    setInputText(text);
  };

    const handleAddUserChat = () => {
    const newChatList: Array<ChatType> = [
      ...chatList,
      {id: uuid.v4().toString(), role: 'user', message: inputText},
    ];
    setChatList(newChatList);
    setInputText('');
  };

  return (
    <SafeAreaView style={{flex: 1}}>
      <ScrollView
        contentInsetAdjustmentBehavior="automatic"
        style={styles.sectionContainer}>
        <View>
          {chatList.map((e: ChatType) => (
            <ChatView key={e.id} role={e.role} message={e.message} />
          ))}
        </View>
      </ScrollView>
      <View style={styles.inputContainer}>
        <TextInput
          value={inputText}
          onChangeText={handleChangeInputText}
          onSubmitEditing={handleAddUserChat}
          style={styles.textInput}
          autoFocus={true}
          placeholder="メッセージ"
          returnKeyType="send"
        />
      </View>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  sectionContainer: {
    marginTop: 32,
    paddingHorizontal: 24,
    backgroundColor: '#E6ECF8',
  },
  inputContainer: {
    height: 40,
    margin: 20,
  },
  textInput: {
    height: '100%',
    padding: 10,
    borderWidth: 1,
    borderRadius: 20,
    borderColor: '#878787',
  },
});

export default App;


TextInputから文字を入力してEnter Keyを押した時にチャットに追加されたらOK


3. UIを一工夫

キーボードはアプリを立ち上げたら直ぐに入力できる状態にしたいです。
KeyboardAvoidngViewを使って、TextInputのコンポーネントを囲います。
・ TextInputコンポーネント内でautoFocusをtrueにします。

App.tsx
...
     <KeyboardAvoidingView behavior="padding">
        <View style={styles.inputContainer}>
          <TextInput
            value={inputText}
            onChangeText={handleChangeInputText}
            onSubmitEditing={handleAddUserChat}
            style={styles.textInput}
            autoFocus={true}
            placeholder="メッセージ"
            returnKeyType="send"
          />
        </View>
      </KeyboardAvoidingView>
...


以下のようにアプリを立ち上げたタイミングでキーボードが表示されたらOK


ストリーミング処理の実装

1. 必要なパッケージを導入

fetchを使ってストリーミングで受け取れるようにするために、コミュニティから提供されている以下のパッケージをインストール

yarn add react-native-fetch-api


次にもともとReact Nativeがサポートしていない機能を使えるようにするために以下のパッケージをインストール

yarn addreact-native-polyfill-globals

依存関係があるパッケージも追加

yarn add text-encoding@^0.7.0 url-parser@^0.0.1 web-streams-polyfill@^3.3.3


2. polyfillを追加

index.js内に追記します。

index.js

require('react-native-polyfill-globals/src/fetch').polyfill();
require('react-native-polyfill-globals/src/encoding').polyfill();
require('react-native-polyfill-globals/src/readable-stream').polyfill();

AppRegistry.registerComponent(appName, () => App);


3. 追加の型定義

導入したパッケージはTypeScriptに対応してないため、このまま使うとエラーになってしまいます。
自分たちで型定義する必要があります。

stream.d.ts
// Decoder
type BufferSource = ArrayBufferView | ArrayBuffer;
interface TextDecoderCommon {
  readonly encoding: string;
  readonly fatal: boolean;
  readonly ignoreBOM: boolean;
}
interface TextDecodeOptions {
  stream?: boolean;
}
interface TextDecoder extends TextDecoderCommon {
  decode(input?: BufferSource, options?: TextDecodeOptions): string;
}
declare const TextDecoder: {
  prototype: TextDecoder;
  new (label?: string, options?: TextDecoderOptions): TextDecoder;
};

// Fetch
interface RequestInit {
  reactNative?: Partial<{textStreaming: boolean}>;
}


4. APIリクエスト処理を実装

APIにリクエストを送る関数を実装します。
APIは以下の記事で作ったFastAPI製のものを使います。
こちらではマルチモーダルに対応するためにmultipart形式でのリクエスト方法にしていますので注意してください。

https://zenn.dev/headwaters/articles/bf9532e71fb844

App.tsx
import {TextDecoder} from 'text-encoding';
import {ReadableStream} from 'web-streams-polyfill';

...

 const handleSubmitMessageToApi = async () => {
    // ストリーミングで文字を受け取る際に検索で必要になるので定数で持っておく
    const newAIChatId = uuid.v4().toString();
    const newChatList: Array<ChatType> = [
      ...chatList,
      {id: uuid.v4().toString(), role: 'user', message: inputText},
      {id: newAIChatId, role: 'ai', message: ''},
    ];
    setChatList(newChatList);

    const endpoint = '<your endpoint>';
    const headers = {
      'Content-Type': 'multipart/form-data',
      accept: 'application/json',
    };
    // FormData形式でリクエストボディを作成
    const requestBody = new FormData();
    requestBody.append('message', JSON.stringify(inputText));

    const response = await fetch(endpoint, {
      method: 'POST',
      headers: headers,
      body: requestBody,
      // 本来は存在しないkeyだがパッケージを入れることで利用可能に
      reactNative: {textStreaming: true},
    });
    setInputText('');

    const stream = (await response.body) as ReadableStream<Uint8Array> | null;

    if (stream) {
      const reader = stream.getReader();
      // doneになるまでループ処理で応答テキストを更新する
      while (true) {
        const {done, value} = await reader.read();
        const decoder = new TextDecoder('utf-8');
        if (done) {
          break;
        }
        // 新しいテキストを受け取り次第、chatIdを検索して対象のmessageの値を更新する
        setChatList(prev =>
          prev.map(d => {
            if (d.id === newAIChatId) {
              const newMessage = d.message + decoder.decode(value);
              return {
                ...d,
                message: newMessage,
              };
            }
            return d;
          }),
        );
      }
    }
  };

動作確認

ストリーミングで返ってきました!

GPTを使ったアプリの開発をする際は注意

React Nativeで開発する場合はどうしたらいいんですかね...

正直今後のことを考えてもこのパッケージを使うのは良くないかなと思いますので、通常のfetchでサポートして欲しいのですが。

個人的には別の言語で作った方がいいかなと思いました。
Flutterだとストリーミングがサポートされているので、Flutterを使う。
もしくはSwiftとかKotlinといったネイティブ言語で開発をした方が安全かなと。

ヘッドウォータース

Discussion