Google Meet に AIアドバイザーを同席させる

Web会議中に音声でGPTに質問して回答ともらえたらいいのに、と思ってプロダクトを探したところ、不思議となさそうだったので作ってみました。

![1280x800.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26825/db753a03-9728-79b1-b546-dbad49a0c1bd.png)

※ ここで紹介する方法は、自分のマイクからのAI呼び出しにのみ対応していて、他の参加者は呼び出しできません

# 構成

Google Meet にて Chrome拡張 から Speech-to-Text して（特定のキーワードを含む場合）Gemini の API を呼び出し、回答を Meet のメッセージ欄に出力します。

Chrome拡張は、Vite + Yarn v4 + TypeScript（Vanilla＝Vueなどのフレームワークなしの素のTS）で構築（ちなみに私の開発環境は mac です）。`tsc` はTypeチェックだけにし、TypeScript から JavaScript へのビルドは `vite build` を使います（Viteが生成した初期状態でそうなってる）。

# 前提知識：Chrome拡張の作り方

知っている方は読み飛ばしてください。

`manifest.json` に設定などを記述し、必要なJavaScriptファイルを含むフォルダをChromeに読み込ませると手元のPCで簡単に確認ができます。

| コンポーネント            | 説明                                               |
|-------------------------|----------------------------------------------------|
| popup.html<br/>popup.js | Chromeで拡張のアイコンをクリックしたときに呼ばれるファイル。<ul><li>拡張の設定などのViewを用意したり</li><li>アクションのトリガーとなるボタンを用意したりに使う</li></ul>※デバッグはpopupを右クリックしてデベロッパーツールを開く |
| content.js              | Chromeが開いているタブに**差し込まれる**JSファイル。<ul><li>表示しているページのDOMを読み込んでページに対する操作に使う</li></ul>※デバッグはページのデベロッパーツールを開く |
| background.js           | ServiceWorkerとして、Chromeを開いている間、Chrome拡張で一つ起動しているファイル。<ul><li>バックグラウンドで行いたい処理や</li><li>ページを開いている間の状態保存に使う</li></ul>※デバッグは拡張機能のページ（[chrome://extensions/](chrome://extensions/)）にて、`ビューを検証 Service Worker` でデベロッパーツールを開くか、エラーがあれば `エラー` ボタンが表示されるのでそこで確認できる

TypeScriptで作ってViteでビルドしますが、これらのファイルは別々に出力する必要があります。

また、もちろんファイル名は自由です。以下のプロジェクトでは popup.html/popup.js でなく、Viteが自動生成する index.html + index.js の形で構築しています。

# プロジェクトの構築

プロダクト名はギリシャ神話の知恵の女神から名前をもらってメーティス（metis）としました。 

https://github.com/uskithub/metis

## Viteでプロジェクトの雛形を作成

```shell
yarn create vite
```

```shell
➤ YN0000: · Yarn 4.5.3
➤ YN0000: ┌ Resolution step
➤ YN0085: │ + create-vite@npm:6.1.1
➤ YN0000: └ Completed
➤ YN0000: ┌ Fetch step
➤ YN0013: │ A package was added to the project (+ 262.29 KiB).
➤ YN0000: └ Completed
➤ YN0000: ┌ Link step
➤ YN0000: │ ESM support for PnP uses the experimental loader API and is therefore experimental
➤ YN0000: └ Completed
➤ YN0000: · Done with warnings in 0s 65ms

✔ Project name: … metis
✔ Select a framework: › Vanilla
✔ Select a variant: › TypeScript

Scaffolding project in /Users/yusuke/Workspaces/metis...

Done. Now run:

  cd metis
  yarn
  yarn dev
```

Yarn v4系に関しては以下の記事を参照。

https://qiita.com/uskiita/items/8a0cfc0c1c7d3e13fd55#yarn

初期状態のプロジェクトは以下。

```shell:tree
.
├── index.html
├── package.json
├── public
│   └── vite.svg
├── src
│   ├── counter.ts
│   ├── main.ts
│   ├── style.css
│   ├── typescript.svg
│   └── vite-env.d.ts
└── tsconfig.json
```

ちなみに Yarn の PnPモード でいけたので PnPモード で作りました。VSCode/Cursor を PnPに対応させるには以下参照。

https://qiita.com/uskiita/items/8a0cfc0c1c7d3e13fd55#yarn-%E3%81%AE-pnp%E3%83%A2%E3%83%BC%E3%83%89-%E3%81%AB%E5%AF%BE%E5%BF%9C%E3%81%99%E3%82%8B


## Chrome拡張としてビルドできるように調整

### Dependenciesの追加

```shell
yarn add @types/chrome --dev 
```


### public 以下に manifest.json を用意

```json:manifest.json
{
  "manifest_version": 3,
  "name": "metis",
  "version": "1.0",
  "description": "Google Meet に AIアドバイザーのメーティスを同席させます",
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },
  "action": {
    "default_popup": "index.html"
  },
  "permissions": [],
  "host_permissions": ["https://meet.google.com/*"],
  "content_scripts": [
    {
      "matches": ["https://meet.google.com/*"],
      "js": ["content.js"]
    }
  ]
}
```


### アイコンの用意

manifest に記載した通り、Chrome拡張には 16x16、48x48、128x128 の PNG形式のアイコンが必要です（[公式](https://developer.chrome.com/docs/extensions/reference/manifest/icons?hl=ja)）。

アイコンは以下のサイトを利用して作成しました。
https://icon-z.com/

各サイズには mac のプレビューで編集。public 以下に icons フォルダを作ってそこに放り込みます。

<img width="300" alt="metis.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26825/65dd3748-088d-d365-1610-72573e8985ad.png">

### src 以下に content.js を作成

まずは空で良いです。

### vite.config.ts

Chrome拡張の要件として、popup向けのJSファイルと content や background向けの JS を分けて出力する必要があります。

popup向けのファイルはまとめて出力し、content向けは（background向けも必要なら）別途出力するため vite.config を複数用意し別々にビルドします。

#### popup向け

```vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        index: "index.html",
      },
    },
    outDir: "dist",
    emptyOutDir: true,
  }
});
```

#### content向け

```vite.config.content.ts
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        content: "src/content.ts",
      },
      output: {
        entryFileNames: "[name].js",
        inlineDynamicImports: true,
        format: "iife",
      },
    },
    outDir: "dist",
    emptyOutDir: false,
  },
});
```

| 設定項目                     | 解説
|----------------------------|----------------------------------------------------------
| inlineDynamicImports: true | モジュールのファイル内容を展開して出力結果から import 構文を無くす
| format: "iife"             | 即時実行関数のフォーマットでファイルを出力する
| emptyOutDir: false         | 出力フォルダ（dist）をクリアしないようにします（popupのビルドを残すため）

#### package.json の scripts の修正

```diff_json:package.json
    "scripts": {
      "dev": "vite",
-     "build": "tsc && vite build,
+     "build": "tsc && vite build && vite build --config=vite.config.content.ts",
      "preview": "vite preview"
    },
```


### ビルドして動作確認

ここまででビルドできるようになったはずです。

```shell
yarn build
```

distができて以下のようになっているはず。

```tree
.
├── assets
│   ├── index-D12_I9U1.js ※もちろんindex-[hash].js の hash部分は同じではないです
│   └── index-dv8c_Ups.css
├── content.js
├── icons
│   ├── icon128.png
│   ├── icon16.png
│   └── icon48.png
├── index.html
├── manifest.json
└── vite.svg
```

chrome で拡張機能のページ [chrome://extensions](chrome://extensions) を開き、ページ右上のデベロッパーモードのスイッチを ON にし、左上の `パッケージ化されていない拡張機能を読み込む` ボタンを押下して、build して作られた dist フォルダを指定します。

![拡張機能の動作確認.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26825/fd6e2585-8309-7090-3725-db1897e60c0f.png)

拡張を読み込んだ直後にエラーダイアログが出ない、かつ表示される拡張機能のアイテムに `エラー` がなければ一先ず OK です。

**NG例**
<img width="500" alt="読み込み直後にエラーダイアログ.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26825/644e708f-1590-ed0e-8ea0-693080ebeb8a.png">

<img width="500" alt="読み込めはしたがエラーがある.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26825/cb2cb506-075a-03dc-79b2-1419fd5e8c27.png">


ツールバーに表示（固定）させてクリックすると、Viteで生成したHTMLが表示されます。

<img width="400" alt="Viteが作ったHTMLが表示される.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26825/46c96f5c-b0cb-66b9-d20e-d2d648483ea2.png">

# popupの実装（割愛）

アドバイザーの動作確認には使わないので割愛します。

# contentの実装

## ミーティングの開始／終了を捕捉する

[MutationObserver](https://developer.mozilla.org/ja/docs/Web/API/MutationObserver) を使って Meet の DOM を変更を観測し、ミーティングへの参加とミーティングからの退出を補足します。

どうやろうかと考えた末、`<video>`タグの個数に着目してみました。

|          | 開始前      | 参加中   | 退出後    | 
|:--------:|------------|---------|----------|
| 個数      | 0 → 1 → 2  | → 0 → 1 | → 0      |
| 状態の変化 | initializing → ready | → connectiong → meeting | → terminated

```typescript:contente.ts
const MeetStatus = {
  initilizing: "initilizing",
  ready: "ready",
  connecting: "connecting",
  meeting: "meeting",
  terminated: "terminated",
} as const;

type MeetStatus = (typeof MeetStatus)[keyof typeof MeetStatus];

let meetStatus: MeetStatus = MeetStatus.initilizing;

// 監視対象のノード
const targetNode = document.body;

const observer = new MutationObserver((mutations: MutationRecord[]) => {
  for (const mutation of mutations) {
    if (mutation.type !== "childList") return;

    // <video> の個数をカウント
    const numOfVideoTags = targetNode.querySelectorAll("video").length;

    if (meetStatus === MeetStatus.initilizing && numOfVideoTags > 0) {
      meetStatus = MeetStatus.ready;
      console.log("Meet status changed: ", meetStatus);
    } else if (meetStatus === MeetStatus.ready && numOfVideoTags < 1) {
      meetStatus = MeetStatus.connecting;
      console.log("Meet status changed: ", meetStatus);
    } else if (meetStatus === MeetStatus.connecting && numOfVideoTags > 0) {
      meetStatus = MeetStatus.meeting;
      console.log("Meet status changed: ", meetStatus);
    } else if (meetStatus === MeetStatus.meeting && numOfVideoTags < 1) {
      meetStatus = MeetStatus.terminated;
      console.log("Meet status changed: ", meetStatus);
      observer.disconnect();
    }
  }
});

observer.observe(targetNode, {
  childList: true,
  subtree: true,
});
```

ここまででミーティングへの参加、退出を検知できていることを、コンソール上で確認できます。

## AIへの質問機能

ミーティング参加後、content では以下の流れで処理を行います。

1. マイク入力を取得し音声をテキストに変換（Speech-To-Text）
1. （特定キーワードがあったら）GPTなど、生成AIのAPIを呼び出し
1. 回答をメッセージとして送信する


### 1. マイク入力を取得し音声をテキストに変換

Speech-to-Text、いわゆる STT については色々やり方がありますが、ここではおそらく最も簡単な [webkitSpeechRecognition](https://developer.mozilla.org/ja/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API) で実現しました。

:::note warn 
Meetとシステムのサウンドの入力デバイスが違う場合には注意
webkitSpeechRecognition はブラウザのデフォルト＝システムのデフォルトの入力デバイスを利用するようです。そして変更ができません。
これが Google Meet で使用する入力デバイスと一致していればいいですが、そうでない場合、「精度が悪いな〜💢」ってことになります。システムの設定を Meet の設定に手動で合わせてください。
:::

```typescript:stt.ts
export type Observer<T> = (data: T) => void;

export class Stt {
  private recognition: SpeechRecognition;
  private observers: Observer<string>[] = [];
  
  constructor(
    private lang: string = "ja-JP",
    private keywords: string[] = ["メーティス", "メティス", "メイティス"]
  ) {
    this.recognition = this.createRecognition();
  }

  private createRecognition(): SpeechRecognition {
    const recognition = new webkitSpeechRecognition();
    recognition.lang = this.lang;
    // recognition.interimResults = true; // 中間結果も取得する場合
    recognition.continuous = true;

    recognition.onresult = (event: SpeechRecognitionEvent) => {
      const num = event.results.length;
      const transcript = event.results[num - 1][0].transcript;
      console.log(transcript);

      // keywordsに引っかかればobserverを呼ぶ
      if (
        transcript.length > 0 &&
        this.keywords.reduce(
          (result, keyword) => result || transcript.includes(keyword),
          false
        )
      ) {
        for (const observer of this.observers) {
          observer(transcript.trim());
        }
      }
    };

    recognition.onend = () => {
      this.start(); // 定期的に切れるので繋ぎ直す
    };

    return recognition;
  }

  subscribe(observer: Observer<string>): void {
    this.observers.push(observer);
  }

  start() {
    this.recognition.start();
  }

  stop() {
    this.recognition.stop();
    this.observers = [];
  }
}
```

```diff_typescript:content.ts
+ import { Stt } from "./stt";

  ...
  
+ let stt: Stt | null = null;
  const observer = new MutationObserver((mutations: MutationRecord[]) => {
    for (const mutation of mutations) {
      if (mutation.type !== "childList") return;
  
      // <video> の個数をカウント
      const numOfVideoTags = targetNode.querySelectorAll("video").length;
  
      if (meetStatus === MeetStatus.initilizing && numOfVideoTags > 0) {
        meetStatus = MeetStatus.ready;
        console.log("Meet status changed: ", meetStatus);
+       stt = new Stt();
+       stt.subscribe((transcript) => console.log("☆☆☆", transcript));
      } else if (meetStatus === MeetStatus.ready && numOfVideoTags < 1) {
        meetStatus = MeetStatus.connecting;
        console.log("Meet status changed: ", meetStatus);
      } else if (meetStatus === MeetStatus.connecting && numOfVideoTags > 0) {
        meetStatus = MeetStatus.meeting;
        console.log("Meet status changed: ", meetStatus);
+       stt?.start();
      } else if (meetStatus === MeetStatus.meeting && numOfVideoTags < 1) {
        meetStatus = MeetStatus.terminated;
        console.log("Meet status changed: ", meetStatus);
        observer.disconnect();
+       stt?.stop();
+       stt = null;
      }
    }
  });
```

はい、ボッチで Meet に参加してしゃべってみてください。内容がおおよそテキスト化されてコンソール上で確認できます。


### 2. 生成AIのAPIを呼び出し

ここでは [Gemini](https://github.com/google-gemini/generative-ai-js/tree/main/samples) さんを裏方にすることにしました。

システムプロンプトは以下のようにしています。

```
あなたは専門知識を持つAIアシスタントの メーティス です。以下のルールに従って回答してください：
	1. 回答は明確で正確かつ具体的にする
	2. 複雑な概念を説明する場合は、わかりやすさを優先する
	3. 回答は構造化して良いが、Markdown記法は使わず、以下の例に倣う
		● ラベル例1
		  - 箇条書き1
		  - 箇条書き2
		    * 箇条書き2-1
		    * 箇条書き2-2
		● ラベル例2
		  1. リスト1
		  2. リスト2
		    a. リスト2-1
		    b. リスト2-2
	4. 簡素すぎる回答は避け、十分な情報を提供する
	5. 回答を要約することで500文字以内に収める
質問者の意図を正確に理解し、最適な回答を心がけてください。
```

Meet のメッセージ欄では Markdown が利かないのと、最大文字数が500文字なのでそのための制限を入れています。ソースでは英文にしているのはその方がルールをしっかり守ってくれるためです（それでも500文字制限を守ってくれないことがある）。

```typescript:adviser.ts
import { GenerativeModel, GoogleGenerativeAI } from "@google/generative-ai";

export class Adviser {
  private model: GenerativeModel;

  constructor(private name: string = "メーティス") {
    this.model = this.createModel();
  }

  private createModel(): GenerativeModel {
    const genAI = new GoogleGenerativeAI("※ご自分のAPIキーを指定してください");
    return genAI.getGenerativeModel({
      model: "gemini-1.5-flash",
      systemInstruction: `You are ${this.name}, an AI assistant with specialized knowledge. Please follow these rules when providing responses:
	1. Ensure responses are clear, accurate, and specific.
	2. Prioritize simplicity and clarity when explaining complex concepts.
	3. You may structure responses, but do not use Markdown. Instead, follow the formatting example below:
		● Example Label 1
		  - Bullet 1
		  - Bullet 2
		    • Sub-bullet 2-1
		    • Sub-bullet 2-2
		● Example Label 2
		  1. List Item 1
		  2. List Item 2
		    a. Sub-item 2-1
		    b. Sub-item 2-2
	4. Avoid overly simplistic answers; provide sufficient information.
	5. Summarize the response to keep it within 500 characters.
Strive to understand the user’s intent accurately and deliver the most optimal response.`,
    });
  }

  public async answer(question: string): Promise<string> {
    return this.model.generateContent(question).then((result) => {
      return result.response.text();
    });
  }
}
```

```diff_typescript:content.ts
  import { Stt } from "./stt";
+ import { Adviser } from "./adviser";
  
  ...
  
  let stt: Stt | null = null;
+ const adviser = new Adviser();
  
  const observer = new MutationObserver((mutations: MutationRecord[]) => {
    for (const mutation of mutations) {
      if (mutation.type !== "childList") return;
  
      // <video> の個数をカウント
      const numOfVideoTags = targetNode.querySelectorAll("video").length;
  
      if (meetStatus === MeetStatus.initilizing && numOfVideoTags > 0) {
        meetStatus = MeetStatus.ready;
        console.log("Meet status changed: ", meetStatus);
        stt = new Stt();
-       stt.subscribe((transcript) => console.log("☆☆☆", transcript));
+       stt.subscribe((transcript) => {
+         const question = `${transcript}？`;
+         console.log("☆☆☆", question);
+         adviser.answer(question).then((advice) => {
+           console.log("★★★", advice);
+         });
+       });
      } else if (meetStatus === MeetStatus.ready && numOfVideoTags < 1) {
        meetStatus = MeetStatus.connecting;
        console.log("Meet status changed: ", meetStatus);
      } else if (meetStatus === MeetStatus.connecting && numOfVideoTags > 0) {
        meetStatus = MeetStatus.meeting;
        console.log("Meet status changed: ", meetStatus);
        stt?.start();
      } else if (meetStatus === MeetStatus.meeting && numOfVideoTags < 1) {
        meetStatus = MeetStatus.terminated;
        console.log("Meet status changed: ", meetStatus);
        observer.disconnect();
        stt?.stop();
        stt = null;
      }
    }
  });
```

ここまででコンソールに回答が出るところまで確認できます。

### 3. 回答をメッセージとして送信する

この工程は多少力技です。QuerySelector でメッセージの投稿欄および送信ボタンを取得し、回答を挿入して送信ボタンをスクリプトから click します。なので「全員とチャット」ボタンで事前にチャットのパネルを開いておかないと機能しないのでご注意ください。また Meet の UI がアップデートされたら追随して修正する必要が出てきます。

```typescript:messenger.ts
export class Messenger {
  private messageInput: HTMLTextAreaElement | null = null;
  private sendButton: HTMLButtonElement | null = null;

  constructor() {
    this.setup();
  }

  private setup(): void {
    // textarea はページに一つしかない
    const messageInput =
      document.querySelector<HTMLTextAreaElement>("textarea");

    if (messageInput === null) {
      return;
    }
    // どうやら textarea と送信ボタンの aria-label は共通の模様
    const label = messageInput.getAttribute("aria-label");

    if (label === null) {
      throw new Error("😇 fatal: aria-label of textarea not found!");
    }

    const sendButton = document.querySelector<HTMLButtonElement>(
      `button[aria-label=${label}]`
    );

    if (sendButton === null) {
      throw new Error("fatal: send button not found!");
    }
    this.messageInput = messageInput;
    this.sendButton = sendButton;
  }

  send(message: string): void {
    if (this.messageInput === null) {
      this.setup();
      if (this.messageInput === null) {
        console.error("😭 Please open the chat panel.");
        return;
      }
    }

    this.messageInput.value = message;

    // イベントをトリガーして入力内容を反映
    const inputEvent = new Event("input", { bubbles: true });
    this.messageInput.dispatchEvent(inputEvent);

    this.sendButton?.click();
  }
}
```

```diff_typescript:content.ts
  import { Stt } from "./stt";
  import { Adviser } from "./adviser";
+ import { Messenger } from "./messenger";
  
  ...
  
  let stt: Stt | null = null;
  const adviser = new Adviser();
+ const messenger = new Messenger();
  
  const observer = new MutationObserver((mutations: MutationRecord[]) => {
    for (const mutation of mutations) {
      if (mutation.type !== "childList") return;
  
      // <video> の個数をカウント
      const numOfVideoTags = targetNode.querySelectorAll("video").length;
  
      if (meetStatus === MeetStatus.initilizing && numOfVideoTags > 0) {
        meetStatus = MeetStatus.ready;
        console.log("Meet status changed: ", meetStatus);
        stt = new Stt();
        stt.subscribe((transcript) => {
          const question = `${transcript}？`;
          console.log("☆☆☆", question);
+         messenger.send(question);
          adviser.answer(question).then((advice) => {
            console.log("★★★", advice);
+           messenger.send(advice);
          });
        });
      } else if (meetStatus === MeetStatus.ready && numOfVideoTags < 1) {
        meetStatus = MeetStatus.connecting;
        console.log("Meet status changed: ", meetStatus);
      } else if (meetStatus === MeetStatus.connecting && numOfVideoTags > 0) {
        meetStatus = MeetStatus.meeting;
        console.log("Meet status changed: ", meetStatus);
        stt?.start();
      } else if (meetStatus === MeetStatus.meeting && numOfVideoTags < 1) {
        meetStatus = MeetStatus.terminated;
        console.log("Meet status changed: ", meetStatus);
        observer.disconnect();
        stt?.stop();
        stt = null;
      }
    }
  });
```

ここまでで「ねぇ メーティス、テスラとBYDとトヨタを比較しつつ、3社の2025年の業績を予測して」とかいうとメッセージ欄に回答が書き込まれます。ボッチ Meet がボッチではなくなりました☺️

# 終わりに

より受け答えの精度を高めたい人はモデルを替えるなり、別のAIを試すなり、プロンプトを工夫するなりすると良いと思います！500文字以内にして、と言っているのにしてくれないので最終的には[分割送信を追加](https://github.com/uskithub/metis/blob/master/src/messenger.ts#L46-L92)しましたよ（`gemini-2.0-flash-exp` は 同じプロンプトでも500文字守ってくれそうです）。

あと雰囲気優先で「メーティス」さんと命名しましたが、ちゃんと聞き取ってくれないことが多々ありイラッとすると思うので名前は変えて使った方が良いです。アラジンとか「あ」始まりだと強い気がします。Alexa や Siri もきっと判別しやすさを念頭に命名したんだろうなぁと思いました。

UI を付け加えたものを Chrome ウェブストアで公開しています。お試しあれ。

https://chromewebstore.google.com/detail/metis-ai%E3%82%A2%E3%83%89%E3%83%90%E3%82%A4%E3%82%B6%E3%83%BC-for-google/ighmjagkkekcphjnafojdigopfmkfeac?authuser=0&hl=ja