Gemini を活用した Flutter アプリを作成する

1. Gemini を活用した Flutter アプリを構築する

作成するアプリの概要

この Codelab では、Gemini API の機能を Flutter アプリに直接組み込むインタラクティブな Flutter アプリケーション Colorist を作成します。自然言語でアプリを操作できるようにしたいけれど、どこから始めればよいかわからないという方は、ぜひお試しください。この Codelab では、その方法について説明します。

Colorist を使用すると、ユーザーは自然言語で色を説明できます(「夕焼けのオレンジ」や「深海の青」など)。アプリは次の処理を行います。

  • Google の Gemini API を使用してこれらの説明を処理します
  • 説明を正確な RGB カラー値に変換します
  • 画面の色をリアルタイムで表示する
  • 色の技術的な詳細と、色に関する興味深いコンテキストを提供します
  • 最近生成した色の履歴を保持します

色表示とチャット インターフェースを示す Colorist アプリのスクリーンショット

このアプリは、カラー表示領域とインタラクティブなチャット システムが片側に、LLM の未加工のインタラクションを示す詳細なログパネルがもう片側に表示される分割画面インターフェースを備えています。このログを使用すると、LLM 統合の実際の仕組みをより深く理解できます。

Flutter デベロッパーにとって重要な理由

LLM はユーザーがアプリケーションを操作する方法に革命をもたらしていますが、モバイルアプリやパソコンアプリに効果的に統合するには、独自の課題があります。この Codelab では、API 呼び出しだけでなく、実践的なパターンについても説明します。

パートナー様の学習プロセス

この Codelab では、Colorist を段階的に構築するプロセスについて説明します。

  1. プロジェクトのセットアップ - 基本的な Flutter アプリの構造と colorist_ui パッケージから始めます。
  2. Gemini の基本的な統合 - アプリを Firebase AI Logic に接続し、LLM 通信を実装します。
  3. 効果的なプロンプト - LLM が色の説明を理解するように導くシステム プロンプトを作成する
  4. 関数宣言 - LLM がアプリケーションで色を設定するために使用できるツールを定義します。
  5. ツール処理 - LLM からの関数呼び出しを処理し、アプリの状態に接続します。
  6. ストリーミング応答 - リアルタイムのストリーミング LLM 応答でユーザー エクスペリエンスを向上させる
  7. LLM コンテキストの同期 - ユーザーのアクションを LLM に通知して、一貫性のあるエクスペリエンスを実現します

学習内容

  • Flutter アプリケーション用に Firebase AI Logic を構成する
  • LLM の動作をガイドする効果的なシステム プロンプトを作成する
  • 自然言語とアプリの機能を結びつける関数宣言を実装する
  • 応答性の高いユーザー エクスペリエンスを実現するためにストリーミング レスポンスを処理する
  • UI イベントと LLM の間で状態を同期する
  • Riverpod を使用して LLM の会話状態を管理する
  • LLM を活用したアプリケーションでエラーを適切に処理する

コードのプレビュー: 実装する内容の概要

LLM がアプリで色を設定できるようにするために作成する関数宣言の例を次に示します。

FunctionDeclaration get setColorFuncDecl => FunctionDeclaration(
  'set_color',
  'Set the color of the display square based on red, green, and blue values.',
  parameters: {
    'red': Schema.number(description: 'Red component value (0.0 - 1.0)'),
    'green': Schema.number(description: 'Green component value (0.0 - 1.0)'),
    'blue': Schema.number(description: 'Blue component value (0.0 - 1.0)'),
  },
);

この Codelab の概要を紹介する動画

Observable Flutter エピソード #59 で、Craig Labenz と Andrew Brogdon がこの Codelab について解説しています。

前提条件

この Codelab を最大限に活用するには、次のものが必要です。

  • Flutter 開発の経験 - Flutter の基本と Dart の構文に精通している
  • 非同期プログラミングの知識 - Future、async/await、ストリームの理解
  • Firebase アカウント - Firebase を設定するには Google アカウントが必要です。

それでは、最初の LLM を活用した Flutter アプリの構築を始めましょう。

2. プロジェクトの設定とエコー サービス

最初の手順では、プロジェクト構造を設定し、後で Gemini API 統合に置き換えるエコー サービスを実装します。これにより、アプリケーション アーキテクチャが確立され、LLM 呼び出しの複雑さを追加する前に UI が正しく動作していることを確認できます。

このステップの学習内容

  • 必要な依存関係を含む Flutter プロジェクトをセットアップする
  • UI コンポーネント用の colorist_ui パッケージの操作
  • エコー メッセージ サービスを実装して UI に接続する

新しい Flutter プロジェクトを作成する

まず、次のコマンドを使用して新しい Flutter プロジェクトを作成します。

flutter create -e colorist --platforms=android,ios,macos,web,windows

-e フラグは、デフォルトの counter アプリのない空のプロジェクトを必要とすることを示します。このアプリは、デスクトップ、モバイル、ウェブで動作するように設計されています。ただし、現時点では flutterfire は Linux をサポートしていません。

依存関係を追加する

プロジェクト ディレクトリに移動し、必要な依存関係を追加します。

cd colorist
flutter pub add colorist_ui flutter_riverpod riverpod_annotation
flutter pub add --dev build_runner riverpod_generator riverpod_lint json_serializable

これにより、次のキー パッケージが追加されます。

  • colorist_ui: Colorist アプリの UI コンポーネントを提供するカスタム パッケージ
  • flutter_riverpodriverpod_annotation: 状態管理用
  • logging: 構造化ロギングの場合
  • コード生成と linting の開発依存関係

pubspec.yaml は次のようになります。

pubspec.yaml

name: colorist
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: ^3.9.2

dependencies:
  flutter:
    sdk: flutter
  colorist_ui: ^0.3.0
  flutter_riverpod: ^3.0.0
  riverpod_annotation: ^3.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^6.0.0
  build_runner: ^2.7.1
  riverpod_generator: ^3.0.0
  riverpod_lint: ^3.0.0
  json_serializable: ^6.11.1

flutter:
  uses-material-design: true

main.dart ファイルを実装する

lib/main.dart のコンテンツを次のように置き換えます。

lib/main.dart

import 'package:colorist_ui/colorist_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() async {
  runApp(ProviderScope(child: MainApp()));
}

class MainApp extends ConsumerWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: MainScreen(
        sendMessage: (message) {
          sendMessage(message, ref);
        },
      ),
    );
  }

  // A fake LLM that just echoes back what it receives.
  void sendMessage(String message, WidgetRef ref) {
    final chatStateNotifier = ref.read(chatStateProvider.notifier);
    final logStateNotifier = ref.read(logStateProvider.notifier);

    chatStateNotifier.addUserMessage(message);
    logStateNotifier.logUserText(message);
    chatStateNotifier.addLlmMessage(message, MessageState.complete);
    logStateNotifier.logLlmText(message);
  }
}

これにより、ユーザーのメッセージを返すことで LLM の動作を模倣するエコーサービスを実装する Flutter アプリが設定されます。

アーキテクチャについて

colorist アプリのアーキテクチャについて簡単に説明します。

colorist_ui パッケージ

colorist_ui パッケージは、事前構築済みの UI コンポーネントと状態管理ツールを提供します。

  1. MainScreen: 次のものを表示するメイン UI コンポーネントです。
    • パソコンの分割画面レイアウト(操作領域とログパネル)
    • モバイルのタブ付きインターフェース
    • カラー表示、チャット インターフェース、履歴のサムネイル
  2. 状態管理: アプリは複数の状態通知を使用します。
    • ChatStateNotifier: チャット メッセージを管理します
    • ColorStateNotifier: 現在の色と履歴を管理します
    • LogStateNotifier: デバッグ用のログエントリを管理します
  3. メッセージ処理: アプリは、さまざまな状態のメッセージ モデルを使用します。
    • ユーザー メッセージ: ユーザーが入力したメッセージ
    • LLM メッセージ: LLM(または現時点ではエコーサービス)によって生成されます。
    • MessageState: LLM メッセージが完了したか、まだストリーミング中かを追跡します。

アプリケーション アーキテクチャ

このアプリは次のアーキテクチャに沿ったものです。

  1. UI レイヤ: colorist_ui パッケージによって提供されます。
  2. 状態管理: リアクティブな状態管理に Riverpod を使用します。
  3. サービスレイヤ: 現在はシンプルなエコー サービスが含まれていますが、Gemini Chat サービスに置き換えられます。
  4. LLM 統合: 後の手順で追加します。

この分離により、UI コンポーネントの処理は完了しているため、LLM インテグレーションの実装に集中できます。

アプリを実行する

次のコマンドでアプリを実行します。

flutter run -d DEVICE

DEVICE は、macoswindowschrome などのターゲット デバイスまたはデバイス ID に置き換えます。

Colorist アプリのスクリーンショット。エコー サービスが Markdown をレンダリングしている様子を示す

Colorist アプリに次のものが表示されます。

  1. デフォルトの色が表示されるカラー表示領域
  2. メッセージを入力できるチャット インターフェース
  3. チャットでのやり取りを示すログパネル

「濃い青色がいい」などのメッセージを入力して、[送信] を押します。エコー サービスは、メッセージをそのまま繰り返します。後のステップでは、Firebase AI Logic を使用して、これを実際の色の解釈に置き換えます。

次のステップ

次のステップでは、Firebase を構成し、基本的な Gemini API インテグレーションを実装して、エコー サービスを Gemini チャット サービスに置き換えます。これにより、アプリは色の説明を解釈し、インテリジェントな回答を提供できるようになります。

トラブルシューティング

UI パッケージに関する問題

colorist_ui パッケージで問題が発生した場合は、次の操作を行います。

  • 最新バージョンを使用していることを確認する
  • 依存関係が正しく追加されていることを確認する
  • 競合するパッケージ バージョンがないか確認する

ビルドエラー

ビルドエラーが表示された場合:

  • 最新の安定版チャンネルの Flutter SDK がインストールされていることを確認する
  • flutter clean の後に flutter pub get を実行する
  • コンソール出力で特定のエラー メッセージを確認する

学んだ主なコンセプト

  • 必要な依存関係を含む Flutter プロジェクトをセットアップする
  • アプリケーションのアーキテクチャとコンポーネントの役割を理解する
  • LLM の動作を模倣するシンプルなサービスを実装する
  • サービスを UI コンポーネントに接続する
  • 状態管理に Riverpod を使用する

3. 基本的な Gemini Chat 統合

このステップでは、前のステップのエコー サービスを、Firebase AI Logic を使用した Gemini API 統合に置き換えます。Firebase を構成し、必要なプロバイダを設定して、Gemini API と通信する基本的なチャット サービスを実装します。

このステップの学習内容

  • Flutter アプリケーションで Firebase を設定する
  • Gemini にアクセスするように Firebase AI Logic を構成する
  • Firebase サービスと Gemini サービスの Riverpod プロバイダを作成する
  • Gemini API を使用して基本的なチャット サービスを実装する
  • 非同期 API レスポンスとエラー状態の処理

Firebase を設定する

まず、Flutter プロジェクト用に Firebase を設定する必要があります。これには、Firebase プロジェクトの作成、アプリの追加、必要な Firebase AI Logic 設定の構成が含まれます。

Firebase プロジェクトを作成する

  1. Firebase コンソールに移動し、Google アカウントでログインします。
  2. [Firebase プロジェクトを作成する] をクリックするか、既存のプロジェクトを選択します。
  3. セットアップ ウィザードに沿ってプロジェクトを作成します。

