😸

Mesopの基本コードとGradioとの比較

始めに

マシンラーニング・ソリューションズアルバイトの吉田岳史です。機械学習を用いたPythonによるAIアプリの構築、共有を行う際にはGradioというフレームワークが有名です。本ブログでは新たにGoogleが開発したAIアプリ開発に適したフレームワークであるMesopを解説します。本ブログを読むことで、Mesopの始め方や簡単な使い方、そして、Gradioとの違いを理解することが目的です。

Mesopとは

MesopのGitHubに

Create web apps without the complexity of frontend development.
Used at Google for rapid AI app development.
とあるように、AIのアプリ開発に使えるフレームワークです。

Mesopの構文は既存のPythonの構文と似ており、また、各コンポーネントの配置がCSSをもとにできるという利点があります。

インストール方法

pip install mesop

pipコマンドを使ってインストールできます。詳細はこのページに書いてあります。

使用方法

最初のHello World

Hello Worldをブラウザに表示することから始めましょう。main.pyという名前で以下のファイルを作成します。

mesop-hello-world.py
import mesop as me

@me.page(path="/")
def app():
  me.text("Hello World")

その後、コマンドラインにおいてmesop main.pyと打ちます。
mesop main.pyはMesopを使って、main.pyを起動する意味のコマンドになっています。
Mesopを使ってHello Worldを表示するときのターミナル上での応答
ターミナル上でのコマンド入力
コマンドライン上で上記のような応答が出てきます。localhost:32123にアクセスして、以下のような画面が出てきたら成功です。
Mesopを使ってHello Worldを表示するときのWebページ上の結果
Webページへの出力
一部変更されていますが、上記のコードは公式GitHubに記載されています。

  • Every Mesop app starts with import mesop as me. This is the only recommended way to import mesop, otherwise your app may break in the future because you may be relying on internal implementation details.
  • @me.page is a function decorator which makes a function a root component for a particular path. If you omit the path parameter, this is the equivalent of @me.page(path="/").
  • app is a Python function that we will call a component because it's creating Mesop components in the body.

上記のようにMesopを用いたアプリでは、まずimport mesop as me でMesopをインポートすることが推奨されています。また、@meから始まる部分は関数デコレーターと呼ばれる部分です。今回使用している@me.page(path=”/”) は/以下のurlにアクセスした際に表示する関数を指定します。この関数デコレーターがついている関数appはMesopのComponentと呼ばれる部分です。

Mesopにおける概念

Component とは

Componentとは上記のコード内にある関数appのように、アプリケーションのブロックです。Mesopによるアプリケーションは本質的にはこのブロックの木構造によって構成されます。

State とは

Stateはアプリケーションのひとつのセッションごとの状態を保持するクラスです。

Gradioとの比較

Gradioと比べてMesopを使う利点は主に学習コストの低さです。上記で上げた概念のStateを使うことでWebアプリに必要なボタンやテキストボックスなどの実装が直感的に行えます。また、これらのコンポーネントの配置がCSSをベースに行えます。PythonとCSSの基礎知識がある程度ある人にとってはMesopを始めるメリットがありそうです。

一方でGradioの利点は以下の部分が挙げられます。

  • demo appの共有が容易
  • 日本語記事が多い
  • 現状の使用率が高い

現状では、Mesopを使用する場合には基本的に英語の公式ドキュメントを読む必要があります。一方でGradioで開発を行う場合には日本語でのアプリ開発の記事を参考にすることもできます。

コードによる比較

テキストの入出力を行うコード

GradioとMesopそれぞれにおいてのコードの比較を行います。まずはテキストの入力、そして、入力されたテキストを表示するコードをそれぞれ実装してみましょう。まずはGradioを使用して実装してみます。上からコード、起動時の画面です。

gradio-textbox.py
import gradio as gr

def display(text: str) -> str:
  return "Hello " + text + "!"

with gr.Blocks() as demo:
  text_input = gr.Textbox(label = "input")
  output = gr.Textbox(label = "output")
  text_input.change(display, inputs = text_input, outputs = output)

if __name__ == "__main__":
  demo.launch()

Gradioを用いて作成したテキストの入出力を行うWebページ
Gradioによるテキスト入出力の画像
続いて、Mesopを使用して同様の機能のものを実装します。

