Flutter + NCMB + OpenAIで画像生成アプリを作る（その3: チャット画面の作成）

NCMBでは公式SDKとしてSwift/Objective-C/Kotlin/Java/Unity/JavaScript SDKを用意しています。また、それ以外にもコミュニティSDKとして、非公式ながらFlutter/React Native/Google Apps Script/C#/Ruby/Python/PHPなど幅広い言語向けにSDKが開発されています。

今回はコミュニティSDKの一つ、Flutter/Dart SDKを使ってOpenAIの画像生成APIを用いたAI画像生成アプリを作ってみます。[前回は画像生成に関わるスクリプトの処理について解説しました](https://qiita.com/goofmint/items/b76f08d149f6ed1e7aec)ので、今回はチャット画面を作成します。

## コード

今回のコードは [NCMBMania/flutter-image-gen](https://github.com/NCMBMania/flutter-image-gen) にアップロードしてあります。実装時の参考にしてください。

## チャット画面について

このファイルは `lib/chat.dart` として作成しています。チャット画面は[flutter\_chat\_ui | Flutter Package](https://pub.dev/packages/flutter_chat_ui)を用いるので、とても簡単です。

```dart
import 'dart:core';

import 'package:flutter/material.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'package:ncmb/ncmb.dart';
import 'dart:typed_data';
import 'dart:html' as webFile;

class ChatPage extends StatefulWidget {
  const ChatPage({super.key});

  @override
  State<ChatPage> createState() => _ChatPageState();
}

class _ChatPageState extends State<ChatPage> {
  @override
  Widget build(BuildContext context) => Scaffold(
        body: Chat(
          messages: _messages,
          onSendPressed: _handleSendPressed,
          showUserAvatars: false,
          showUserNames: false,
          user: const types.User(
            id: 'user',
          ),
        ),
      );
  // 省略
}
```

![localhost_55761_.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/f342b48d-669d-5a46-fc03-c30aa63e5dd2.png)


## Dartの実装

以下のコードはすべて `lib/chat.dart` の　`_ChatPageState` 中に記述します。

### 必要な変数の定義

まずチャットメッセージ用の変数を定義します。

```dart
List<types.Message> _messages = [];
```

### 初期化された際の処理

初期化時の処理で、過去のメッセージを取得します。

```dart
  @override
  void initState() {
    super.initState();
    _loadMessages();
  }

  void _loadMessages() async {
    // メッセージを取得するクエリを作成
    var query = NCMBQuery('ImageGen');
    // 1日前以降のメッセージを取得
    var date = DateTime.now().subtract(const Duration(days: 1));
    query.greaterThanOrEqualTo('createDate', date);
    query.order('createDate', descending: true); // 古いデータが上に来るようにする
    var ary = (await query.fetchAll()).map((o) => o as NCMBObject).toList();
    // 取得したNCMBObjectのリストをチャットメッセージに変換
    final messages = await Future.wait(ary.map((e) => toMessage(e)));
    // メッセージを更新
    setState(() {
      _messages = messages.expand((l) => l).toList();
    });
  }
```

`toMessage` 関数はNCMBObjectをチャットメッセージに変換します。チャットメッセージはプロンプトとしてのテキストメッセージと、レスポンスとしての画像メッセージの2つに分かれます。

```dart
  // NCMBObjectをチャットメッセージに変換する関数
  Future<List<types.Message>> toMessage(NCMBObject obj) async {
    var messages = <types.Message>[];
    var message = toTextMessage(obj);
    messages.add(message);
    var imageMessage = await toImageMessage(obj);
    messages.add(imageMessage);
    return messages.reversed.toList();
  }

  // NCMBObjectをTextMessageに変換する関数
  types.TextMessage toTextMessage(NCMBObject obj) {
    return types.TextMessage(
      author: types.User(id: obj.getString('role', defaultValue: 'user')),
      createdAt: obj.getDateTime('createDate').microsecondsSinceEpoch,
      id: "${obj.objectId}-text",
      text: obj.getString('content'),
    );
  }

  // NCMBObjectをImageMessageに変換する関数
  Future<types.ImageMessage> toImageMessage(NCMBObject obj) async {
    // 画像ファイルをダウンロード
    final imageName = obj.getString('image', defaultValue: '');
    final file = await NCMBFile.download(imageName);
    // 画像ファイルを表示するためのURIを作成
    final blob = ByteData.sublistView(file.data);
    final path = webFile.Url.createObjectUrlFromBlob(webFile.Blob([blob]));
    return types.ImageMessage(
      author: types.User(id: obj.getString('role', defaultValue: 'assistant')),
      createdAt: obj.getDateTime('createDate').microsecondsSinceEpoch,
      id: "${obj.objectId}-image",
      name: imageName,
      height: 256,
      width: 256,
      size: file.data.length,
      uri: path,
    );
  }
```

## チャットを送信・保存する処理

チャットメッセージを書き込んで送信ボタンを押すと `_handleSendPressed` 関数が呼び出されます。

```dart
void _handleSendPressed(types.PartialText message) async {
  // 以下はこの中に記述
};
```

### 入力テキストを取得

まず入力されたテキストを取得し、そのデータをNCMBのデータストアインスタンスにします。そして、保存後にテキストメッセージを描画します。

```dart
// メッセージを保存
var obj = NCMBObject('ImageGen');
obj.set('content', message.text);
obj.set('role', 'user');
await obj.save();
final textMessage = toTextMessage(obj);
_addMessage(textMessage);
```

### OpenAI APIの呼び出し

NCMBのスクリプトを実行します。これでOpenAI APIを呼び出して、結果を受け取ります。

```dart
// OpenAIのAPIを呼び出す
var script = NCMBScript('image.js').body('prompt', message.text);
var res = (await script.post()) as Map<String, dynamic>;
```

### 結果をデータストアに保存

受け取った結果は `obj` の `image` に紐付けて保存します。

```dart
// OpenAIの返答を保存
obj.set('image', res['fileName'] as String);
await obj.save();
```

後は画像をチャット画面に表示して完了です。

```dart
final imageMessage = await toImageMessage(obj);
_addMessage(imageMessage);
```

これで完成です。

## 実行例

以下は実行例です。

![neko.mp4-min.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/2035c643-700c-d6bc-f0b0-ad0f3413b891.gif)


## 全体のコード

`lib/chat.dart` のコードは以下の通りです。

```dart
import 'dart:core';

import 'package:flutter/material.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'package:ncmb/ncmb.dart';
import 'dart:typed_data';
import 'dart:html' as webFile;

class ChatPage extends StatefulWidget {
  const ChatPage({super.key});

  @override
  State<ChatPage> createState() => _ChatPageState();
}

class _ChatPageState extends State<ChatPage> {
  List<types.Message> _messages = [];

  @override
  void initState() {
    super.initState();
    _loadMessages();
  }

  @override
  Widget build(BuildContext context) => Scaffold(
        body: Chat(
          messages: _messages,
          onSendPressed: _handleSendPressed,
          showUserAvatars: false,
          showUserNames: false,
          user: const types.User(
            id: 'user',
          ),
        ),
      );

  void _addMessage(types.Message message) {
    setState(() {
      _messages.insert(0, message);
    });
  }

  void _handleSendPressed(types.PartialText message) async {
    // メッセージを保存
    var obj = NCMBObject('ImageGen');
    obj.set('content', message.text);
    obj.set('role', 'user');
    await obj.save();
    final textMessage = toTextMessage(obj);
    _addMessage(textMessage);
    // OpenAIのAPIを呼び出す
    var script = NCMBScript('image.js').body('prompt', message.text);
    var res = (await script.post()) as Map<String, dynamic>;
    // OpenAIの返答を保存
    obj.set('image', res['fileName'] as String);
    await obj.save();
    final imageMessage = await toImageMessage(obj);
    _addMessage(imageMessage);
  }

  // NCMBObjectをチャットメッセージに変換する関数
  Future<List<types.Message>> toMessage(NCMBObject obj) async {
    var messages = <types.Message>[];
    var message = toTextMessage(obj);
    messages.add(message);
    var imageMessage = await toImageMessage(obj);
    messages.add(imageMessage);
    return messages.reversed.toList();
  }

  // NCMBObjectをTextMessageに変換する関数
  types.TextMessage toTextMessage(NCMBObject obj) {
    return types.TextMessage(
      author: types.User(id: obj.getString('role', defaultValue: 'user')),
      createdAt: obj.getDateTime('createDate').microsecondsSinceEpoch,
      id: "${obj.objectId}-text",
      text: obj.getString('content'),
    );
  }

  // NCMBObjectをImageMessageに変換する関数
  Future<types.ImageMessage> toImageMessage(NCMBObject obj) async {
    // 画像ファイルをダウンロード
    final imageName = obj.getString('image', defaultValue: '');
    final file = await NCMBFile.download(imageName);
    // 画像ファイルを表示するためのURIを作成
    final blob = ByteData.sublistView(file.data);
    final path = webFile.Url.createObjectUrlFromBlob(webFile.Blob([blob]));
    return types.ImageMessage(
      author: types.User(id: obj.getString('role', defaultValue: 'assistant')),
      createdAt: obj.getDateTime('createDate').microsecondsSinceEpoch,
      id: "${obj.objectId}-image",
      name: imageName,
      height: 256,
      width: 256,
      size: file.data.length,
      uri: path,
    );
  }

  void _loadMessages() async {
    // メッセージを取得するクエリを作成
    var query = NCMBQuery('ImageGen');
    // 1日前以降のメッセージを取得
    var date = DateTime.now().subtract(const Duration(days: 1));
    // query.greaterThanOrEqualTo('createDate', date);
    query.order('createDate', descending: true); // 古いデータが上に来るようにする
    var ary = (await query.fetchAll()).map((o) => o as NCMBObject).toList();
    // 取得したNCMBObjectのリストをTextMessageに変換
    final messages = await Future.wait(ary.map((e) => toMessage(e)));
    // メッセージを更新
    setState(() {
      _messages = messages.expand((l) => l).toList();
    });
  }
}
```

## まとめ

今回のAI画像生成アプリではNCMBの以下の機能を利用しました。

- データストア
    - データ保存
    - データ検索
- ファイルストア
    - ファイルアップロード
    - ファイルダウンロード
- スクリプト
    - OpenAI APIの実行

NCMBには他にも認証、プッシュ通知などの機能があります。ぜひそれらの機能も利用してアプリを開発してください。

[mBaaSでサーバー開発不要！ | ニフクラ mobile backend](https://mbaas.nifcloud.com/)