Firebase プロジェクトで Firebase AI Logic を設定する

  1. Firebase コンソールで、プロジェクトに移動します。
  2. 左側のサイドバーで [AI] を選択します。
  3. [AI] プルダウン メニューで [AI ロジック] を選択します。
  4. Firebase AI Logic カードで、[開始] を選択します。
  5. プロンプトに沿って、プロジェクトで Gemini Developer API を有効にします。

FlutterFire CLI をインストールする

FlutterFire CLI を使用すると、Flutter アプリでの Firebase の設定が簡単になります。

dart pub global activate flutterfire_cli

Flutter アプリに Firebase を追加する

  1. Firebase Core パッケージと Firebase AI Logic パッケージをプロジェクトに追加します。
flutter pub add firebase_core firebase_ai
  1. FlutterFire 構成コマンドを実行します。
flutterfire configure

このコマンドは次のことを行います。

  • 作成した Firebase プロジェクトを選択するよう求める
  • Flutter アプリを Firebase に登録する
  • プロジェクトの構成を含む firebase_options.dart ファイルを生成する

このコマンドは、選択したプラットフォーム(iOS、Android、macOS、Windows、ウェブ)を自動的に検出し、適切に構成します。

プラットフォーム固有の構成

Firebase では、Flutter のデフォルトよりも高い最小バージョンが必要です。また、Firebase AI Logic サーバーと通信するためにネットワーク アクセスも必要です。

macOS の権限を構成する

macOS の場合、アプリのエンタイトルメントでネットワーク アクセスを有効にする必要があります。

  1. macos/Runner/DebugProfile.entitlements を開き、次のコードを追加します。

macos/Runner/DebugProfile.entitlements

<key>com.apple.security.network.client</key>
<true/>
  1. また、macos/Runner/Release.entitlements を開いて同じエントリを追加します。

iOS の設定を構成する

iOS の場合は、ios/Podfile の上部にある最小バージョンを更新します。

ios/Podfile

# Firebase requires at least iOS 15.0
platform :ios, '15.0'

Gemini モデル プロバイダを作成する

次に、Firebase と Gemini の Riverpod プロバイダを作成します。新しいファイル lib/providers/gemini.dart を作成します。

lib/providers/gemini.dart

import 'dart:async';

import 'package:firebase_ai/firebase_ai.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../firebase_options.dart';

part 'gemini.g.dart';

@riverpod
Future<FirebaseApp> firebaseApp(Ref ref) =>
    Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

@riverpod
Future<GenerativeModel> geminiModel(Ref ref) async {
  await ref.watch(firebaseAppProvider.future);

  final model = FirebaseAI.googleAI().generativeModel(
    model: 'gemini-2.0-flash',
  );
  return model;
}

@Riverpod(keepAlive: true)
Future<ChatSession> chatSession(Ref ref) async {
  final model = await ref.watch(geminiModelProvider.future);
  return model.startChat();
}

このファイルは、3 つのキー プロバイダのベースを定義します。これらのプロバイダは、Riverpod コード ジェネレータによって dart run build_runner を実行すると生成されます。

  1. firebaseAppProvider: プロジェクト構成で Firebase を初期化します
  2. geminiModelProvider: Gemini 生成モデル インスタンスを作成します
  3. chatSessionProvider: Gemini モデルとのチャット セッションを作成して維持します。

チャット セッションの keepAlive: true アノテーションにより、アプリのライフサイクル全体でチャット セッションが維持され、会話のコンテキストが保持されます。

Gemini Chat サービスを実装する

新しいファイル lib/services/gemini_chat_service.dart を作成して、チャット サービスを実装します。

lib/services/gemini_chat_service.dart

import 'dart:async';

import 'package:colorist_ui/colorist_ui.dart';
import 'package:firebase_ai/firebase_ai.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../providers/gemini.dart';

part 'gemini_chat_service.g.dart';

class GeminiChatService {
  GeminiChatService(this.ref);
  final Ref ref;

  Future<void> sendMessage(String message) async {
    final chatSession = await ref.read(chatSessionProvider.future);
    final chatStateNotifier = ref.read(chatStateProvider.notifier);
    final logStateNotifier = ref.read(logStateProvider.notifier);

    chatStateNotifier.addUserMessage(message);
    logStateNotifier.logUserText(message);
    final llmMessage = chatStateNotifier.createLlmMessage();
    try {
      final response = await chatSession.sendMessage(Content.text(message));

      final responseText = response.text;
      if (responseText != null) {
        logStateNotifier.logLlmText(responseText);
        chatStateNotifier.appendToMessage(llmMessage.id, responseText);
      }
    } catch (e, st) {
      logStateNotifier.logError(e, st: st);
      chatStateNotifier.appendToMessage(
        llmMessage.id,
        "\nI'm sorry, I encountered an error processing your request. "
        "Please try again.",
      );
    } finally {
      chatStateNotifier.finalizeMessage(llmMessage.id);
    }
  }
}

@riverpod
GeminiChatService geminiChatService(Ref ref) => GeminiChatService(ref);

このサービス:

  1. ユーザー メッセージを受け取り、Gemini API に送信します。
  2. モデルからのレスポンスでチャット インターフェースを更新する
  3. すべての通信をログに記録して、実際の LLM フローを簡単に把握できるようにします。
  4. 適切なユーザー フィードバックでエラーを処理する

注: この時点では、ログ ウィンドウはチャット ウィンドウとほぼ同じように表示されます。関数呼び出しとストリーミング レスポンスを導入すると、ログはさらに興味深いものになります。

Riverpod コードを生成する

ビルドランナー コマンドを実行して、必要な Riverpod コードを生成します。

dart run build_runner build --delete-conflicting-outputs

これにより、Riverpod の機能に必要な .g.dart ファイルが作成されます。

main.dart ファイルを更新する

新しい Gemini Chat サービスを使用するように lib/main.dart ファイルを更新します。

lib/main.dart

import 'package:colorist_ui/colorist_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'providers/gemini.dart';
import 'services/gemini_chat_service.dart';

void main() async {
  runApp(ProviderScope(child: MainApp()));
}

class MainApp extends ConsumerWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final model = ref.watch(geminiModelProvider);

    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: model.when(
        data: (data) => MainScreen(
          sendMessage: (text) {
            ref.read(geminiChatServiceProvider).sendMessage(text);
          },
        ),
        loading: () => LoadingScreen(message: 'Initializing Gemini Model'),
        error: (err, st) => ErrorScreen(error: err),
      ),
    );
  }
}

この更新での主な変更点は次のとおりです。

  1. エコー サービスを Gemini API ベースのチャット サービスに置き換える
  2. Riverpod の AsyncValue パターンと when メソッドを使用して読み込み画面とエラー画面を追加する
  3. sendMessage コールバックを使用して UI を新しいチャット サービスに接続する

アプリを実行する

次のコマンドでアプリを実行します。

flutter run -d DEVICE

DEVICE は、macoswindowschrome などのターゲット デバイスまたはデバイス ID に置き換えます。

Gemini LLM が明るい黄色をリクエストするユーザーに応答している様子を示す Colorist アプリのスクリーンショット

メッセージを入力すると、Gemini API に送信され、エコーではなく LLM からレスポンスが返されます。ログパネルに API とのやり取りが表示されます。

LLM 通信について

Gemini API との通信時に何が起こるのかを理解しましょう。

通信フロー

  1. ユーザー入力: ユーザーがチャット インターフェースにテキストを入力します。
  2. リクエストの形式: アプリが Gemini API 用にテキストを Content オブジェクトとしてフォーマットします。
  3. API 通信: テキストは Firebase AI Logic を介して Gemini API に送信されます
  4. LLM 処理: Gemini モデルがテキストを処理し、レスポンスを生成します。
  5. レスポンスの処理: アプリがレスポンスを受け取り、UI を更新します。
  6. ロギング: 透明性を確保するため、すべての通信が記録されます

チャット セッションと会話のコンテキスト

Gemini とのチャット セッションでは、メッセージ間でコンテキストが維持されるため、会話形式のやり取りが可能です。つまり、LLM は現在のセッションでの過去のやり取りを「記憶」し、より一貫性のある会話を可能にします。

チャット セッション プロバイダの keepAlive: true アノテーションにより、このコンテキストはアプリのライフサイクル全体で保持されます。この永続的なコンテキストは、LLM との自然な会話の流れを維持するために不可欠です。

次のステップ

この時点では、Gemini API に回答の制限はないため、何でも質問できます。たとえば、色アプリの目的とは関係のないバラ戦争の概要を尋ねることができます。

次のステップでは、Gemini が色の説明をより効果的に解釈できるように、システム プロンプトを作成します。これにより、アプリケーション固有のニーズに合わせて LLM の動作をカスタマイズし、アプリのドメインに機能を集中させる方法を説明します。

トラブルシューティング

Firebase の構成に関する問題

Firebase の初期化でエラーが発生した場合は、次の操作を行います。

  • firebase_options.dart ファイルが正しく生成されたことを確認する
  • Firebase AI Logic にアクセスするために Blaze プランにアップグレードしたことを確認する

API アクセス エラー

Gemini API へのアクセスでエラーが発生した場合は、次の操作を行います。

  • Firebase プロジェクトで課金が正しく設定されていることを確認する
  • Firebase プロジェクトで Firebase AI Logic と Cloud AI API が有効になっていることを確認する
  • ネットワーク接続とファイアウォールの設定を確認する
  • モデル名(gemini-2.0-flash)が正しく、使用可能であることを確認する

会話のコンテキストに関する問題

Gemini がチャットの以前のコンテキストを記憶していないことに気づいた場合:

  • chatSession 関数に @Riverpod(keepAlive: true) アノテーションが付いていることを確認する
  • すべてのメッセージ交換で同じチャット セッションを再利用していることを確認する
  • メッセージを送信する前に、チャット セッションが適切に初期化されていることを確認する

プラットフォーム固有の問題

プラットフォーム固有の問題の場合:

  • iOS/macOS: 適切な利用資格が設定され、最小バージョンが構成されていることを確認する
  • Android: 最小 SDK バージョンが正しく設定されていることを確認する
  • コンソールでプラットフォーム固有のエラー メッセージを確認する

学んだ主なコンセプト

  • Flutter アプリケーションで Firebase を設定する
  • Gemini にアクセスできるように Firebase AI Logic を構成する
  • 非同期サービス用の Riverpod プロバイダを作成する
  • LLM と通信するチャット サービスの実装
  • 非同期 API の状態(読み込み中、エラー、データ)の処理
  • LLM の通信フローとチャット セッションについて

4. 色の説明に関する効果的なプロンプト

このステップでは、Gemini が色の説明を解釈する際に役立つシステム プロンプトを作成して実装します。システム プロンプトは、コードを変更せずに特定のタスクに合わせて LLM の動作をカスタマイズする強力な方法です。

このステップの学習内容

  • システム プロンプトとその LLM アプリケーションにおける重要性を理解する
  • ドメイン固有のタスクに効果的なプロンプトを作成する
  • Flutter アプリでシステム プロンプトを読み込んで使用する
  • 一貫した形式の回答を提供するように LLM を誘導する
  • システム プロンプトが LLM の動作に与える影響をテストする

システム プロンプトについて

実装に入る前に、システム プロンプトとは何か、なぜ重要なのかを理解しましょう。

システム プロンプトとは

システム プロンプトは、LLM に与えられる特別なタイプの指示で、回答のコンテキスト、動作ガイドライン、期待値を設定します。ユーザー メッセージとは異なり、システム プロンプトは次のようになります。

  • LLM の役割とペルソナを設定する
  • 専門知識や能力を定義する
  • フォーマットの指示を提供する
  • レスポンスに制約を設定する
  • さまざまなシナリオへの対処方法について説明する

システム プロンプトは、LLM に「職務記述書」を与えるようなものと考えてください。会話全体を通してモデルがどのように動作するかを指示します。