mesop-textbox.py
import mesop as me

@me.stateclass
class State:
  input: str

def on_input(e: me.InputEvent):
  state = me.state(State)
  state.input = e.value

@me.page(path = "/")
def app():
  state = me.state(State)
  me.input(label = "input", on_input = on_input)
  me.text(f"Hello {state.input}!" if state.input else "")

これを実行すると以下のような結果が得られます。
Mesopを用いて作成したテキストの入出力を行うWebページ
Mesopによるテキスト入出力の画像
実装の違いとして一番大きな部分は受け取った入力の保存の部分です。Gradioでは受け取った値はtext_inputという変数に保存された後、displayという関数を通したのちにoutputのtext boxに渡されています。一方Mesopでは、受け取った値は一度Stateのクラスに保存され、その値がtextボックスに渡されています。

また、見た目のフォーマットとしても何も改善しない場合にはMesopのほうが少し簡素なつくりになっていることがわかります。

Mesop を用いたAIアプリ (ChatGPT)

先に挙げているようにMesopを使うことで効率的にAIアプリを作成することができます。

公式GitHubチュートリアルではGeminiとAnthropic AIを使用したチャットボットの開発ができます。ここでは公式チュートリアルのコードを参考にして、OpenAIのChatGPTとMesopを利用してChatBotを作成してみます。

今回のChatBotはmain.py, openai_prompt.py, data_model.pyの三つのファイルから構成されています。

main.py
import mesop as me
from data_model import State, ChatMessage
from openai_prompt import send_prompt

def header():
  def navigate_home(e: me.ClickEvent):
    me.navigate("/")
    state = me.state(State)
    state.conversations = []

  with me.box(
    on_click=navigate_home,
    style=me.Style(
      cursor="pointer",
      padding=me.Padding.all(16),
    ),
  ):
    me.text(
      "AIChat",
      style=me.Style(
        font_weight=500,
        font_size=24,
        color="#3D3929",
        letter_spacing="0.3px",
      ),
    )
    
def on_blur(e: me.InputBlurEvent):
  state = me.state(State)
  state.input = e.value

def chat_input():
  state = me.state(State)
  with me.box():
    me.native_textarea(
      value = state.input,
      placeholder = "Enter a prompt",
      on_blur = on_blur,
      style=me.Style(
        padding=me.Padding(top=16, left=16),
        outline="none",
        width="100%",
        border=me.Border.all(me.BorderSide(style="none")),
      ),
    )
  with me.content_button(
    type = "icon", on_click = send_prompt,
  ):
    me.icon("send")

@me.page(path = "/")
def app():
  header()
  with me.box(
    style=me.Style(
      width="min(680px, 100%)",
      margin=me.Margin.symmetric(horizontal="auto", vertical=36),
    )
  ):
    chat_input()
    display_conversations()

def display_conversations():
    state = me.state(State)
    for chat in state.conversations:
      if chat.role == "system": continue
      with me.box(style=me.Style(margin=me.Margin(bottom=24))):
        display_message(chat)

def display_message(message: ChatMessage):
  style = me.Style(
    padding=me.Padding.all(12),
    border_radius=8,
    margin=me.Margin(bottom=8),
  )
  if message.role == "user":
    style.background = "#e7f2ff"
  else:
    style.background = "#ffffff"

  with me.box(style=style):
    me.markdown(message.content)
    if message.in_progress:
      me.progress_spinner()
openai_prompt.py
import mesop as me
from openai import OpenAI
from data_model import ChatMessage, State
import os

from dotenv import load_dotenv, find_dotenv

load_dotenv(find_dotenv())
client = OpenAI(
  api_key = os.environ.get("OPENAI_API_KEY")
)

def chatmessage2json(data: ChatMessage):
  return {"role" : data.role, "content" : data.content}


def send_prompt(e: me.ClickEvent):
  state = me.state(State)
  if not state.conversations:
    state.conversations.append(ChatMessage(role = "system", content = "You are a helpful assistant"))
  input = state.input
  messages = state.conversations
  messages.append(ChatMessage(role="user", content = input, in_progress = True))
  history = []
  for conversation in state.conversations:
    history.append(chatmessage2json(conversation))
  response = client.chat.completions.create(
    messages = history,
    model = state.model
  )
  message = response.choices[0].message.content
  messages[-1].in_progress = False
  messages.append(ChatMessage(role="assistant", content = message))
