6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

flutter_ai_toolkitを使ってみる

Posted at

FlutterでもLLMを使った機能を提供するアプリを実装することができます。
Gemini、ChatGPT、ClaudeなどいろいろなLLMがありますが、それらを抽象化したインタフェイスを通して統一的に扱うためのライブラリがflutter_ai_toolkitです。

flutter_ai_toolkitを使う手順

flutter_ai_toolkitを使って実際にアプリを動かすためには、LlmProviderという抽象クラスを実装したクラスが必要になります。

とりあえずFirebase AI

Firebase AIがこのLlmProviderの具象クラスを提供しているので、とりあえずこれを使うのが一番簡単かと思います。
これに伴ってFirebase Consoleでアプリケーションのセットアップ、AI LogicまたはGenkitというサービスの設定も必要になります。

flutter pub add flutter_ai_toolkit firebase_core firebase_ai

最小のコードだと以下のような感じでAIチャットが実装できます。APIキーをアプリに書く必要がないのは良いですね。もっとちゃんとやるなら、LLMモデルも定数ではなく、外部から取得するほうが良さそうではあります。

main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(const MyApp());
}
MyApp
@override
Widget build(BuildContext context) {
  return LlmChatView(
    provider: FirebaseProvider(
      model: FirebaseAI.googleAI().generativeModel(
        model: 'gemini-2.5-flash',
      ),
    ),
  );
}

ローカルMCP的なものを実装する

FirebaseAIでは、MCPをDartのDSLとして書くような感じで定義し、実装できます。
たとえば以下のようにすると、天気を問い合わせたときにonFunctionCallが実行され、その結果をもとにLLMからの返信が表示されます。

  @override
  Widget build(BuildContext context) {
    return LlmChatView(
      provider: FirebaseProvider(
        model: FirebaseAI.googleAI().generativeModel(
          model: 'gemini-2.5-flash',
          tools: [
            Tool.functionDeclarations([
              FunctionDeclaration(
                'getWeather',
                '現在の天気を取得する',
                parameters: {'location': Schema.string(description: '場所')},
              ),
            ]),
          ],
        ),
        onFunctionCall: (functionCall) async {
          if (functionCall.name == 'getWeather') {
            final location = functionCall.args['location'] as String?;
            // ここで天気情報を取得する処理を実装する
            // サンプルでは固定の天気情報を返す
            return {
              'location': location ?? '不明な場所',
              'temperature': '25°C',
              'condition': '晴れ',
            };
          }
          return null;
        },
      ),
    );
  }

何か作ってみる

ここではチャットで商品検索できるような架空のECサイトアプリを作ってみようかと思います。

  • 検索ページとチャットページがある
  • チャットで商品について問い合わせると、検索結果を教えてくれる
  • 検索結果ページに、チャットでの検索結果を反映させる

具体的には以下のようなものになります。

screen-20251219-163257.gif

意図していない機能

関数を呼び出す以外に、FunctionDeclarationで提供した情報を使ってチャットに返信してくれることもあるようです。
gemini-2.5-flashでは、商品カテゴリを尋ねるとFunctionDeclarationのparametersで列挙してあるカテゴリ一覧が表示されます。たとえばgetSupportedCategoriesという関数を実装しなくても、どこかで情報を与えておくとそれを使ってくれるのは良いですね。

問題点(未検証)

MCPのような実装を入れたからなのかは不明ですが、すごい勢いで無料Quotaを消費していく気がします。
FirebaseのSparkプランでチャットを交わすと、数回で無料枠を使い切りエラーになりました。
ちょっと試しに何かやってみようと思える感じではなかったです。

ソースコード

実行可能なソースコードです。

  • Firebaseは設定済みという前提
  • サンプルなのでコードはファイル1つに収める
  • 使用するパッケージを極力減らす

追加パッケージ

以下のパッケージを追加します。

  • flutter_ai_toolkit
  • firebase_core
  • firebase_ai
  • collection
shell
flutter pub add flutter_ai_toolkit firebase_core firebase_ai collection
main.dart
import 'package:collection/collection.dart';
import 'package:firebase_ai/firebase_ai.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart';

/// カスタムURLスキームのスキーム名
const _customScheme = 'myapp';

/// カスタムURLスキームのホスト名
const _customHost = 'myhost';

/// 使用するLLMモデル名
const _llmModel = 'gemini-2.5-flash';

/// 商品カテゴリの列挙型
enum _Category {
  all('指定なし'),
  homeElectronics('家電'),
  books('書籍'),
  clothing('アパレル'),
  toys('おもちゃ'),
  sports('スポーツ'),
  beauty('ヘルス・ビューティ'),
  automotive('自動車'),
  grocery('日用品');

  const _Category(this.displayName);

  /// 表示用の名前
  final String displayName;
}

/// 検索パラメータクラス
class _SearchParameters {
  _SearchParameters({
    this.keyword,
    this.category = _Category.all,
    this.recommended = false,
  });

