てけとーぶろぐ。

ソフトウェアの開発と、お絵かきと、雑記と。

Python + Flet + Gemini API でAIチャットアプリを作る

前回はコンソールアプリとしてAIチャットを作りました。

kurima.hatenablog.com

このAIチャットにFletを使ってGUIをつけてみます。

FletはPythonで書けるFlutterと言えば伝わるでしょうか?

自分は何年も前からPythonでデスクトップアプリを組むには何を使ったらいいのかと悩みあれこれ眺めたり少し触ったりしてきました。

やっぱりTkinterなのかなぁなどと思いつつも、整った画面を作るのにノウハウが必要なところに違和感を感じていました。かといって広く使われていないものも使う気がしないといった状態でした。

そんなときにFletを見つけ、やっと腰を据えて使えるものが来たかー?と感じました。

ちなみにStreamlitも、こりゃすごい、と思ったのですけど、自由度が少し足りないんですよね…。そこを犠牲にして思い切り使いやすくしているというものだと思うので仕方ないと思います。

Fletやらの感想はこのくらいにしてアプリを作っていきます。プロジェクトの作成、APIキーの取得、プログラムからのAPIキーの参照については前回と同じなので詳しくは前回をご参照ください

プロジェクトの作成

プロジェクトを作成します。

$ rye init gemini-chat
$ cd gemini-chat
$ rye sync
パッケージのインストール

Gemini API 用の Python SDK が含まれる google-generativeai パッケージ、GUIの作成に使うfletパッケージをインストールします。

$ rye add google-generativeai
$ rye add flet
$ rye sync
Pythonファイルの作成

GUIのチャットアプリを作るわけですけど、ちょうどFletのチュートリアルにチャットアプリがありましたのでこちらをベースとして使わせてもらいたいと思います。

flet.dev

以下のPythonファイルを作成します。 gemini-chat/src/gemini_chat/main.py

そしてまずは以下にあるチュートリアルの最終的なコードをそのままコピーしてmain.pyの内容とします。

github.com

.env ファイルの作成

gemini-chat/.env を以下の内容で作成します。("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"の部分は取得したAPIキー) 作成の理由は前回を参照してください。

GOOGLE_API_KEY="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
.gitignore ファイル編集

gemini-chat/.gitignore に以下を追記します。 編集の理由は前回を参照してください。

# env
.env
pyproject.toml ファイルの編集

.envファイルから環境変数に値をセットしてプログラムの実行をするような スクリプトを gemini-chat/pyproject.toml に定義します。 pyproject.tomlに以下を追記します。 Fletを使ったアプリなのでfletコマンドで実行します。

[tool.rye.scripts]
run-main = { cmd = "flet run src/gemini_chat/main.py", env-file = ".env" }
プログラムの実行

以下のコマンドでプログラムを実行します。

$ rye run run-main

プログラムを実行するとFletのチュートリアルのチャットが表示されます。 これに少し手を加えてAIチャットにしましょう。

ユーザー名入力処理の削除

AIチャットでユーザー名の入力を求められるのは煩わしいのでユーザー名入力の処理を削除して固定のユーザー名となるようにしましょう。

ユーザー名入力のダイアログの「Join chat」ボタンをクリックしたときの処理である main() の中の join_chat_click() を削除します。具体的には以下のコードを削除します。

    def join_chat_click(e):
        if not join_user_name.value:
            join_user_name.error_text = "Name cannot be blank!"
            join_user_name.update()
        else:
            page.session.set("user_name", join_user_name.value)
            page.dialog.open = False
            new_message.prefix = ft.Text(f"{join_user_name.value}: ")
            page.pubsub.send_all(
                Message(
                    user_name=join_user_name.value,
                    text=f"{join_user_name.value} has joined the chat.",
                    message_type="login_message",
                )
            )
            page.update()