data_model.py
#data_model.py
from dataclasses import dataclass, field
from typing import Literal

import mesop as me

Role = Literal["user", "assistant", "system"]

@dataclass(kw_only=True)
class ChatMessage:
  role: Role = "user"
  content: str = ""
  in_progress: bool = False

@me.stateclass
class State:
  input: str = ""
  conversations: list[ChatMessage] = field(default_factory=list)
  model: str = "gpt-4o-mini"

コードの実行のみを確認したい方は、これらのコードを同一ディレクトリ上にコピーしてコマンドラインにて mesop main.pyと実行することで動作できます。このコードはmesopに加えて、python-dotenv, openaiを使用しています。コマンド上で

pip install python-dotenv

pip install openai

を実行して、python-dotenv, openaiをインストールしてください。また、このコードはOpenAI APIを使用しています。API KEYを未取得の場合はOpenAI Platformよりアカウントを作成してAPI KEYの生成を行ってください。作成したAPI KEYはmain.pyと同一ディレクトリに.envというファイルを作成します。そして、以下の例のようにファイル内にAPI KEYを記載します。YOUR_API_KEYの部分を作成したAPI KEYに対して置き換えてください。

.env
OPENAI_API_KEY=YOUR_API_KEY

demoアプリのWebページ上での実行画面
demoアプリの実行画面

main.pyに関しての説明

まずは、main.pyの内容に関して詳しく説明していきます。
ここに出てくるコンポーネントは app関数、header関数、chat_input関数、display_conversations関数、display_message関数の5つがあります。先に説明した通りMesopによるアプリはコンポーネントの木構造となっています。今回の例では以下の画像のように、appがheader, chat_input, display_conversationsを子とする木構造の根になっています。
demoアプリのコンポーネントの木構造
demoアプリの構造図

app関数について

実際にapp関数の中でheader関数, chat_input関数、display_conversations関数を表示していることが関数の内部を見てみるとわかります。また、display_conversations関数の内部ではメッセージの伝達回数分、display_message関数を表示しています。

@me.page(path = "/")
def app():
  header()
  with me.box(
    style=me.Style(
      width="min(680px, 100%)",
      margin=me.Margin.symmetric(horizontal="auto", vertical=36),
    )
  ):
    chat_input()
    display_conversations()

app関数の先頭のpathの部分ではurlの指定をしています。また、関数内部ではchat_input関数 display_conversations関数に対してCSSを適用させています。with me.box以降のchat_input(), display_conversations()の部分をインデントすることで、CSSの適用範囲を指定しています。

header関数について

def header():
  def navigate_home(e: me.ClickEvent):
    me.navigate("/")
    state = me.state(State)
    state.conversations = []

  with me.box(
    on_click=navigate_home,
    style=me.Style(
      cursor="pointer",
      padding=me.Padding.all(16),
    ),
  ):
    me.text(
      "AIChat",
      style=me.Style(
        font_weight=500,
        font_size=24,
        color="#3D3929",
        letter_spacing="0.3px",
      ),
    )

このheader関数の内部ではまず、navigate_home関数を定義しています。me.navigate(url)は呼び出されたときにurlに遷移します。また、state.conversations = []の部分ではホーム画面に遷移した場合には履歴が消すようにしています。また、me.text()の文字をクリックされた際に上記のnavigate_home()が呼ばれるようになっています。

左上のAIChatの部分がこの関数によって生成されています。

chat_input関数について

def on_blur(e: me.InputBlurEvent):
  state = me.state(State)
  state.input = e.value

def chat_input():
  state = me.state(State)
  with me.box():
    me.native_textarea(
      value = state.input,
      placeholder = "Enter a prompt",
      on_blur = on_blur,
      style=me.Style(
        padding=me.Padding(top=16, left=16),
        outline="none",
        width="100%",
        border=me.Border.all(me.BorderSide(style="none")),
      ),
    )
  with me.content_button(
    type = "icon", on_click = send_prompt,
  ):
    me.icon("send")

chat_input関数は以下の写真の部分を表示しています。
demoアプリの入力部分
テキストを入力するエリアに関してはme.native_textarea()で実装されています。ここでは、on_blurの関数を定義することでテキストエリアへのフォーカスがなくなったタイミングでstate.inputにテキストエリアへと入力されたテキストが代入されます。