システム プロンプトが重要な理由

システム プロンプトは、一貫性のある有用な LLM インタラクションを作成するために不可欠です。

  1. 一貫性を確保する: 一貫した形式で回答するようにモデルをガイドします。
  2. 関連性を高める: モデルを特定のドメイン(この場合は色)に絞り込む
  3. 境界を確立する: モデルが行うべきことと行うべきでないことを定義する
  4. ユーザー エクスペリエンスの向上: より自然で役立つインタラクション パターンを作成する
  5. 後処理を減らす: 解析や表示が容易な形式でレスポンスを取得する

Colorist アプリでは、LLM が色の説明を一貫して解釈し、特定の形式で RGB 値を提供する必要があります。

システム プロンプト アセットを作成する

まず、実行時に読み込まれるシステム プロンプト ファイルを作成します。この方法では、アプリを再コンパイルすることなくプロンプトを変更できます。

次の内容の新しいファイル assets/system_prompt.md を作成します。

assets/system_prompt.md

# Colorist System Prompt

You are a color expert assistant integrated into a desktop app called Colorist. Your job is to interpret natural language color descriptions and provide the appropriate RGB values that best represent that description.

## Your Capabilities

You are knowledgeable about colors, color theory, and how to translate natural language descriptions into specific RGB values. When users describe a color, you should:

1. Analyze their description to understand the color they are trying to convey
2. Determine the appropriate RGB values (values should be between 0.0 and 1.0)
3. Respond with a conversational explanation and explicitly state the RGB values

## How to Respond to User Inputs

When users describe a color:

1. First, acknowledge their color description with a brief, friendly response
2. Interpret what RGB values would best represent that color description
3. Always include the RGB values clearly in your response, formatted as: `RGB: (red=X.X, green=X.X, blue=X.X)`
4. Provide a brief explanation of your interpretation

Example:
User: "I want a sunset orange"
You: "Sunset orange is a warm, vibrant color that captures the golden-red hues of the setting sun. It combines a strong red component with moderate orange tones.

RGB: (red=1.0, green=0.5, blue=0.25)

I've selected values with high red, moderate green, and low blue to capture that beautiful sunset glow. This creates a warm orange with a slightly reddish tint, reminiscent of the sun low on the horizon."

## When Descriptions are Unclear

If a color description is ambiguous or unclear, please ask the user clarifying questions, one at a time.

## Important Guidelines

- Always keep RGB values between 0.0 and 1.0
- Always format RGB values as: `RGB: (red=X.X, green=X.X, blue=X.X)` for easy parsing
- Provide thoughtful, knowledgeable responses about colors
- When possible, include color psychology, associations, or interesting facts about colors
- Be conversational and engaging in your responses
- Focus on being helpful and accurate with your color interpretations

システム プロンプトの構造を理解する

このプロンプトの動作を詳しく見てみましょう。

  1. 役割の定義: LLM を「色の専門家アシスタント」として確立します。
  2. タスクの説明: 色の説明を RGB 値に変換するタスクを定義します。
  3. レスポンス形式: 一貫性を保つために RGB 値の形式を正確に指定します
  4. 交換の例: 期待されるインタラクション パターンの具体的な例を示します。
  5. エッジケースの処理: 不明瞭な説明の処理方法を説明します
  6. 制約とガイドライン: RGB 値を 0.0 ~ 1.0 の範囲に保つなどの境界を設定します。

この構造化されたアプローチにより、LLM のレスポンスは一貫性があり、有益で、RGB 値をプログラムで抽出する場合に簡単に解析できる形式になります。

pubspec.yaml を更新する

次に、pubspec.yaml の末尾を更新してアセット ディレクトリを含めます。

pubspec.yaml

flutter:
  uses-material-design: true

  assets:
    - assets/

flutter pub get を実行してアセット バンドルを更新します。

システム プロンプト プロバイダを作成する

システム プロンプトを読み込むための新しいファイル lib/providers/system_prompt.dart を作成します。

lib/providers/system_prompt.dart

import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'system_prompt.g.dart';

@riverpod
Future<String> systemPrompt(Ref ref) =>
    rootBundle.loadString('assets/system_prompt.md');

このプロバイダは、Flutter のアセット読み込みシステムを使用して、実行時にプロンプト ファイルを読み取ります。

Gemini モデル プロバイダを更新する

次に、システム プロンプトを含めるように lib/providers/gemini.dart ファイルを変更します。

lib/providers/gemini.dart

import 'dart:async';

import 'package:firebase_ai/firebase_ai.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../firebase_options.dart';
import 'system_prompt.dart';                                          // Add this import

part 'gemini.g.dart';

@riverpod
Future<FirebaseApp> firebaseApp(Ref ref) =>
    Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

@riverpod
Future<GenerativeModel> geminiModel(Ref ref) async {
  await ref.watch(firebaseAppProvider.future);
  final systemPrompt = await ref.watch(systemPromptProvider.future);  // Add this line

  final model = FirebaseAI.googleAI().generativeModel(
    model: 'gemini-2.0-flash',
    systemInstruction: Content.system(systemPrompt),                  // And this line
  );
  return model;
}

@Riverpod(keepAlive: true)
Future<ChatSession> chatSession(Ref ref) async {
  final model = await ref.watch(geminiModelProvider.future);
  return model.startChat();
}

重要な変更点は、生成モデルの作成時に systemInstruction: Content.system(systemPrompt) を追加することです。これにより、Gemini はこのチャット セッションのすべてのインタラクションで、指示をシステム プロンプトとして使用します。

Riverpod コードを生成する

ビルドランナー コマンドを実行して、必要な Riverpod コードを生成します。

dart run build_runner build --delete-conflicting-outputs

アプリケーションを実行してテストする

続いてアプリケーションを実行します。

flutter run -d DEVICE

カラー選択アプリのキャラクターで Gemini LLM が回答している様子を示す Colorist アプリのスクリーンショット

さまざまな色の説明でテストしてみます。

  • 「スカイブルーがいい」
  • 「フォレスト グリーンにして」
  • 「鮮やかな夕焼けのオレンジ色を作成して」
  • 「I want the color of fresh lavender」
  • 「深海の青色のような色を見せて」

Gemini が、色に関する会話形式の説明と、一貫した形式の RGB 値を返していることがわかります。システム プロンプトは、必要なタイプの回答を提供するように LLM を効果的に誘導しています。

また、色以外のコンテキストでコンテンツをリクエストしてみてください。バラ戦争の主な原因などです。前の手順との違いに気づくはずです。

専門的なタスクにおけるプロンプト エンジニアリングの重要性

システム プロンプトは芸術であり科学でもあります。これらは LLM 統合の重要な部分であり、特定のアプリケーションでモデルがどれほど有用であるかに大きな影響を与える可能性があります。ここで行ったのは、プロンプト エンジニアリングの一種です。アプリケーションのニーズに合わせてモデルが動作するように指示を調整しています。

効果的なプロンプト エンジニアリングには、次の要素が含まれます。

  1. 明確な役割の定義: LLM の目的を確立する
  2. 明示的な指示: LLM がどのように応答すべきかを正確に指定する
  3. 具体的な例: 優れた回答がどのようなものかを説明するだけでなく、実際に示す
  4. エッジケースの処理: 曖昧なシナリオに対処する方法を LLM に指示する
  5. 形式設定の仕様: 回答が一貫性のある使いやすい形式で構成されるようにする

作成したシステム プロンプトにより、Gemini の汎用的な機能が、アプリケーションのニーズに合わせて特別にフォーマットされた回答を提供する、専門的な色解釈アシスタントに変換されます。これは、さまざまなドメインやタスクに適用できる強力なパターンです。

次のステップ

次のステップでは、関数宣言を追加してこの基盤を構築します。これにより、LLM は RGB 値を提案するだけでなく、アプリ内の関数を呼び出して色を直接設定できるようになります。これは、LLM が自然言語と具体的なアプリケーション機能のギャップを埋める方法を示しています。

トラブルシューティング

アセットの読み込みに関する問題

システム プロンプトの読み込み中にエラーが発生した場合は、次の操作を行います。

  • pubspec.yaml にアセット ディレクトリが正しくリストされていることを確認する
  • rootBundle.loadString() のパスがファイルの場所と一致していることを確認する
  • flutter clean の後に flutter pub get を実行してアセットバンドルを更新します。

一貫性のない対応

LLM が形式の指示に一貫して従っていない場合:

  • システム プロンプトでフォーマットの要件をより明確にしてみる
  • 期待されるパターンを示す例を追加する
  • リクエストする形式がモデルに適していることを確認する

API レート制限

レート制限に関連するエラーが発生した場合は、次の操作を行います。

  • Firebase AI Logic サービスには使用量の上限があることに注意してください
  • 指数バックオフでの再試行ロジックの実装を検討する
  • Firebase コンソールで割り当てに関する問題を確認する

学んだ主なコンセプト

  • LLM アプリケーションにおけるシステム プロンプトの役割と重要性を理解する
  • 明確な指示、例、制約を含む効果的なプロンプトを作成する
  • Flutter アプリケーションでシステム プロンプトを読み込んで使用する
  • ドメイン固有のタスクに対する LLM の動作をガイドする
  • プロンプト エンジニアリングを使用して LLM のレスポンスを形成する

このステップでは、コードを変更することなく、システム プロンプトで明確な指示を指定するだけで、LLM の動作を大幅にカスタマイズする方法を示します。

5. LLM ツールの関数宣言

このステップでは、関数宣言を実装して、アプリで Gemini がアクションを実行できるようにする作業を開始します。この強力な機能により、LLM は RGB 値を提案するだけでなく、専用のツール呼び出しを通じてアプリの UI で実際に設定できます。ただし、Flutter アプリで実行された LLM リクエストを確認するには、次のステップが必要です。

このステップの学習内容

  • LLM 関数呼び出しの概要と Flutter アプリケーションでのメリット
  • Gemini のスキーマベースの関数宣言を定義する
  • 関数宣言を Gemini モデルと統合する
  • ツール機能を活用するようにシステム プロンプトを更新する

関数呼び出しについて

関数宣言を実装する前に、関数宣言とは何か、なぜ関数宣言が重要なのかを理解しましょう。

関数呼び出しとは

関数呼び出し(「ツール使用」と呼ばれることもあります)は、LLM が次のことを行えるようにする機能です。

  1. ユーザー リクエストが特定の関数を呼び出すことでメリットが得られるかどうかを認識する
  2. その関数に必要なパラメータを含む構造化 JSON オブジェクトを生成する
  3. アプリケーションでこれらのパラメータを使用して関数を実行する
  4. 関数の結果を受け取り、レスポンスに組み込む

関数呼び出しを使用すると、LLM は実行する内容を説明するだけでなく、アプリケーションで具体的なアクションをトリガーできます。

Flutter アプリで関数呼び出しが重要な理由

関数呼び出しは、自然言語とアプリケーション機能の間に強力なブリッジを作成します。

  1. 直接アクション: ユーザーが自然言語で希望を説明すると、アプリが具体的なアクションで応答します
  2. 構造化された出力: LLM は、解析が必要なテキストではなく、クリーンで構造化されたデータを生成します。
  3. 複雑なオペレーション: LLM が外部データにアクセスしたり、計算を実行したり、アプリケーションの状態を変更したりできるようにします。
  4. ユーザー エクスペリエンスの向上: 会話と機能のシームレスな統合を実現

Colorist アプリでは、関数呼び出しにより、ユーザーが「フォレスト グリーンが欲しい」と言うと、テキストから RGB 値を解析しなくても、UI がその色ですぐに更新されます。

関数宣言を定義する

新しいファイル lib/services/gemini_tools.dart を作成して、関数宣言を定義します。

lib/services/gemini_tools.dart

