Flutter WebでSSEを扱う際の注意点

2024/04/12に公開

はじめに

これはFlutter Webの話です。

Webをターゲットにしない場合、こんなことを考えなくてもよいはずです。

結論

http.post(request)でストリームを取得し、読み取ろうとしても意図した動作になりません。

サーバーサイドでのデータ送信が全て完了した後、一括での読み取りとなってしまいます。(意味ない)

これはFlutter Webの仕様のようです。[1]

都度データを読み出すためには、fetch_clientを使ってあげる必要があります。

Flutterサンプルコード

Future<ByteStream>からストリームを読み取るサンプルになります。
このissueのポストがズバリ解決方法になっています。

リクエストからレスポンスストリームを取得

sse_stream_web.dart
import 'package:fetch_client/fetch_client.dart';
import 'package:http/http.dart';

Future<ByteStream> getStream(Request request) async {
  final FetchClient fetchClient = FetchClient(mode: RequestMode.cors);
  final FetchResponse response = await fetchClient.send(request);
  return response.stream;
}

リクエストを投げ、レスポンスストリームから読み取り

main.dart
import 'package:XXXXX/sse_stream_web.dart';

final endpointUri = Uri.http(apiServerUrl, "/getTopicStream/");
    final body = {
      "apikey": "",
      "prompt": prompt,
      "picture_base64": pictureBase64,
      "dry_run": isDryRun,
    };
    final header = {
      'accept': 'text/event-stream',
      "Content-Type": "application/json",
    };

    var request = http.Request(
      "POST",
      endpointUri,
    );
    header.forEach((key, value) {
      request.headers[key] = value;
    });
    request.body = jsonEncode(body);

    // [NOTE] 64KiB以上のデータを送信するためにfalseを設定する!
    // これがないとfetch errorで死ぬほど悩むことになります
    request.persistentConnection = false; 

    var stream = getStream(request);

    stream.asStream().listen((event) {
      event
          .transform(const Utf8Decoder())
          .transform(const LineSplitter())
          .listen(
        (dataLine) {
            // ストリーム中身
            print(dataLine); 
        })
    });

(参考)FastAPI サーバーサイド処理

OpenAIのAPIにstream=Trueを指定してリクエストを投げ、その中身を返しているだけです。

プロンプト・base64エンコードした画像の文字列を投げ、レスポンスストリームを返しています。


@app.post("/getTopicStream/")
async def get_topic_stream(item:GetTopicRequestItem) -> EventSourceResponse:
    # validate the input
    if item.prompt == "":
        raise HTTPException(status_code=400, detail="prompt is required")
    
    if item.dry_run:
        return EventSourceResponse(get_topic_stream_test())
    else:
        def get_stream():
            try:
                print("------------------")
                print("start")

                response = chatter.chat_stream(memory_id=item.apikey,message=item.prompt,base64_str=item.picture_base64)

                for chunk in response:
                    if chunk is not None:
                        content = chunk.choices[0].delta.content
                        data = {"content":f"{content}"}
                        if content is not None:
                            yield content
                yield f"\n\n\n(使用モデル : {chatter.model})"
                print("end")
                print("------------------")

            except Exception as e:
                raise HTTPException(status_code=500, detail=str(e))
        
        return EventSourceResponse(get_stream())



ついでの注意点

fetch_clientを使う場合、64KiB以上のデータを送信するためにはrequest.persistentConnection=falseとしなければなりません!

ドキュメントにも書いてありますが、見落としました。

デカい画像をアップロードしたときにエラーになってしまい、かなりハマってしまいました。

背景(読まなくてよい)

覚えたてのFlutterを使って、イケてるWEBサイト上でAIとおしゃべりしたいと思っていた時にハマりました。

ストリーミング応答するAPIを作るのも初めてだったため、Flutter・APIのどちらに問題があるかで切り分けに時間がかかったのも原因の1つです。

FlutterはWEBとWEB以外のプラットフォームでは動作が異なる可能性が多分にあるので気を付けましょう。

上記情報で皆さんの3日間が救われることを願います。

脚注
  1. https://pub.dev/documentation/http/latest/browser_client/BrowserClient-class.html ↩︎

Discussion