ユーザー名入力のダイアログの表示の処理を削除します。具体的には main() の中盤にある以下のコードを削除します。

    # A dialog asking for a user display name
    join_user_name = ft.TextField(
        label="Enter your name to join the chat",
        autofocus=True,
        on_submit=join_chat_click,
    )
    page.dialog = ft.AlertDialog(
        open=True,
        modal=True,
        title=ft.Text("Welcome!"),
        content=ft.Column([join_user_name], width=300, height=70, tight=True),
        actions=[ft.ElevatedButton(text="Join chat", on_click=join_chat_click)],
        actions_alignment=ft.MainAxisAlignment.END,
    )

固定のユーザー名を設定する処理を追加します。main() の最後に以下のコードを追加します。

    user_name = "You"
    page.session.set("user_name", user_name)
    new_message.prefix = ft.Text(f"{user_name}: ")
    page.pubsub.send_all(
        Message(
            user_name=user_name,
            text=f"{user_name} has joined the chat.",
            message_type="login_message",
        )
    )
    page.update()
Gemini APIのChatSession作成処理の追加

Gemini APIのChatSessionを作成する処理を追加します。main() の最後に以下のコードを追加します。

エラーメッセージの表示にアラートダイアログを使うようにしただけでChatSessionの作成自体は前回と同じです。

    GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY")
    chat_session = None

    if GOOGLE_API_KEY is not None:
        genai.configure(api_key=GOOGLE_API_KEY)

        model = genai.GenerativeModel("gemini-1.5-flash")

        chat_session = model.start_chat(history=[])
    else:

        def handle_close(e):
            print("handle_close()")
            page.dialog.open = False
            page.update()

        page.dialog = ft.AlertDialog(
            open=True,
            modal=True,
            title=ft.Text("エラー"),
            content=ft.Text("環境変数 GOOGLE_API_KEY がセットされていません"),
            actions=[
                ft.TextButton("OK", on_click=handle_close),
            ],
            actions_alignment=ft.MainAxisAlignment.END,
        )

main.py の先頭に import も追加します。

import google.generativeai as genai
import os
AIによる返答処理の追加

AIによる返答処理を追加します。 ユーザーからのメッセージを受けてAIからのメッセージを作成・送信し、AIからのメッセージを受けた場合もユーザーからメッセージを受けた場合と同じように処理します。 そのために main() の中の on_message() を編集します。以下の on_message() を

    def on_message(message: Message):
        if message.message_type == "chat_message":
            m = ChatMessage(message)
        elif message.message_type == "login_message":
            m = ft.Text(message.text, italic=True, color=ft.colors.BLACK45, size=12)
        chat.controls.append(m)
        page.update()

以下のように変更します。

先頭の if に "ai_chat_message" の場合が加わり、末尾にAIからのメッセージ作成・送信が加わっています。

    def on_message(message: Message):
        if (
            message.message_type == "chat_message"
            or message.message_type == "ai_chat_message"
        ):
            m = ChatMessage(message)
        elif message.message_type == "login_message":
            m = ft.Text(message.text, italic=True, color=ft.colors.BLACK45, size=12)

        chat.controls.append(m)
        page.update()

        if message.message_type == "chat_message" and chat_session is not None:
            response = chat_session.send_message(message.text)
            page.pubsub.send_all(
                Message(
                    "AI",
                    response.text,
                    message_type="ai_chat_message",
                )
            )
プログラムの再実行

再度以下のコマンドでプログラムを実行します。

$ rye run run-main

このようにAIとチャットできます。

スクリーンショット

それっぽくなってきました。

最終的な全体のコードを載せておきます。

import flet as ft
import google.generativeai as genai
import os


class Message:
    def __init__(self, user_name: str, text: str, message_type: str):
        self.user_name = user_name
        self.text = text
        self.message_type = message_type