import 'package:firebase_ai/firebase_ai.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'gemini_tools.g.dart';

class GeminiTools {
  GeminiTools(this.ref);

  final Ref ref;

  FunctionDeclaration get setColorFuncDecl => FunctionDeclaration(
    'set_color',
    'Set the color of the display square based on red, green, and blue values.',
    parameters: {
      'red': Schema.number(description: 'Red component value (0.0 - 1.0)'),
      'green': Schema.number(description: 'Green component value (0.0 - 1.0)'),
      'blue': Schema.number(description: 'Blue component value (0.0 - 1.0)'),
    },
  );

  List<Tool> get tools => [
    Tool.functionDeclarations([setColorFuncDecl]),
  ];
}

@riverpod
GeminiTools geminiTools(Ref ref) => GeminiTools(ref);

関数宣言について

このコードの動作を詳しく見てみましょう。

  1. 関数の命名: 関数の目的を明確に示すために、関数に set_color という名前を付けます。
  2. 関数の説明: LLM がいつ使用するかを理解するのに役立つ明確な説明を提供します。
  3. パラメータ定義: 構造化されたパラメータを独自の説明とともに定義します。
    • red: RGB の赤色成分。0.0 ~ 1.0 の数値で指定します。
    • green: RGB の緑のコンポーネント。0.0 ~ 1.0 の数値で指定します。
    • blue: RGB の青色コンポーネント。0.0 ~ 1.0 の数値で指定します。
  4. スキーマタイプ: Schema.number() を使用して、これらが数値であることを示します。
  5. ツール コレクション: 関数宣言を含むツールのリストを作成します。

この構造化されたアプローチにより、Gemini LLM は次のことを理解できます。

  • この関数を呼び出すタイミング
  • 提供する必要があるパラメータ
  • これらのパラメータに適用される制約(値の範囲など)

Gemini モデル プロバイダを更新する

次に、Gemini モデルの初期化時に関数宣言を含めるように lib/providers/gemini.dart ファイルを変更します。

lib/providers/gemini.dart

import 'dart:async';

import 'package:firebase_ai/firebase_ai.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../firebase_options.dart';
import '../services/gemini_tools.dart';                              // Add this import
import 'system_prompt.dart';

part 'gemini.g.dart';

@riverpod
Future<FirebaseApp> firebaseApp(Ref ref) =>
    Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

@riverpod
Future<GenerativeModel> geminiModel(Ref ref) async {
  await ref.watch(firebaseAppProvider.future);
  final systemPrompt = await ref.watch(systemPromptProvider.future);
  final geminiTools = ref.watch(geminiToolsProvider);                // Add this line

  final model = FirebaseAI.googleAI().generativeModel(
    model: 'gemini-2.0-flash',
    systemInstruction: Content.system(systemPrompt),
    tools: geminiTools.tools,                                        // And this line
  );
  return model;
}

@Riverpod(keepAlive: true)
Future<ChatSession> chatSession(Ref ref) async {
  final model = await ref.watch(geminiModelProvider.future);
  return model.startChat();
}

主な変更点は、生成モデルの作成時に tools: geminiTools.tools パラメータを追加することです。これにより、Gemini は呼び出し可能な関数を認識します。

システム プロンプトを更新する

次に、新しい set_color ツールを使用するように LLM に指示するように、システム プロンプトを変更する必要があります。assets/system_prompt.md を更新します。

assets/system_prompt.md

# Colorist System Prompt

You are a color expert assistant integrated into a desktop app called Colorist. Your job is to interpret natural language color descriptions and set the appropriate color values using a specialized tool.

## Your Capabilities

You are knowledgeable about colors, color theory, and how to translate natural language descriptions into specific RGB values. You have access to the following tool:

`set_color` - Sets the RGB values for the color display based on a description

## How to Respond to User Inputs

When users describe a color:

1. First, acknowledge their color description with a brief, friendly response
2. Interpret what RGB values would best represent that color description
3. Use the `set_color` tool to set those values (all values should be between 0.0 and 1.0)
4. After setting the color, provide a brief explanation of your interpretation

Example:
User: "I want a sunset orange"
You: "Sunset orange is a warm, vibrant color that captures the golden-red hues of the setting sun. It combines a strong red component with moderate orange tones."

[Then you would call the set_color tool with approximately: red=1.0, green=0.5, blue=0.25]

After the tool call: "I've set a warm orange with strong red, moderate green, and minimal blue components that is reminiscent of the sun low on the horizon."

## When Descriptions are Unclear

If a color description is ambiguous or unclear, please ask the user clarifying questions, one at a time.

## Important Guidelines

- Always keep RGB values between 0.0 and 1.0
- Provide thoughtful, knowledgeable responses about colors
- When possible, include color psychology, associations, or interesting facts about colors
- Be conversational and engaging in your responses
- Focus on being helpful and accurate with your color interpretations

システム プロンプトの主な変更点は次のとおりです。

  1. ツールの導入: 形式設定された RGB 値を求めるのではなく、set_color ツールについて LLM に伝えます。
  2. 変更されたプロセス: ステップ 3 を「レスポンスの値をフォーマットする」から「ツールを使用して値を設定する」に変更します。
  3. 更新された例: レスポンスに書式設定されたテキストではなくツール呼び出しを含める方法を示します
  4. 書式設定の要件を削除: 構造化された関数呼び出しを使用しているため、特定のテキスト形式は不要になりました。

この更新されたプロンプトは、LLM にテキスト形式で RGB 値を提供するだけでなく、関数呼び出しを使用するように指示します。

Riverpod コードを生成する

ビルドランナー コマンドを実行して、必要な Riverpod コードを生成します。

dart run build_runner build --delete-conflicting-outputs

アプリケーションを実行する

この時点で、Gemini は関数呼び出しを使用しようとするコンテンツを生成しますが、関数呼び出しのハンドラはまだ実装されていません。アプリを実行して色を説明すると、Gemini がツールを呼び出したかのように応答しますが、次のステップまで UI の色が変わることはありません。

アプリを実行します。

flutter run -d DEVICE

Gemini LLM が部分レスポンスで応答している様子を示す Colorist アプリのスクリーンショット

「深海のような青」や「森のような緑」などの色を説明して、回答を確認してみてください。LLM は上記の関数を呼び出そうとしていますが、コードはまだ関数呼び出しを検出していません。

関数呼び出しのプロセス

Gemini が関数呼び出しを使用するとどうなるかを見てみましょう。

  1. 関数の選択: LLM は、ユーザーのリクエストに基づいて関数呼び出しが役立つかどうかを判断します。
  2. パラメータ生成: LLM は、関数のスキーマに適合するパラメータ値を生成します。
  3. 関数呼び出しの形式: LLM は、構造化された関数呼び出しオブジェクトをレスポンスで送信します。
  4. アプリケーションの処理: アプリがこの呼び出しを受け取り、関連する関数(次のステップで実装)を実行します。
  5. レスポンスの統合: マルチターンの会話では、LLM は関数の結果が返されることを想定しています

現在のアプリの状態では、最初の 3 つのステップは実行されていますが、ステップ 4 と 5(関数呼び出しの処理)はまだ実装されていません。これは次のステップで実装します。

技術的な詳細: Gemini が関数を使用するタイミングを判断する方法

Gemini は、次の情報に基づいて関数を使用するタイミングをインテリジェントに判断します。

  1. ユーザーの意図: ユーザーのリクエストに最適なのが関数かどうか
  2. 関数の関連性: 利用可能な関数がタスクにどの程度一致しているか
  3. パラメータの可用性: パラメータ値を確実に特定できるかどうか
  4. システム指示: 関数使用に関するシステム プロンプトからのガイダンス

明確な関数宣言とシステム指示を提供することで、Gemini が色の説明リクエストを set_color 関数を呼び出す機会として認識するように設定しました。

次のステップ

次のステップでは、Gemini からの関数呼び出しのハンドラを実装します。これで、ユーザーの説明に基づいて LLM の関数呼び出しを通じて UI の色を実際に変更できるようになります。

トラブルシューティング

関数宣言に関する問題

関数宣言でエラーが発生した場合:

  • パラメータ名と型が想定どおりであることを確認する
  • 関数名が明確でわかりやすいことを確認する
  • 関数の説明が目的を正確に説明していることを確認する

システム プロンプトの問題

LLM が関数を使用しようとしていない場合:

  • システム プロンプトで、set_color ツールを使用するよう LLM に明確に指示していることを確認する
  • システム プロンプトの例で関数の使用方法が示されていることを確認する
  • ツールを使用する手順をより明確にしてみる

一般的な問題

その他の問題が発生した場合:

  • コンソールで関数宣言に関連するエラーを確認する
  • ツールがモデルに正しく渡されていることを確認する
  • Riverpod で生成されたすべてのコードが最新であることを確認する

学んだ主なコンセプト

  • Flutter アプリで LLM の機能を拡張するための関数宣言を定義する
  • 構造化データ収集用のパラメータ スキーマを作成する
  • 関数宣言と Gemini モデルの統合
  • 関数使用を促すようにシステム プロンプトを更新
  • LLM が関数を選択して呼び出す仕組みを理解する

このステップでは、LLM が自然言語入力と構造化された関数呼び出しのギャップを埋め、会話とアプリケーション機能のシームレスな統合の基盤を築く方法を示します。

6. ツール処理の実装

このステップでは、Gemini からの関数呼び出しのハンドラを実装します。これにより、自然言語入力と具体的なアプリケーション機能の間のコミュニケーションが完了し、LLM がユーザーの説明に基づいて UI を直接操作できるようになります。

このステップの学習内容

  • LLM アプリケーションの関数呼び出しパイプライン全体について
  • Flutter アプリケーションで Gemini からの関数呼び出しを処理する
  • アプリケーションの状態を変更する関数ハンドラの実装
  • 関数レスポンスの処理と LLM への結果の返信
  • LLM と UI 間の完全な通信フローを作成する
  • 透明性を確保するための関数呼び出しとレスポンスのロギング

関数呼び出しパイプラインについて

実装に入る前に、関数呼び出しパイプライン全体を理解しましょう。

エンドツーエンドのフロー

  1. ユーザー入力: ユーザーが自然言語で色を説明します(例: "forest green")
  2. LLM 処理: Gemini が説明を分析し、set_color 関数を呼び出すことを決定します。
  3. 関数呼び出しの生成: Gemini は、パラメータ(赤、緑、青の値)を含む構造化された JSON を作成します。
  4. 関数呼び出しの受信: アプリが Gemini からこの構造化データを受信します。
  5. 関数の実行: アプリは指定されたパラメータで関数を実行します。
  6. 状態の更新: 関数がアプリの状態を更新します(表示される色を変更します)。
  7. レスポンスの生成: 関数が結果を LLM に返す
  8. 回答の組み込み: LLM はこれらの結果を最終的な回答に組み込みます。
  9. UI の更新: UI が状態の変化に反応し、新しい色を表示します。

適切な LLM 統合には、完全な通信サイクルが不可欠です。LLM が関数呼び出しを行う場合、リクエストを送信して次に進むだけではありません。代わりに、アプリケーションが関数を実行して結果を返すのを待ちます。LLM はこれらの結果を使用して最終的なレスポンスを生成し、実行されたアクションを認識する自然な会話フローを作成します。

関数ハンドラを実装する

lib/services/gemini_tools.dart ファイルを更新して、関数呼び出しのハンドラを追加しましょう。

lib/services/gemini_tools.dart

import 'package:colorist_ui/colorist_ui.dart';
import 'package:firebase_ai/firebase_ai.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'gemini_tools.g.dart';

class GeminiTools {
  GeminiTools(this.ref);

  final Ref ref;

