🎱

AIずんだもんと遊べるChrome拡張をPlasmo + OpenAIで作ってみた

に公開

こんにちは👋

無機質なデスクトップをずっと触ったまま作業するのは非常に苦痛なことで、なんだか可愛いものが画面に欲しくなります。
そんな願いを叶えるために、ブラウザ上で可愛く動く「AIずんだもん」を召喚できるChrome拡張を作ってみました。

https://x.com/tosuri13/status/1929119979498049702

https://github.com/874wokiite/ai-secretary-zundamon

Chrome拡張のコアとなるフレームワークにPlasmo、AIずんだもんで利用する基盤モデルにはOpenAIのgpt-4oを使用しています。

今回は「AIずんだもん」の詳しい実装や技術的なトピックについて紹介していこうと思います。

このChrome拡張機能は@874wokiiteと一緒に作りました。デザインやコンセプトなどについては、こちらの記事を見てみてください。

https://note.com/kanon_fujita/n/n8249bb2c63fe

何ができるの?

① AIずんだもんとお喋りができる!!

心ゆくまま、ずんだもんとお喋りすることができます。

特に捻ったことはしていませんが、ただAIとやり取りするだけのチャットアプリと違い「オレは今ずんだもんと話しているんだ!!」という気持ちになれます。

(レスポンスが返ってくるまで、考えるポーズをしてくれるのが可愛い)

② AIずんだもんに予定をお知らせしてもらえる!!

公式ドキュメントと睨めっこしてたら、気づかずに大事な予定をすっぽかしてしまった...みたいなこと、よくあると思います。そんな悲惨なことにならないよう、ずんだもんがリマインドしてくれる機能があります!!

Googleカレンダーと連携することで、予定が始まる前に下からずんだもんがニュッと出てきてリマインドしてくれます。

その日の忙しさによっては、リマインダーの設定時に同情されたりすることもあります。

③ AIずんだもんから話しかけてもらえる!?

ずっと画面を見ていると、たまにずんだもんが話しかけてくれます。

予定の合間を縫っていい感じのタイミングで話しかけてくれるので、集中している作業の邪魔になることもあんまりないです。

AIずんだもんの仕組み

フローの一部を紹介

全ての機能について紹介するのは大変なので、AIずんだもんのリマインダー機能について、フローを少し紹介しようと思います。

AIずんだもんは、UI描画を担当するContent Scriptsとバックグラウンド処理を行うBSWのやり取りで構成されています(Content ScriptsとBSWについては後ほど詳しく説明します)。

Google Calendar APIで取得された今日の予定を生成AIに投げ、リマインダーセット時のコメントや予定の合間に表示されるフレーズ、出現するタイミングを生成します。

リマインダーや生成したフレーズは、BSWでセットしたアラームの発火をトリガーに、ユーザが見ているページ上に描画されます。基本的にはどの機能もこんな仕組みで成り立っています。

Plasmoについて

Chrome拡張の中で生成AIを使うには少々面倒な部分があります。それは、PopupやContent Scriptsから直接生成AIプロバイダーが提供するAPIを呼べないということです。

詳しく説明すると、前提としてChrome拡張には大きく分けて以下の3つの世界があります。

  • Popup

    • 右上にあるChrome拡張のアイコンを押したときに表示されるUI部分です
    • 有名なものだと「Wappalyzer」の分析結果が描画される部分とかですね
  • Content Scripts

    • 表示されているページ上で処理を実行することができるスクリプトです
    • コンテンツの取得やDOMの操作などを行うことができます
  • Background Service Worker (BSW)

    • バックグラウンドで処理を実行することができるスクリプトです
    • タイマーやイベント管理、外部APIとの通信などを行うことができます

https://qiita.com/sakaimo/items/416f36db1aa982d8d00c

今回のChrome拡張では、ユーザが表示しているページ上にUIやずんだもんを描画するため、Content Scriptsが主に取り扱う範囲となっています。

一見Popupに見えるお喋りUIなども、Chrome拡張がクリックされたときのリスナーをBSWに設定することで、それをトリガーにContent Scriptsへ描画イベントを発行しています。

しかし、Content ScriptsからはCORSの問題で直接外部APIと通信を行うことができないため、メッセージを使ってBSWとやり取りする必要があります。

コードで書くとこんな感じです。