class ChatMessage(ft.Row):
    def __init__(self, message: Message):
        super().__init__()
        self.vertical_alignment = ft.CrossAxisAlignment.START
        self.controls = [
            ft.CircleAvatar(
                content=ft.Text(self.get_initials(message.user_name)),
                color=ft.colors.WHITE,
                bgcolor=self.get_avatar_color(message.user_name),
            ),
            ft.Column(
                [
                    ft.Text(message.user_name, weight="bold"),
                    ft.Text(message.text, selectable=True),
                ],
                tight=True,
                spacing=5,
            ),
        ]

    def get_initials(self, user_name: str):
        if user_name:
            return user_name[:1].capitalize()
        else:
            return "Unknown"  # or any default value you prefer

    def get_avatar_color(self, user_name: str):
        colors_lookup = [
            ft.colors.AMBER,
            ft.colors.BLUE,
            ft.colors.BROWN,
            ft.colors.CYAN,
            ft.colors.GREEN,
            ft.colors.INDIGO,
            ft.colors.LIME,
            ft.colors.ORANGE,
            ft.colors.PINK,
            ft.colors.PURPLE,
            ft.colors.RED,
            ft.colors.TEAL,
            ft.colors.YELLOW,
        ]
        return colors_lookup[hash(user_name) % len(colors_lookup)]


def main(page: ft.Page):
    page.horizontal_alignment = ft.CrossAxisAlignment.STRETCH
    page.title = "Flet Chat"

    def send_message_click(e):
        if new_message.value != "":
            page.pubsub.send_all(
                Message(
                    page.session.get("user_name"),
                    new_message.value,
                    message_type="chat_message",
                )
            )
            new_message.value = ""
            new_message.focus()
            page.update()

    def on_message(message: Message):
        if (
            message.message_type == "chat_message"
            or message.message_type == "ai_chat_message"
        ):
            m = ChatMessage(message)
        elif message.message_type == "login_message":
            m = ft.Text(message.text, italic=True, color=ft.colors.BLACK45, size=12)

        chat.controls.append(m)
        page.update()

        if message.message_type == "chat_message" and chat_session is not None:
            response = chat_session.send_message(message.text)
            page.pubsub.send_all(
                Message(
                    "AI",
                    response.text,
                    message_type="ai_chat_message",
                )
            )

    page.pubsub.subscribe(on_message)

    # Chat messages
    chat = ft.ListView(
        expand=True,
        spacing=10,
        auto_scroll=True,
    )

    # A new message entry form
    new_message = ft.TextField(
        hint_text="Write a message...",
        autofocus=True,
        shift_enter=True,
        min_lines=1,
        max_lines=5,
        filled=True,
        expand=True,
        on_submit=send_message_click,
    )

    # Add everything to the page
    page.add(
        ft.Container(
            content=chat,
            border=ft.border.all(1, ft.colors.OUTLINE),
            border_radius=5,
            padding=10,
            expand=True,
        ),
        ft.Row(
            [
                new_message,
                ft.IconButton(
                    icon=ft.icons.SEND_ROUNDED,
                    tooltip="Send message",
                    on_click=send_message_click,
                ),
            ]
        ),
    )

    user_name = "You"
    page.session.set("user_name", user_name)
    new_message.prefix = ft.Text(f"{user_name}: ")
    page.pubsub.send_all(
        Message(
            user_name=user_name,
            text=f"{user_name} has joined the chat.",
            message_type="login_message",
        )
    )
    page.update()

    GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY")
    chat_session = None

    if GOOGLE_API_KEY is not None:
        genai.configure(api_key=GOOGLE_API_KEY)

        model = genai.GenerativeModel("gemini-1.5-flash")

        chat_session = model.start_chat(history=[])
    else:

        def handle_close(e):
            print("handle_close()")
            page.dialog.open = False
            page.update()

        page.dialog = ft.AlertDialog(
            open=True,
            modal=True,
            title=ft.Text("エラー"),
            content=ft.Text("環境変数 GOOGLE_API_KEY がセットされていません"),
            actions=[
                ft.TextButton("OK", on_click=handle_close),
            ],
            actions_alignment=ft.MainAxisAlignment.END,
        )


ft.app(target=main)