  FunctionDeclaration get setColorFuncDecl => FunctionDeclaration(
    'set_color',
    'Set the color of the display square based on red, green, and blue values.',
    parameters: {
      'red': Schema.number(description: 'Red component value (0.0 - 1.0)'),
      'green': Schema.number(description: 'Green component value (0.0 - 1.0)'),
      'blue': Schema.number(description: 'Blue component value (0.0 - 1.0)'),
    },
  );

  List<Tool> get tools => [
    Tool.functionDeclarations([setColorFuncDecl]),
  ];

  Map<String, Object?> handleFunctionCall(                           // Add from here
    String functionName,
    Map<String, Object?> arguments,
  ) {
    final logStateNotifier = ref.read(logStateProvider.notifier);
    logStateNotifier.logFunctionCall(functionName, arguments);
    return switch (functionName) {
      'set_color' => handleSetColor(arguments),
      _ => handleUnknownFunction(functionName),
    };
  }

  Map<String, Object?> handleSetColor(Map<String, Object?> arguments) {
    final colorStateNotifier = ref.read(colorStateProvider.notifier);
    final red = (arguments['red'] as num).toDouble();
    final green = (arguments['green'] as num).toDouble();
    final blue = (arguments['blue'] as num).toDouble();
    final functionResults = {
      'success': true,
      'current_color': colorStateNotifier
          .updateColor(red: red, green: green, blue: blue)
          .toLLMContextMap(),
    };

    final logStateNotifier = ref.read(logStateProvider.notifier);
    logStateNotifier.logFunctionResults(functionResults);
    return functionResults;
  }

  Map<String, Object?> handleUnknownFunction(String functionName) {
    final logStateNotifier = ref.read(logStateProvider.notifier);
    logStateNotifier.logWarning('Unsupported function call $functionName');
    return {
      'success': false,
      'reason': 'Unsupported function call $functionName',
    };
  }                                                                  // To here.
}

@riverpod
GeminiTools geminiTools(Ref ref) => GeminiTools(ref);

関数ハンドラについて

これらの関数ハンドラの機能を見てみましょう。

  1. handleFunctionCall: 次の処理を行う中央ディスパッチャー。
    • ログパネルで透明性のための関数呼び出しをログに記録する
    • 関数名に基づいて適切なハンドラにルーティングする
    • LLM に返送される構造化されたレスポンスを返します。
  2. handleSetColor: 次の処理を行う set_color 関数の特定のハンドラ。
    • 引数マップから RGB 値を抽出します
    • 想定される型(double)に変換します。
    • colorStateNotifier を使用してアプリの色の状態を更新します
    • 成功ステータスと現在の色情報を含む構造化されたレスポンスを作成します
    • デバッグ用に関数結果をログに記録する
  3. handleUnknownFunction: 不明な関数のフォールバック ハンドラ。
    • サポートされていない関数に関する警告をログに記録する
    • LLM にエラー レスポンスを返す

handleSetColor 関数は、LLM の自然言語理解と具体的な UI の変更のギャップを埋めるため、特に重要です。

関数呼び出しとレスポンスを処理するように Gemini チャット サービスを更新する

次に、LLM レスポンスからの関数呼び出しを処理し、結果を LLM に送り返すように lib/services/gemini_chat_service.dart ファイルを更新します。

lib/services/gemini_chat_service.dart

import 'dart:async';

import 'package:colorist_ui/colorist_ui.dart';
import 'package:firebase_ai/firebase_ai.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../providers/gemini.dart';
import 'gemini_tools.dart';                                          // Add this import

part 'gemini_chat_service.g.dart';

class GeminiChatService {
  GeminiChatService(this.ref);
  final Ref ref;

  Future<void> sendMessage(String message) async {
    final chatSession = await ref.read(chatSessionProvider.future);
    final chatStateNotifier = ref.read(chatStateProvider.notifier);
    final logStateNotifier = ref.read(logStateProvider.notifier);

    chatStateNotifier.addUserMessage(message);
    logStateNotifier.logUserText(message);
    final llmMessage = chatStateNotifier.createLlmMessage();
    try {
      final response = await chatSession.sendMessage(Content.text(message));

      final responseText = response.text;
      if (responseText != null) {
        logStateNotifier.logLlmText(responseText);
        chatStateNotifier.appendToMessage(llmMessage.id, responseText);
      }

      if (response.functionCalls.isNotEmpty) {                       // Add from here
        final geminiTools = ref.read(geminiToolsProvider);
        final functionResultResponse = await chatSession.sendMessage(
          Content.functionResponses([
            for (final functionCall in response.functionCalls)
              FunctionResponse(
                functionCall.name,
                geminiTools.handleFunctionCall(
                  functionCall.name,
                  functionCall.args,
                ),
              ),
          ]),
        );
        final responseText = functionResultResponse.text;
        if (responseText != null) {
          logStateNotifier.logLlmText(responseText);
          chatStateNotifier.appendToMessage(llmMessage.id, responseText);
        }
      }                                                              // To here.
    } catch (e, st) {
      logStateNotifier.logError(e, st: st);
      chatStateNotifier.appendToMessage(
        llmMessage.id,
        "\nI'm sorry, I encountered an error processing your request. "
        "Please try again.",
      );
    } finally {
      chatStateNotifier.finalizeMessage(llmMessage.id);
    }
  }
}

@riverpod
GeminiChatService geminiChatService(Ref ref) => GeminiChatService(ref);

コミュニケーション フローを理解する

ここでの重要な追加は、関数呼び出しとレスポンスの完全な処理です。

if (response.functionCalls.isNotEmpty) {
  final geminiTools = ref.read(geminiToolsProvider);
  final functionResultResponse = await chatSession.sendMessage(
    Content.functionResponses([
      for (final functionCall in response.functionCalls)
        FunctionResponse(
          functionCall.name,
          geminiTools.handleFunctionCall(
            functionCall.name,
            functionCall.args,
          ),
        ),
    ]),
  );
  final responseText = functionResultResponse.text;
  if (responseText != null) {
    logStateNotifier.logLlmText(responseText);
    chatStateNotifier.appendToMessage(llmMessage.id, responseText);
  }
}

このコード:

  1. LLM レスポンスに関数呼び出しが含まれているかどうかを確認します
  2. 関数呼び出しごとに、関数名と引数を使用して handleFunctionCall メソッドを呼び出します。
  3. 各関数呼び出しの結果を収集します。
  4. Content.functionResponses を使用して、これらの結果を LLM に送り返します。
  5. 関数結果に対する LLM のレスポンスを処理する
  6. 最終レスポンスのテキストで UI を更新します

これにより、往復フローが作成されます。

  • ユーザー → LLM: 色をリクエストする
  • LLM → アプリ: パラメータ付きの関数呼び出し
  • アプリ → ユーザー: 新しい色が表示される
  • アプリ → LLM: 関数の結果
  • LLM → ユーザー: 関数の結果を組み込んだ最終的なレスポンス

Riverpod コードを生成する

ビルドランナー コマンドを実行して、必要な Riverpod コードを生成します。

dart run build_runner build --delete-conflicting-outputs

フロー全体を実行してテストする

続いてアプリケーションを実行します。

flutter run -d DEVICE

Gemini LLM が関数呼び出しで応答している様子を示す Colorist アプリのスクリーンショット

さまざまな色の説明を入力してみましょう。

  • 「深紅色のものが欲しい」
  • 「落ち着いた空色を見せて」
  • 「新鮮なミントの葉の色を教えて」
  • 「夕焼けのオレンジ色が見たい」
  • 「濃いロイヤル パープルにして」

次のように表示されます。

  1. チャット インターフェースに表示されたメッセージ
  2. チャットに表示された Gemini の回答
  3. ログパネルに記録されている関数呼び出し
  4. 関数の結果が直後にログに記録される
  5. 説明された色を表示するように更新される色の長方形
  6. RGB 値が更新され、新しい色のコンポーネントが表示される
  7. Gemini の最終的な回答が表示され、設定された色についてコメントすることが多い

ログパネルには、バックグラウンドで何が起こっているかについての情報が表示されます。表示される項目

  • Gemini が行う正確な関数呼び出し
  • 各 RGB 値に対して選択するパラメータ
  • 関数が返す結果
  • Gemini からのフォローアップの回答

色状態通知ツール

色の更新に使用している colorStateNotifier は、colorist_ui パッケージの一部です。管理対象:

  • UI に表示される現在の色
  • 色の履歴(直近 10 色)
  • UI コンポーネントへの状態変更の通知

新しい RGB 値で updateColor を呼び出すと、次の処理が行われます。

  1. 指定された値を使用して新しい ColorData オブジェクトを作成します。
  2. アプリの状態の現在の色を更新します
  3. 色を履歴に追加する
  4. Riverpod の状態管理を介して UI の更新をトリガーします。

colorist_ui パッケージの UI コンポーネントは、この状態を監視し、状態が変化すると自動的に更新して、リアクティブなエクスペリエンスを実現します。

エラー処理について

実装に堅牢なエラー処理が含まれている。

  1. Try-catch ブロック: すべての LLM インタラクションをラップして例外をキャッチします。
  2. エラーロギング: スタック トレースを含むエラーをログパネルに記録します。
  3. ユーザー フィードバック: チャットでわかりやすいエラー メッセージを表示します
  4. 状態のクリーンアップ: エラーが発生した場合でもメッセージの状態を確定します

これにより、LLM サービスや関数の実行で問題が発生した場合でも、アプリの安定性が維持され、適切なフィードバックが提供されます。

ユーザー エクスペリエンスのための関数呼び出しの力

ここで達成したことは、LLM が強力な自然言語インターフェースをどのように作成できるかを示しています。

  1. 自然言語インターフェース: ユーザーが日常言語で意図を表現する
  2. インテリジェントな解釈: LLM が曖昧な説明を正確な値に変換します
  3. 直接操作: 自然言語に応じて UI が更新されます
  4. コンテキストに応じた回答: LLM は、変更に関する会話のコンテキストを提供します。
  5. 認知負荷が低い: ユーザーは RGB 値や色彩理論を理解する必要がない

LLM 関数呼び出しを使用して自然言語と UI アクションを橋渡しするこのパターンは、色の選択以外にも無数のドメインに拡張できます。

次のステップ

次のステップでは、ストリーミング レスポンスを実装してユーザー エクスペリエンスを向上させます。完全なレスポンスを待つのではなく、テキスト チャンクと関数呼び出しを受信したときに処理することで、応答性が高く魅力的なアプリケーションを作成できます。

トラブルシューティング

関数呼び出しに関する問題

Gemini が関数を呼び出さない場合や、パラメータが正しくない場合:

  • 関数宣言がシステム プロンプトの説明と一致していることを確認する
  • パラメータ名と型が一貫していることを確認する
  • システム プロンプトで LLM にツールを使用するよう明示的に指示する
  • ハンドラの関数名が宣言と完全に一致していることを確認する
  • ログパネルで関数呼び出しの詳細情報を確認する

関数レスポンスの問題

関数結果が LLM に正しく返送されない場合:

  • 関数が正しい形式の Map を返していることを確認する
  • Content.functionResponses が正しく構築されていることを確認する
  • ログで関数レスポンスに関連するエラーを探す
  • レスポンスに同じチャット セッションを使用していることを確認する

色の表示に関する問題

色が正しく表示されない場合:

  • RGB 値が double に適切に変換されるようにします(LLM が整数として送信する可能性があります)。
  • 値が想定範囲(0.0 ~ 1.0)内にあることを確認します。
  • カラー状態通知が正しく呼び出されていることを確認する
  • 関数に渡される正確な値についてログを調べます

全般的な問題