content.js
chrome.runtime.sendMessage({ type: "USE_API" }, (response) => {
  console.log(response);
});
background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "USE_API") {
    fetch(TARGET_URL)
      .then(res => res.json())
      .then(data => {
        sendResponse(data);
      });
    return true;
  }
});

中身が少ないのでそこまで見づらくはないのですが、宣言的に書ける方が嬉しいですし、TypeScriptで型の恩恵を受けるようなことは難しいです。

このような異なる世界間のやり取りを容易にするインターフェースを提供してくれているのがPlasmoです。実際にはReact + TypeScriptを使用したChrome拡張の作成を全体的にサポートしてくれるようなフレームワークです。

https://docs.plasmo.com

https://zenn.dev/nado1001/articles/plasmo-browser-extension

先ほどのコードはPlasmoを使うと、このように書くことができます。

content.ts
import { sendToBackground } from "@plasmohq/messaging"

const response = await sendToBackground({ name: "useApi" });
console.log(response)
background/messages/useAPI.ts
import type { PlasmoMessaging } from "@plasmohq/messaging"
 
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
  const message = await fetch(TARGET_URL)
  res.send({ message })
}
 
export default handler

いい感じですね。

あとは、これをそのままReactのhooksとして扱うことで、簡単にContent ScriptsのUI描画処理に統合することができます。AIずんだもんでも、生成AIのテキスト生成やGoogle Calendarの予定取得、リマインダーのセットなどでPlasmoのメッセージングAPIを利用しており、スムーズに開発を進めることができました。

また、manifest.jsonの自動作成やTailwind CSSとの統合など、Chrome拡張を開発する上で苦痛だった部分を広範囲にカバーしてくれています。嬉しいですね。

生成AIについて

AIずんだもんで使用しているモデルはgpt-4oです。
gpt-4oを使うメリットとしてStructured Outputが利用できるという点があります。

https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses

今回の例でいくと、生成AIが出力したリマインダーの内容をアラームとして渡しているのですが、指定したJSONの出力フォーマットを守ってくれない、あるいは不要な相槌や返事が挟まってしまうことがよくあります。

これはもちろんプロンプトベースで"ある程度"制御することはできますが、なるべく出力が確実に指定されたJSONフォーマットであることを担保できているのが望ましいです。

これを手軽に実現できるのがStructured Outputです。特にOpenAI公式のライブラリに含まれているヘルパー関数を使用すると、zodの型定義をそのまま出力の型として利用することができます。

background.ts
import { zodResponseFormat } from "openai/helpers/zod";
import { z } from "zod";

const responseScheme = z.object({
    phrases: z.array(
        z.object({
            comment: z.string(),
            time: z.string(),
        }),
    ),
});

...

const completion = await openai.beta.chat.completions.parse({
    model: "gpt-4o",
    messages: [
        {
            role: "system",
            content: `あなたにはこれから東北応援キャラクターである「ずんだもん」になりきってタスクを実行してもらいます...`
        },
        {
            role: "user",
            content: JSON.stringify(schedules),
        },
    ],
    response_format: zodResponseFormat(responseScheme, "data"),
});

記述するプロンプトは、ずんだもんのロール作りや制約表現などに注力できるのでオススメです。

ちなみに、Claudeファミリーでも似たようなことはできますが、ツールを用意しなければならないという手間があります。LangChainのwithStructuredOutputで出力の型を制御できるのは、内部で勝手にツールを宣言してくれているからです。

それ以外の部分について

ずんだもんの声が再生されたり、アニメーションが入ったり、ずんだもんのポーズが変わったり...色々機能はありますが、この辺りは特に手の込んだことはしていません。

ただ、Content Scriptsにおいては、Reactでよく使用されている便利ライブラリが使えないため、問題なく動作するか事前に確認しておく必要があります。

  • useSound

    • HTMLAudioElementを使ったローレベルな実装を強いられます
  • Framer Motion

    • 何故かReact Springは使えたので、AIずんだもんではこっちを使ってます
  • Tanstack QuerySWR

    • 状態管理系もダメです。useContextで頑張りましょう。

まとめ

ずんだもんと楽しくお喋りできる「AIずんだんもん」を紹介しました。

Plasmoを使うことで生成AIを活用したChrome拡張を簡単に作ることができるようになります。
Chrome拡張の開発に苦しんだ事のある方は、ぜひ使ってみてください。

Discussion