  /// キーワード(部分一致検索用)
  final String? keyword;

  /// 商品カテゴリ
  final _Category category;

  /// おすすめ商品のみ表示するかどうか
  final bool recommended;

  /// コピーメソッド
  _SearchParameters copyWith({
    String? keyword,
    _Category? category,
    bool? recommended,
  }) {
    return _SearchParameters(
      keyword: keyword ?? this.keyword,
      category: category ?? this.category,
      recommended: recommended ?? this.recommended,
    );
  }

  bool get isEmpty {
    return keyword == null &&
        category == _Category.all &&
        recommended == false;
  }

  /// 等価性のオーバーライド。値で比較する。
  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is _SearchParameters &&
          runtimeType == other.runtimeType &&
          keyword == other.keyword &&
          category == other.category &&
          recommended == other.recommended;

  @override
  int get hashCode =>
      keyword.hashCode ^ category.hashCode ^ recommended.hashCode;
}

///
/// サンプル用に簡単にするため、ファイルスコープのValueNotifierを使って状態管理を行う。
///
/// Bottom navigation barの選択状態を管理するValueNotifier
final _bottomNavigationIndex = ValueNotifier<int>(0);

/// 商品検索のパラメータを管理するValueNotifier
final _searchParameters = ValueNotifier<_SearchParameters>(_SearchParameters());

/// アプリのエントリーポイント
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(const MaterialApp(home: _Contents()));
}

/// アプリのメインコンテンツを表示するウィジェット
class _Contents extends StatelessWidget {
  const _Contents();

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: _bottomNavigationIndex,
      builder: (_, currentIndex, _) => Scaffold(
        body: IndexedStack(
          index: currentIndex,
          children: const [_HomeView(), _ChatView()],
        ),
        bottomNavigationBar: BottomNavigationBar(
          currentIndex: currentIndex,
          onTap: (index) {
            _bottomNavigationIndex.value = index;
          },
          items: [
            const BottomNavigationBarItem(icon: Icon(Icons.home), label: 'ホーム'),
            const BottomNavigationBarItem(
              icon: Icon(Icons.chat),
              label: 'チャット',
            ),
          ],
        ),
      ),
    );
  }
}

/// ホーム画面のウィジェット
class _HomeView extends StatelessWidget {
  const _HomeView();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: const Padding(
        padding: EdgeInsets.symmetric(horizontal: 16),
        child: Column(
          children: [
            SizedBox(
              height: 32,
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  _CategoryFilter(),
                  Expanded(child: _SearchForm()),
                ],
              ),
            ),
            Expanded(child: _SearchResultList()),
          ],
        ),
      ),
    );
  }
}

/// カテゴリ選択用のドロップダウンウィジェット
class _CategoryFilter extends StatelessWidget {
  const _CategoryFilter();

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: _searchParameters,
      builder: (_, value, _) {
        return PopupMenuButton(
          initialValue: value.category,
          onSelected: (newValue) {
            _searchParameters.value = value.copyWith(category: newValue);
          },
          itemBuilder: (context) => [
            for (final category in _Category.values)
              PopupMenuItem(value: category, child: Text(category.displayName)),
          ],
          icon: const Icon(Icons.filter_list),
        );
      },
    );
  }
}

/// 商品名検索用のテキストフォームウィジェット
class _SearchForm extends StatefulWidget {
  const _SearchForm();

  @override
  State<_SearchForm> createState() => _SearchFormState();
}

class _SearchFormState extends State<_SearchForm> {
  final _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: _searchParameters,
      builder: (_, value, _) {
        _controller.text = value.keyword ?? '';

        return TextFormField(
          controller: _controller,
          decoration: const InputDecoration(hintText: '商品名'),
          onFieldSubmitted: (text) {
            _searchParameters.value = value.copyWith(keyword: text);
          },
        );
      },
    );
  }
}

/// 検索結果リスト
class _SearchResultList extends StatelessWidget {
  const _SearchResultList();

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: _searchParameters,
      builder: (_, params, _) {
        if (params.isEmpty) {
          return const Center(child: Text('検索条件を指定してください'));
        }

        return ListView(
          children: [
            for (int i = 0; i < 20; i++)
              ListTile(
                leading: params.recommended ? const Icon(Icons.thumb_up) : null,
                title: Text('商品 ${(params.keyword ?? '商品')} #$i'),
                subtitle: Text('カテゴリ: ${params.category.displayName}'),
              ),
          ],
        );
      },
    );
  }
}

/// チャット画面のウィジェット
class _ChatView extends StatelessWidget {
  const _ChatView();