一般的な問題の場合:

  • ログでエラーや警告を確認する
  • Firebase AI Logic の接続を確認する
  • 関数パラメータの型が一致しないかどうかを確認する
  • Riverpod で生成されたすべてのコードが最新であることを確認する

学んだ主なコンセプト

  • Flutter で完全な関数呼び出しパイプラインを実装する
  • LLM とアプリケーション間の完全な通信の作成
  • LLM レスポンスからの構造化データの処理
  • 関数結果を LLM に送り返して回答に組み込む
  • ログパネルを使用して LLM とアプリケーションのやり取りを可視化する
  • 自然言語入力を具体的な UI の変更に接続する

この手順を完了すると、アプリは LLM 統合の最も強力なパターンの 1 つである、自然言語入力を具体的な UI アクションに変換し、これらのアクションを認識する一貫性のある会話を維持するパターンを示すようになります。これにより、ユーザーにとって魔法のように感じられる直感的で会話型のインターフェースが作成されます。

7. UX を改善するためのストリーミング レスポンス

このステップでは、Gemini からのストリーミング レスポンスを実装して、ユーザー エクスペリエンスを向上させます。レスポンス全体が生成されるのを待つのではなく、テキスト チャンクと関数呼び出しを受信したときに処理することで、応答性が高く魅力的なアプリケーションを作成できます。

このステップで説明する内容

  • LLM を活用したアプリケーションにおけるストリーミングの重要性
  • Flutter アプリケーションでストリーミング LLM レスポンスを実装する
  • API から到着した部分的なテキスト チャンクを処理する
  • メッセージの競合を防ぐための会話状態の管理
  • ストリーミング レスポンスでの関数呼び出しの処理
  • 進行中の回答の視覚的インジケーターを作成する

LLM アプリケーションでストリーミングが重要な理由

実装する前に、LLM で優れたユーザー エクスペリエンスを実現するためにストリーミング レスポンスが重要な理由を理解しましょう。

ユーザー エクスペリエンスの向上

ストリーミング レスポンスには、ユーザー エクスペリエンスを大幅に向上させるメリットがいくつかあります。

  1. 認識されるレイテンシの短縮: ユーザーは、完全なレスポンスを数秒待つのではなく、テキストがすぐに表示される(通常は 100 ~ 300 ミリ秒以内)のを確認できます。この即時性に対する認識により、ユーザー満足度が大幅に向上します。
  2. 自然な会話のリズム: テキストが徐々に表示されることで、人間がコミュニケーションをとる様子が再現され、より自然な会話体験が実現します。
  3. 段階的な情報処理: ユーザーは、大量のテキストを一度に処理するのではなく、情報が届くたびに処理を開始できます。
  4. 早期に中断できる可能性がある: 完全なアプリケーションでは、LLM が役に立たない方向に進んでいると判断した場合、ユーザーが LLM を中断またはリダイレクトできる可能性があります。
  5. アクティビティの視覚的な確認: ストリーミング テキストにより、システムが動作していることをすぐに確認できるため、不確実性が軽減されます。

技術的なメリット

ストリーミングには、UX の改善以外にも技術的なメリットがあります。

  1. 関数の早期実行: 関数呼び出しは、完全なレスポンスを待たずに、ストリームに表示されるとすぐに検出して実行できます。
  2. UI の増分更新: 新しい情報が届くたびに UI を段階的に更新し、より動的なエクスペリエンスを作成できます。
  3. 会話の状態管理: ストリーミングは、レスポンスが完了したときと処理中のときを明確に示し、状態管理を改善します。
  4. タイムアウトのリスクの軽減: ストリーミング レスポンスを使用しない場合、長時間実行される生成では接続タイムアウトのリスクがあります。ストリーミングは接続を早期に確立し、維持します。

Colorist アプリでストリーミングを実装すると、テキスト レスポンスと色の変化がより迅速に表示されるため、ユーザー エクスペリエンスが大幅に向上します。

会話状態管理を追加する

まず、アプリが現在ストリーミング レスポンスを処理しているかどうかを追跡する状態プロバイダを追加しましょう。lib/services/gemini_chat_service.dart ファイルを更新します。

lib/services/gemini_chat_service.dart

import 'dart:async';

import 'package:colorist_ui/colorist_ui.dart';
import 'package:firebase_ai/firebase_ai.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../providers/gemini.dart';
import 'gemini_tools.dart';

part 'gemini_chat_service.g.dart';

final conversationStateProvider = StateProvider(                     // Add from here...
  (ref) => ConversationState.idle,
);                                                                   // To here.

class GeminiChatService {
  GeminiChatService(this.ref);
  final Ref ref;

  Future<void> sendMessage(String message) async {
    final chatSession = await ref.read(chatSessionProvider.future);
    final conversationState = ref.read(conversationStateProvider);   // Add this line
    final chatStateNotifier = ref.read(chatStateProvider.notifier);
    final logStateNotifier = ref.read(logStateProvider.notifier);

    if (conversationState == ConversationState.busy) {               // Add from here...
      logStateNotifier.logWarning(
        "Can't send a message while a conversation is in progress",
      );
      throw Exception(
        "Can't send a message while a conversation is in progress",
      );
    }
    final conversationStateNotifier = ref.read(
      conversationStateProvider.notifier,
    );
    conversationStateNotifier.state = ConversationState.busy;        // To here.
    chatStateNotifier.addUserMessage(message);
    logStateNotifier.logUserText(message);
    final llmMessage = chatStateNotifier.createLlmMessage();
    try {                                                            // Modify from here...
      final responseStream = chatSession.sendMessageStream(
        Content.text(message),
      );
      await for (final block in responseStream) {
        await _processBlock(block, llmMessage.id);
      }                                                              // To here.
    } catch (e, st) {
      logStateNotifier.logError(e, st: st);
      chatStateNotifier.appendToMessage(
        llmMessage.id,
        "\nI'm sorry, I encountered an error processing your request. "
        "Please try again.",
      );
    } finally {
      chatStateNotifier.finalizeMessage(llmMessage.id);
      conversationStateNotifier.state = ConversationState.idle;      // Add this line.
    }
  }

  Future<void> _processBlock(                                        // Add from here...
    GenerateContentResponse block,
    String llmMessageId,
  ) async {
    final chatSession = await ref.read(chatSessionProvider.future);
    final chatStateNotifier = ref.read(chatStateProvider.notifier);
    final logStateNotifier = ref.read(logStateProvider.notifier);
    final blockText = block.text;

    if (blockText != null) {
      logStateNotifier.logLlmText(blockText);
      chatStateNotifier.appendToMessage(llmMessageId, blockText);
    }

    if (block.functionCalls.isNotEmpty) {
      final geminiTools = ref.read(geminiToolsProvider);
      final responseStream = chatSession.sendMessageStream(
        Content.functionResponses([
          for (final functionCall in block.functionCalls)
            FunctionResponse(
              functionCall.name,
              geminiTools.handleFunctionCall(
                functionCall.name,
                functionCall.args,
              ),
            ),
        ]),
      );
      await for (final response in responseStream) {
        final responseText = response.text;
        if (responseText != null) {
          logStateNotifier.logLlmText(responseText);
          chatStateNotifier.appendToMessage(llmMessageId, responseText);
        }
      }
    }
  }                                                                  // To here.
}

@riverpod
GeminiChatService geminiChatService(Ref ref) => GeminiChatService(ref);

ストリーミングの実装について

このコードの動作を詳しく見てみましょう。

  1. 会話の状態のトラッキング:
    • conversationStateProvider は、アプリが現在レスポンスを処理しているかどうかを追跡します。
    • 処理中に状態が idlebusy に移行し、その後 idle に戻ります。
    • これにより、競合する可能性のある複数の同時リクエストを防ぎます。
  2. ストリームの初期化:
    • sendMessageStream() は、完全なレスポンスを含む Future ではなく、レスポンス チャンクのストリームを返します。
    • 各チャンクには、テキスト、関数呼び出し、またはその両方を含めることができます。
  3. プログレッシブ処理:
    • await for は、各チャンクがリアルタイムで到着するたびに処理します。
    • テキストは UI にすぐに付加され、ストリーミング効果が生まれます
    • 関数呼び出しは検出されるとすぐに実行されます
  4. 関数呼び出しの処理:
    • チャンクで関数呼び出しが検出されると、すぐに実行されます。
    • 結果は別のストリーミング呼び出しを介して LLM に返されます
    • これらの結果に対する LLM のレスポンスもストリーミング方式で処理されます。
  5. エラー処理とクリーンアップ:
    • try/catch は堅牢なエラー処理を提供します
    • finally ブロックにより、会話の状態が適切にリセットされる
    • エラーが発生した場合でも、メッセージは常に最終処理される

この実装により、適切な会話状態を維持しながら、応答性が高く信頼性の高いストリーミング エクスペリエンスが実現します。

会話の状態を接続するようにメイン画面を更新

lib/main.dart ファイルを変更して、会話の状態をメイン画面に渡します。

lib/main.dart

import 'package:colorist_ui/colorist_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'providers/gemini.dart';
import 'services/gemini_chat_service.dart';

void main() async {
  runApp(ProviderScope(child: MainApp()));
}

class MainApp extends ConsumerWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final model = ref.watch(geminiModelProvider);
    final conversationState = ref.watch(conversationStateProvider);  // Add this line

    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: model.when(
        data: (data) => MainScreen(
          conversationState: conversationState,                      // And this line
          sendMessage: (text) {
            ref.read(geminiChatServiceProvider).sendMessage(text);
          },
        ),
        loading: () => LoadingScreen(message: 'Initializing Gemini Model'),
        error: (err, st) => ErrorScreen(error: err),
      ),
    );
  }
}

ここでの主な変更点は、conversationStateMainScreen ウィジェットに渡すことです。MainScreencolorist_ui パッケージによって提供)は、この状態を使用して、レスポンスの処理中にテキスト入力を無効にします。

これにより、UI が会話の現在の状態を反映する、一貫性のあるユーザー エクスペリエンスが実現します。

Riverpod コードを生成する

ビルドランナー コマンドを実行して、必要な Riverpod コードを生成します。

dart run build_runner build --delete-conflicting-outputs

ストリーミング レスポンスを実行してテストする

アプリケーションを実行します。

flutter run -d DEVICE

Gemini LLM がストリーミング形式で応答している様子を示す Colorist アプリのスクリーンショット

さまざまな色の説明を使用して、ストリーミングの動作をテストしてみましょう。次のような説明を試してください。

  • 「夕暮れ時の海のディープ ティール色を見せて」
  • 「熱帯の花を思わせる鮮やかなサンゴが見たい」
  • 「古い軍服のような落ち着いたオリーブ グリーンを作成して」

ストリーミングの技術的なフローの詳細

レスポンスをストリーミングする際に何が起こるかを見てみましょう。

接続の確立

sendMessageStream() を呼び出すと、次の処理が行われます。

  1. アプリが Firebase AI Logic サービスへの接続を確立する
  2. ユーザー リクエストがサービスに送信される
  3. サーバーがリクエストの処理を開始する
  4. ストリーム接続は開いたままになり、チャンクを送信する準備が整います。

チャンク送信

Gemini がコンテンツを生成すると、チャンクがストリームを介して送信されます。

  1. サーバーは、生成されたテキストのチャンク(通常は数語または数文)を送信します。
  2. Gemini が関数呼び出しを行うと判断すると、関数呼び出し情報が送信されます。
  3. 関数呼び出しの後にテキスト チャンクが続く場合がある
  4. 生成が完了するまでストリームが継続されます

プログレッシブ処理

アプリは各チャンクを段階的に処理します。

  1. 各テキスト チャンクが既存のレスポンスに追加されます。
  2. 関数呼び出しは検出されるとすぐに実行されます
  3. UI がテキストと関数結果の両方でリアルタイムに更新される
  4. 状態が追跡され、レスポンスがまだストリーミング中であることが示される