その下の紙飛行機マークのボタンはme.content_button()によって定義されています。このボタンを押すとopenai_prompt.pyの内部にあるsend_prompt関数が呼ばれます。

display_conversations関数について

def display_conversations():
    state = me.state(State)
    for chat in state.conversations:
      if chat.role == "system": continue
      with me.box(style=me.Style(margin=me.Margin(bottom=24))):
        display_message(chat)

def display_message(message: ChatMessage):
  style = me.Style(
    padding=me.Padding.all(12),
    border_radius=8,
    margin=me.Margin(bottom=8),
  )
  if message.role == "user":
    style.background = "#e7f2ff"
  else:
    style.background = "#ffffff"

  with me.box(style=style):
    me.markdown(message.content)
    if message.in_progress:
      me.progress_spinner()

display_conversations関数はuser と assistantの会話の履歴を表示する部分です。stateの内部にはすべての会話の記録が残っているのでそれをループで表示しています。システムプロンプトとしてAPIに渡しているプロンプトは会話の記録として表示したくないので、role = “system”のときのみループをそのまま継続させています。

ひとつひとつのメッセージに関してはdisplay_message関数のほうで表示しています。この関数の内部ではCSSのスタイルを先に定義してしまっています。そして、メッセージがuser側なのか、assistant側なのかということで背景色を変えています。その下のmessage.in_progressではuserが入力をしてAPIの応答を待っている間、渦巻マークを表示するようにしています。

openai_prompt.pyに関しての説明

この関数の内部では主にOpenAI APIを使って、会話の応答を得ています。

import mesop as me
from openai import OpenAI
from data_model import ChatMessage, State
import os

from dotenv import load_dotenv, find_dotenv

load_dotenv(find_dotenv())
client = OpenAI(
  api_key = os.environ.get("OPENAI_API_KEY")
)

def chatmessage2json(data: ChatMessage):
  return {"role" : data.role, "content" : data.content}


def send_prompt(e: me.ClickEvent):
  state = me.state(State)
  if not state.conversations:
    state.conversations.append(ChatMessage(role = "system", content = "You are a helpful assistant"))
  input = state.input
  messages = state.conversations
  messages.append(ChatMessage(role="user", content = input, in_progress = True))
  history = []
  for conversation in state.conversations:
    history.append(chatmessage2json(conversation))
  response = client.chat.completions.create(
    messages = history,
    model = state.model
  )
  message = response.choices[0].message.content
  messages[-1].in_progress = False
  messages.append(ChatMessage(role="assistant", content = message))

前半の関数内部に書かれていない部分は主にapi_keyに関連した部分ですね。send_prompt関数の内部実際にAPIをたたいて会話の応答を得ています。ここで、historyと別で会話の履歴を定義しているのは、state内部の会話履歴の記録の方法ではJSON化できずmessageをOpenAIに渡せないからです。stateでの記録方法は公式チュートリアルの方法を参考にしています。

data_model.pyに関しての説明

from dataclasses import dataclass, field
from typing import Literal

import mesop as me

Role = Literal["user", "assistant", "system"]

@dataclass(kw_only=True)
class ChatMessage:
  role: Role = "user"
  content: str = ""
  in_progress: bool = False

@me.stateclass
class State:
  input: str = ""
  conversations: list[ChatMessage] = field(default_factory=list)
  model: str = "gpt-4o-mini"

ここでは、Mesopのセッションの記録を保持するStateクラスとチャットメッセージのクラスについて定義しています。チャットメッセージに関しては、チャットの内容とそのチャットが誰から送られているのか、そして、そのチャットメッセージが返答されているのかという部分を持っています。会話の応答待ちのための渦巻マークの表示はこのin_progressの状態をもとに判断されています。

また、Stateクラス内部のmodelの部分を変更すれば使用するモデルを変更することができます。

まとめ

本ブログではMesopを用いたHello World、Gradioとの比較、OpenAIをつかった簡単なChatBotの作成までを行いました。Mesop自体日本語記事が少ないというデメリットはありますが、書きやすいPythonのフレームワークです。本ブログが開発時のツールの選択やバグの解消などの一助になれば幸いです。

Discussion