  static const _keyword = 'keyword';
  static const _category = 'category';
  static const _recommended = 'recommended';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Chat with AI')),
      body: LlmChatView(
        autofocus: false,
        enableAttachments: false,
        responseBuilder: (context, response) {
          return Padding(
            padding: const EdgeInsets.all(8.0),
            child: _LinkText(
              text: response,
              onClick: (text) {
                final uri = Uri.tryParse(text);
                if (uri != null) {
                  _handleCustomUrlScheme(uri);
                }
              },
            ),
          );
        },
        provider: FirebaseProvider(
          model: FirebaseAI.googleAI().generativeModel(
            model: _llmModel,
            tools: _toolDeclarations(),
          ),
          onFunctionCall: _onFunctionCall,
        ),
      ),
    );
  }

  List<Tool> _toolDeclarations() {
    return [
      Tool.functionDeclarations([
        FunctionDeclaration(
          'searchItems',
          // 取得したURLスキームをチャットに表示するかどうか不安定なので、ここで指示しておく。
          // 本来はプロンプト側を操作するほうがよいが、今回は簡単のためこちらで対応。
          '商品を検索し、ヒットした場合は件数とカスタムURLスキームでのURLを取得する。カスタムURLを取得できた場合は、チャットに表示する',
          parameters: {
            _keyword: Schema.string(
              title: '商品名またはキーワード',
              description: '商品名またはキーワード。省略可能。部分一致検索が可能',
              nullable: true,
            ),
            _category: Schema.enumString(
              title: '商品カテゴリ',
              description: '商品を分類するカテゴリ。省略可能',
              enumValues: _Category.values.map((e) => e.name).toList(),
              nullable: true,
            ),
            _recommended: Schema.boolean(
              title: 'おすすめ商品',
              description: 'おすすめ商品を表示するかどうか。省略可能',
              nullable: true,
            ),
          },
        ),
      ]),
    ];
  }

  /// Function Callが発生した際に呼び出されるハンドラ。
  /// ここでカスタムURLスキームを生成して返す。
  Future<Map<String, Object?>?> _onFunctionCall(
    FunctionCall functionCall,
  ) async {
    switch (functionCall.name) {
      case 'searchItems':
        final keyword = Uri.encodeComponent(
          functionCall.args[_keyword] as String? ?? '',
        );
        final category = Uri.encodeComponent(
          functionCall.args[_category] as String? ?? '',
        );
        final recommended = functionCall.args[_recommended] as bool? ?? false;

        final query = [
          if (keyword.isNotEmpty) '$_keyword=$keyword',
          if (category.isNotEmpty) '$_category=$category',
          if (recommended) '$_recommended=true',
        ].join('&');

        return {
          'number_of_items': 20,
          'link_url': '$_customScheme://$_customHost/search?$query',
        };
      default:
        return null;
    }
  }

  /// カスタムURLスキームを解析し、対応する画面に遷移する
  void _handleCustomUrlScheme(Uri uri) {
    if (uri.scheme != _customScheme || uri.host != _customHost) {
      return;
    }

    if (uri.path == '/search') {
      final name = uri.queryParameters[_keyword];
      final categoryString = uri.queryParameters[_category];
      final category = _Category.values.firstWhereOrNull(
        (e) => e.name == categoryString,
      );
      final recommended =
          uri.queryParameters[_recommended]?.toLowerCase() == 'true';

      // サンプルなのでとりあえずValueNotifierを直接更新して画面遷移する。
      // 実際はUniversal LinkやDeep Linkの仕組みを経由して画面遷移させるほうがよいかも。
      _searchParameters.value = _SearchParameters(
        keyword: name,
        category: category ?? _Category.all,
        recommended: recommended,
      );
      _bottomNavigationIndex.value = 0; // ホーム画面に切り替える
    }
  }
}

/// レスポンス用のウィジェット。カスタムスキームが含まれていたらクリック可能にする
class _LinkText extends StatelessWidget {
  const _LinkText({required this.text, required this.onClick});

  final String text;
  final ValueChanged<String> onClick;

  @override
  Widget build(BuildContext context) {
    // 文字列中の "myapp://myhost/search?$query_string" という部分をクリック可能なテキストにして表示する
    final regex = RegExp(
      '$_customScheme://$_customHost'
      r'/search\?[^\s]+',
    );

    final matches = regex.allMatches(text);
    if (matches.isEmpty) {
      return Text(text);
    }

    final spans = <TextSpan>[];
    var lastEnd = 0;
    for (final match in matches) {
      if (match.start > lastEnd) {
        spans.add(TextSpan(text: text.substring(lastEnd, match.start)));
      }
      final linkText = text.substring(match.start, match.end);
      spans.add(
        TextSpan(
          text: linkText,
          style: const TextStyle(
            color: Colors.blue,
            decoration: TextDecoration.underline,
          ),
          recognizer: TapGestureRecognizer()
            ..onTap = () => onClick.call(linkText),
        ),
      );
      lastEnd = match.end;
    }
    if (lastEnd < text.length) {
      spans.add(TextSpan(text: text.substring(lastEnd)));
    }
    return RichText(
      text: TextSpan(
        style: DefaultTextStyle.of(context).style,
        children: spans,
      ),
    );
  }
}

最後に

株式会社ボトルキューブではFlutterを使ったお仕事を募集中です。
お問い合わせは下記リンク先のフォームからご連絡ください。

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?