ストリーミングの完了

生成が完了したら:

  1. ストリームがサーバーによって閉じられる
  2. await for ループは自然に終了します
  3. メッセージに完了マークが付いている
  4. 会話の状態がアイドル状態に戻ります
  5. UI が更新され、完了状態が反映されます。

ストリーミングと非ストリーミングの比較

ストリーミングのメリットをより深く理解するために、ストリーミング アプローチと非ストリーミング アプローチを比較してみましょう。

Aspect

非ストリーミング

ストリーミング

体感レイテンシ

完全なレスポンスが準備できるまで、ユーザーには何も表示されない

ユーザーが最初の単語をミリ秒単位で確認できる

ユーザー エクスペリエンス

長い待機時間の後にテキストが突然表示される

自然で段階的なテキストの表示

状態管理

よりシンプル(メッセージは保留中か完了のいずれか)

より複雑(メッセージがストリーミング状態になる可能性がある)

関数の実行

完全なレスポンスの後にのみ発生する

レスポンスの生成中に発生する

実装の複雑さ

実装が簡単

追加の状態管理が必要

エラー回復

オール オア ナッシングのレスポンス

部分的なレスポンスも有用な場合があります

コードの複雑さ

複雑さが軽減

ストリーム処理のため、より複雑になる

Colorist のようなアプリケーションでは、ストリーミングの UX のメリットが実装の複雑さを上回ります。特に、生成に数秒かかる可能性がある色の解釈では、その傾向が顕著です。

ストリーミング UX に関するベスト プラクティス

独自の LLM アプリケーションでストリーミングを実装する場合は、次のベスト プラクティスを検討してください。

  1. 明確なビジュアル インジケーター: ストリーミング メッセージと完全なメッセージを区別する明確なビジュアル キューを常に提供します。
  2. 入力のブロック: ストリーミング中にユーザー入力を無効にして、複数のリクエストが重複しないようにします。
  3. エラー復旧: ストリーミングが中断された場合にグレースフル リカバリを処理するように UI を設計する
  4. 状態の遷移: アイドル状態、ストリーミング状態、完了状態間のスムーズな遷移を確保します。
  5. 進行状況の可視化: 処理中の状態を示す控えめなアニメーションやインジケーターを検討する
  6. キャンセル オプション: 完成したアプリでは、ユーザーが進行中の生成をキャンセルする方法を提供します。
  7. 関数結果の統合: ストリームの途中で表示される関数結果を処理するように UI を設計する
  8. パフォーマンスの最適化: ストリームの更新が頻繁に行われる際の UI の再構築を最小限に抑えます

colorist_ui パッケージは、これらのベスト プラクティスの多くを実装しますが、ストリーミング LLM の実装では、これらの点を考慮することが重要です。

次のステップ

次のステップでは、ユーザーが履歴から色を選択したときに Gemini に通知することで、LLM 同期を実装します。これにより、LLM がユーザーが開始したアプリケーションの状態の変化を認識する、よりまとまりのあるエクスペリエンスが実現します。

トラブルシューティング

ストリーム処理に関する問題

ストリーム処理で問題が発生した場合は、次の操作を行います。

  • 事象: 部分レスポンス、テキストの欠落、ストリームの突然の終了
  • 解決策: ネットワーク接続を確認し、コードで適切な async/await パターンを使用していることを確認する
  • 診断: ログパネルで、ストリーム処理に関連するエラー メッセージや警告を確認します。
  • 修正: すべてのストリーム処理で try/catch ブロックによる適切なエラー処理が使用されるようにする

関数呼び出しの欠落

ストリームで関数呼び出しが検出されない場合:

  • 事象: テキストは表示されるが、色が更新されない、またはログに関数呼び出しが表示されない
  • 解決策: 関数呼び出しの使用に関するシステム プロンプトの指示を確認する
  • 診断: ログパネルで関数呼び出しが受信されているかどうかを確認します。
  • 解決策: set_color ツールを使用するように LLM により明確に指示するように、システム プロンプトを調整します。

一般的なエラー処理

その他の問題:

  • ステップ 1: [ログ] パネルでエラー メッセージを確認する
  • ステップ 2: Firebase AI Logic の接続を確認する
  • ステップ 3: Riverpod で生成されたコードがすべて最新であることを確認する
  • ステップ 4: ストリーミング実装で await ステートメントが欠落していないか確認する

学んだ主なコンセプト

  • Gemini API を使用してストリーミング レスポンスを実装し、UX の応答性を高める
  • ストリーミング インタラクションを適切に処理するための会話状態の管理
  • リアルタイム テキストと関数呼び出しを到着時に処理する
  • ストリーミング中に増分更新されるレスポンシブ UI を作成する
  • 適切な非同期パターンによる同時ストリームの処理
  • ストリーミング応答中に適切な視覚的フィードバックを提供する

ストリーミングを実装することで、Colorist アプリのユーザー エクスペリエンスが大幅に向上し、より応答性が高く、魅力的なインターフェースが実現しました。

8. LLM コンテキストの同期

このボーナス ステップでは、ユーザーが履歴から色を選択したときに Gemini に通知することで、LLM コンテキスト同期を実装します。これにより、LLM が明示的なメッセージだけでなく、インターフェースでのユーザー アクションも認識する、より一貫性のあるエクスペリエンスが実現します。

このステップで説明する内容

  • UI と LLM の間で LLM コンテキスト同期を作成する
  • LLM が理解できるコンテキストに UI イベントをシリアル化する
  • ユーザー アクションに基づいて会話コンテキストを更新する
  • さまざまなインタラクション方法で一貫性のあるエクスペリエンスを作成する
  • 明示的なチャット メッセージを超えて LLM のコンテキスト認識を強化する

LLM コンテキストの同期について

従来のチャットボットは、ユーザーの明示的なメッセージにのみ応答するため、ユーザーが他の方法でアプリを操作すると、応答が途切れてしまいます。LLM コンテキスト同期はこの制限に対処します。

LLM コンテキストの同期が重要な理由

ユーザーが UI 要素(履歴から色を選択するなど)を介してアプリを操作した場合、明示的に LLM に伝えない限り、LLM は何が起こったのかを把握できません。LLM コンテキストの同期:

  1. コンテキストを維持する: 関連するユーザー アクションをすべて LLM に通知します
  2. 一貫性を生み出す: LLM が UI 操作を認識する、一貫性のあるエクスペリエンスを生み出す
  3. インテリジェンスを強化する: LLM がすべてのユーザー アクションに適切に応答できるようにします。
  4. ユーザー エクスペリエンスの向上: アプリケーション全体がより統合され、応答性が向上します。
  5. ユーザーの手間を軽減: ユーザーが UI アクションを手動で説明する必要がなくなります。

Colorist アプリで、ユーザーが履歴から色を選択したときに、Gemini がこのアクションを認識し、選択した色についてインテリジェントなコメントを行い、シームレスで認識力の高いアシスタントという印象を維持したいと考えています。

色の選択に関する通知のために Gemini Chat サービスを更新

まず、ユーザーが履歴から色を選択したときに LLM に通知するメソッドを GeminiChatService に追加します。lib/services/gemini_chat_service.dart ファイルを更新します。

lib/services/gemini_chat_service.dart

import 'dart:async';
import 'dart:convert';                                               // Add this import

import 'package:colorist_ui/colorist_ui.dart';
import 'package:firebase_ai/firebase_ai.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../providers/gemini.dart';
import 'gemini_tools.dart';

part 'gemini_chat_service.g.dart';

final conversationStateProvider = StateProvider(
  (ref) => ConversationState.idle,
);

class GeminiChatService {
  GeminiChatService(this.ref);
  final Ref ref;

  Future<void> notifyColorSelection(ColorData color) => sendMessage(  // Add from here...
    'User selected color from history: ${json.encode(color.toLLMContextMap())}',
  );                                                                  // To here.

  Future<void> sendMessage(String message) async {
    final chatSession = await ref.read(chatSessionProvider.future);
    final conversationState = ref.read(conversationStateProvider);
    final chatStateNotifier = ref.read(chatStateProvider.notifier);
    final logStateNotifier = ref.read(logStateProvider.notifier);

    if (conversationState == ConversationState.busy) {
      logStateNotifier.logWarning(
        "Can't send a message while a conversation is in progress",
      );
      throw Exception(
        "Can't send a message while a conversation is in progress",
      );
    }
    final conversationStateNotifier = ref.read(
      conversationStateProvider.notifier,
    );
    conversationStateNotifier.state = ConversationState.busy;
    chatStateNotifier.addUserMessage(message);
    logStateNotifier.logUserText(message);
    final llmMessage = chatStateNotifier.createLlmMessage();
    try {
      final responseStream = chatSession.sendMessageStream(
        Content.text(message),
      );
      await for (final block in responseStream) {
        await _processBlock(block, llmMessage.id);
      }
    } catch (e, st) {
      logStateNotifier.logError(e, st: st);
      chatStateNotifier.appendToMessage(
        llmMessage.id,
        "\nI'm sorry, I encountered an error processing your request. "
        "Please try again.",
      );
    } finally {
      chatStateNotifier.finalizeMessage(llmMessage.id);
      conversationStateNotifier.state = ConversationState.idle;
    }
  }

  Future<void> _processBlock(
    GenerateContentResponse block,
    String llmMessageId,
  ) async {
    final chatSession = await ref.read(chatSessionProvider.future);
    final chatStateNotifier = ref.read(chatStateProvider.notifier);
    final logStateNotifier = ref.read(logStateProvider.notifier);
    final blockText = block.text;

    if (blockText != null) {
      logStateNotifier.logLlmText(blockText);
      chatStateNotifier.appendToMessage(llmMessageId, blockText);
    }

    if (block.functionCalls.isNotEmpty) {
      final geminiTools = ref.read(geminiToolsProvider);
      final responseStream = chatSession.sendMessageStream(
        Content.functionResponses([
          for (final functionCall in block.functionCalls)
            FunctionResponse(
              functionCall.name,
              geminiTools.handleFunctionCall(
                functionCall.name,
                functionCall.args,
              ),
            ),
        ]),
      );
      await for (final response in responseStream) {
        final responseText = response.text;
        if (responseText != null) {
          logStateNotifier.logLlmText(responseText);
          chatStateNotifier.appendToMessage(llmMessageId, responseText);
        }
      }
    }
  }
}

@riverpod
GeminiChatService geminiChatService(Ref ref) => GeminiChatService(ref);

主な追加は notifyColorSelection メソッドです。このメソッドは次の処理を行います。

  1. 選択した色を表す ColorData オブジェクトを受け取ります
  2. メッセージに含めることができる JSON 形式にエンコードします。
  3. ユーザーの選択を示す特別な形式のメッセージを LLM に送信します
  4. 既存の sendMessage メソッドを再利用して通知を処理する

このアプローチでは、既存のメッセージ処理インフラストラクチャを利用して重複を回避します。

メインアプリを更新して、色の選択に関する通知を接続

次に、lib/main.dart ファイルを変更して、色の選択通知関数をメイン画面に渡します。

lib/main.dart

import 'package:colorist_ui/colorist_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'providers/gemini.dart';
import 'services/gemini_chat_service.dart';

void main() async {
  runApp(ProviderScope(child: MainApp()));
}

class MainApp extends ConsumerWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final model = ref.watch(geminiModelProvider);
    final conversationState = ref.watch(conversationStateProvider);

    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: model.when(
        data: (data) => MainScreen(
          conversationState: conversationState,
          notifyColorSelection: (color) {                            // Add from here...
            ref.read(geminiChatServiceProvider).notifyColorSelection(color);
          },                                                         // To here.
          sendMessage: (text) {
            ref.read(geminiChatServiceProvider).sendMessage(text);
          },
        ),
        loading: () => LoadingScreen(message: 'Initializing Gemini Model'),
        error: (err, st) => ErrorScreen(error: err),
      ),
    );
  }
}

主な変更点は、UI イベント(履歴から色を選択する)を LLM 通知システムに接続する notifyColorSelection コールバックを追加することです。

システム プロンプトを更新する

次に、システム プロンプトを更新して、色の選択通知にどのように応答するかを LLM に指示する必要があります。assets/system_prompt.md ファイルを変更します。

assets/system_prompt.md

# Colorist System Prompt

You are a color expert assistant integrated into a desktop app called Colorist. Your job is to interpret natural language color descriptions and set the appropriate color values using a specialized tool.

## Your Capabilities

You are knowledgeable about colors, color theory, and how to translate natural language descriptions into specific RGB values. You have access to the following tool:

`set_color` - Sets the RGB values for the color display based on a description

## How to Respond to User Inputs

When users describe a color:

1. First, acknowledge their color description with a brief, friendly response
2. Interpret what RGB values would best represent that color description
3. Use the `set_color` tool to set those values (all values should be between 0.0 and 1.0)
4. After setting the color, provide a brief explanation of your interpretation

Example:
User: "I want a sunset orange"
You: "Sunset orange is a warm, vibrant color that captures the golden-red hues of the setting sun. It combines a strong red component with moderate orange tones."

[Then you would call the set_color tool with approximately: red=1.0, green=0.5, blue=0.25]

After the tool call: "I've set a warm orange with strong red, moderate green, and minimal blue components that is reminiscent of the sun low on the horizon."

## When Descriptions are Unclear

If a color description is ambiguous or unclear, please ask the user clarifying questions, one at a time.

## When Users Select Historical Colors

Sometimes, the user will manually select a color from the history panel. When this happens, you'll receive a notification about this selection that includes details about the color. Acknowledge this selection with a brief response that recognizes what they've done and comments on the selected color.

Example notification:
User: "User selected color from history: {red: 0.2, green: 0.5, blue: 0.8, hexCode: #3380CC}"
You: "I see you've selected an ocean blue from your history. This tranquil blue with a moderate intensity has a calming, professional quality to it. Would you like to explore similar shades or create a contrasting color?"

## Important Guidelines

- Always keep RGB values between 0.0 and 1.0
- Provide thoughtful, knowledgeable responses about colors
- When possible, include color psychology, associations, or interesting facts about colors
- Be conversational and engaging in your responses
- Focus on being helpful and accurate with your color interpretations

主な追加点は [ユーザーが過去の色を選択した場合] セクションです。このセクションでは、次のことを行います。

  1. 履歴選択通知のコンセプトを LLM に説明する
  2. これらの通知の例を示します
  3. 適切なレスポンスの例を表示する
  4. 選択を認識し、色についてコメントする際の期待値を設定します

これにより、LLM はこれらの特別なメッセージに適切に応答する方法を理解できます。

Riverpod コードを生成する

ビルドランナー コマンドを実行して、必要な Riverpod コードを生成します。

dart run build_runner build --delete-conflicting-outputs

LLM コンテキスト同期を実行してテストする

アプリケーションを実行します。

flutter run -d DEVICE

Colorist アプリのスクリーンショット。Gemini LLM がカラー履歴から選択された内容に回答している様子

LLM コンテキストの同期のテストには、次の手順が含まれます。

  1. まず、チャットで色を説明して、いくつかの色を生成します。
    • 「鮮やかな紫を見せて」
    • 「フォレスト グリーンがいい」
    • 「明るい赤にして」
  2. 履歴ストリップにある色のサムネイルのいずれかをクリックします。

次のような結果が表示されます。

  1. 選択した色がメインディスプレイに表示されます
  2. チャットにユーザー メッセージが表示され、色の選択が示される
  3. LLM は、選択を認識し、色についてコメントすることで応答します
  4. やり取り全体が自然でまとまりがある

これにより、LLM がダイレクト メッセージと UI 操作の両方を認識し、適切に応答するシームレスなエクスペリエンスが実現します。

LLM コンテキスト同期の仕組み

この同期の仕組みについて、技術的な詳細を見ていきましょう。

Data Flow

  1. ユーザー アクション: ユーザーが履歴ストリップの色をクリックする
  2. UI イベント: MainScreen ウィジェットがこの選択を検出します。
  3. コールバックの実行: notifyColorSelection コールバックがトリガーされます。
  4. メッセージの作成: 色データを含む特別な形式のメッセージが作成されます。
  5. LLM 処理: メッセージが Gemini に送信され、形式が認識されます。
  6. コンテキストに応じた回答: Gemini はシステム プロンプトに基づいて適切に応答します
  7. UI の更新: 回答がチャットに表示され、一貫性のあるエクスペリエンスが実現

データのシリアル化

このアプローチの重要な点は、カラーデータをシリアル化する方法です。

'User selected color from history: ${json.encode(color.toLLMContextMap())}'

toLLMContextMap() メソッド(colorist_ui パッケージで提供)は、ColorData オブジェクトを LLM が理解できるキー プロパティを含むマップに変換します。通常は次のようなメンバーで構成します。

  • RGB 値(赤、緑、青)
  • 16 進数コード表現
  • 色に関連付けられた名前または説明

このデータを一貫した形式でメッセージに含めることで、LLM が適切な応答に必要なすべての情報を確実に取得できます。

LLM コンテキスト同期の幅広いアプリケーション

この UI イベントに関する LLM への通知パターンは、色の選択以外にもさまざまな用途があります。

その他のユースケース

  1. フィルタの変更: ユーザーがデータにフィルタを適用したときに LLM に通知する
  2. ナビゲーション イベント: ユーザーが別のセクションに移動したときに LLM に通知します。
  3. 選択内容の変更: ユーザーがリストやグリッドからアイテムを選択したときに LLM を更新します
  4. 設定の更新: ユーザーが設定を変更したときに LLM に通知する
  5. データ操作: ユーザーがデータを追加、編集、削除したときに LLM に通知する

いずれの場合も、パターンは同じです。

  1. UI イベントを検出する
  2. 関連するデータをシリアル化する
  3. 特別な形式の通知を LLM に送信する
  4. システム プロンプトを使用して、LLM が適切に回答するように誘導する

LLM コンテキスト同期のベスト プラクティス

実装に基づいて、効果的な LLM コンテキスト同期のためのベスト プラクティスをいくつか紹介します。

1. 一貫性のある形式

LLM が簡単に識別できるように、通知の形式を統一します。

"User [action] [object]: [structured data]"

2. 豊富なコンテキスト

LLM がインテリジェントに応答できるように、通知に十分な詳細を含めます。色の場合、これは RGB 値、16 進数コード、その他の関連するプロパティを意味します。

3. 明確な指示

通知の処理方法について、システム プロンプトで明確な指示を(できれば例を挙げて)提供します。

4. 自然な統合

通知は、技術的な中断ではなく、会話の流れに自然に沿うように設計します。

5. 選択的通知

会話に関連するアクションのみを LLM に通知します。すべての UI イベントを通知する必要はありません。

トラブルシューティング

通知に関する問題

LLM が色の選択に適切に応答しない場合:

  • 通知メッセージの形式がシステム プロンプトで説明されている形式と一致していることを確認する
  • 色データが正しくシリアル化されていることを確認する
  • 選択を処理するための明確な指示がシステム プロンプトに含まれていることを確認する
  • 通知の送信時にチャット サービスでエラーが発生していないか確認する

コンテキスト管理

LLM がコンテキストを失ったと思われる場合:

  • チャット セッションが適切に維持されていることを確認する
  • 会話の状態が正しく移行することを確認する
  • 通知が同じチャット セッションで送信されていることを確認する

全般的な問題

一般的な問題の場合:

  • ログでエラーや警告を確認する
  • Firebase AI Logic の接続を確認する
  • 関数パラメータの型が一致しないかどうかを確認する
  • Riverpod で生成されたすべてのコードが最新であることを確認する

学んだ主なコンセプト

  • UI と LLM 間の LLM コンテキスト同期の作成
  • UI イベントを LLM に適したコンテキストにシリアル化する
  • さまざまなインタラクション パターンに対する LLM の動作をガイドする
  • メッセージとメッセージ以外のやり取りで一貫したエクスペリエンスを実現する
  • LLM の広範なアプリケーション状態の認識を強化する

LLM コンテキスト同期を実装することで、LLM が単なるテキスト ジェネレータではなく、認識力があり、応答性の高いアシスタントのように感じられる、真に統合されたエクスペリエンスが実現しました。このパターンは、他の無数のアプリケーションに適用して、より自然で直感的な AI 搭載インターフェースを作成できます。

9. 完了

これで、Colorist Codelab は終了です。🎉

作成した内容

Google の Gemini API を統合して自然言語の色説明を解釈する、完全に機能する Flutter アプリケーションを作成しました。アプリで次のことが可能になりました。

  • 「夕焼けのオレンジ」や「深海の青」などの自然言語の説明を処理する
  • Gemini を使用して、これらの説明を RGB 値にインテリジェントに変換する
  • ストリーミング応答で解釈された色をリアルタイムで表示する
  • チャットと UI 要素の両方でユーザー操作を処理する
  • さまざまなインタラクション方法でコンテキスト認識を維持する

次のステップ

Gemini と Flutter の統合の基本を習得したら、次の方法で学習を続けることができます。

Colorist アプリを強化する

  • カラーパレット: 補色または一致する配色を生成する機能を追加
  • 音声入力: 色の説明を音声で入力するための音声認識を統合
  • 履歴の管理: カラーセットの名前付け、整理、エクスポートのオプションを追加
  • カスタム プロンプト: ユーザーがシステム プロンプトをカスタマイズするためのインターフェースを作成します
  • 高度な分析: どの商品説明が最も効果的か、または問題を引き起こしているかを追跡します。

Gemini のその他の機能を確認する

  • マルチモーダル入力: 写真から色を抽出するための画像入力を追加
  • コンテンツの生成: Gemini を使用して、説明やストーリーなどの色に関連するコンテンツを生成します。
  • 関数呼び出しの強化: 複数の関数を使用して、より複雑なツール統合を作成する
  • 安全性の設定: さまざまな安全性の設定と、それらが回答に与える影響について説明します。

これらのパターンを他のドメインに適用する

  • ドキュメント分析: ドキュメントを理解して分析できるアプリを作成する
  • クリエイティブ ライティング支援: LLM を活用した提案でライティング ツールを構築する
  • タスクの自動化: 自然言語を自動化されたタスクに変換するアプリを設計する
  • 知識ベースのアプリケーション: 特定のドメインでエキスパート システムを作成する

リソース

学習を続けるのに役立つリソースをいくつかご紹介します。

公式ドキュメント

プロンプト コースとガイド

コミュニティ

Observable Flutter Agentic シリーズ

エピソード 59 では、Craig Labenz と Andrew Brogden がこの Codelab を取り上げ、アプリのビルドの興味深い部分を紹介しています。

エピソード #60 では、Craig と Andrew が再び登場し、Codelab アプリに新機能を追加し、LLM に指示どおりに動作させるのに苦労する様子を紹介します。

エピソード #61 では、Craig が Chris Sells とともに、ニュースの見出しを分析して対応する画像を生成する新しいアプローチについて解説します。

フィードバック

この Codelab について、ぜひご意見をお聞かせください。フィードバックは、以下の方法でお寄せください。

この Codelab を完了していただき、ありがとうございました。Flutter と AI の交差点で生まれるエキサイティングな可能性を今後も探求していただければ幸いです。