虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

簡単に作れる! VS CodeでAIとチャットする拡張機能を作ってみた

この記事は 2023 夏のブログ連載企画 14 日目の記事になります。

昨日は、山田 さんの「ChatGPTを使ったインフラ構築」でした。

明日は、Y.F さんの「【ChatGPT】フレームワークの移行を試してみた!〜コード変換の実践〜」になります。ご期待ください!

はじめに

こんにちは!虎の穴ラボの古賀です。

私は AT&CSIRT というチームでアーキテクチャ検討やセキュリティ上の脆弱性対応などを担当しています。

最近では「ChatGPT」や「GitHub Copilot」などのテキスト生成やコーディング支援を行う AI を業務で使うことも増えています。その際に ChatGPT を表示しているブラウザと VS Code 間を毎回、移動するのが面倒なため Visual Studio Code(以下、VS Code と略)の拡張機能として「OpenAI API」を使ってチャットをできるようにしてみたのでご紹介したいと思います!

下記は作成したチャットのイメージになります。

「OpenAI API」を使うと、ChatGPTと異なり「Fine-tuning」でモデルに追加のデータを学習させたり、「Function calling」で他の API やデータと連携したりなど独自のチャットボットが作れるようになります。 (今回は「Fine-tuning」と「Function calling」の実装はしません。) また、今回の拡張は「GitHub Copilot Chat」のウェイトリストがなかなか解禁されないので作成したという理由もあります。

前提条件

  • macOS のみ(Windows、Linux は未検証)
  • Node.js v18

プロジェクトを作成する

Microsoft 公式の React / Vite のテンプレートを利用します。調べてみたら一から作ることもできそうでしたが、最初から高速なViteで作られているので公式テンプレートを使う方法を選びました。

$ npx degit microsoft/vscode-webview-ui-toolkit-samples/frameworks/hello-world-react-vite openai-chat-extension
$ cd openai-chat-extension

次にインストールを行います。サブディレクトリにある webview-ui にも npm プロジェクトが作成されているため、どちらもインストールするために下記のコマンドを実行します。

$ npm run install:all

今回の公式テンプレートのプロジェクトは、拡張機能のホスト(WebView を表示する方)とクライアント(WebView の中身を React で提供する方)に npm プロジェクトが分かれており、webview-ui に React のプロジェクトが Vite で構築されています。

下記のコマンドで、React側のプロジェクトが起動することを確認してください。

$ npm run start:webview

必要なモジュールをインストールする

Tailwind CSSの導入

今回は、Tailwind CSSを利用したかったので、追加で導入しました。Tailwind CSSはユーティリティファーストなCSSフレームワークになっています。

tailwindcss.com

$ cd webview-ui
$ npm install -D tailwindcss postcss autoprefixer
$ npx tailwindcss init -p --ts # TypeScriptでconfigを作る
$ cd -

tailwind.config.ts を下記のように修正する

import type { Config } from "tailwindcss";

export default {
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
} satisfies Config;

Hero Iconsの導入

次にHero Iconsを導入します。Hero Icons は Tailwind CSS と同じプロジェクトで作成されているアイコンライブラリです。Figma ファイルなどが用意されており、アイコンも綺麗で React や Vue で扱いやすいので今回、利用しています。

heroicons.com

$ npm install @heroicons/react

インストールしなくても、公式サイトからひとつずつ SVG や JSX としてコピーして利用することもできます。

React でチャットの UI を作る

まず、全体的な機能のイメージを掴みたいので、Tailwind Play で UI を作り、その後に webview-ui/src/App.tsx の return に Tailwind CSS と Hero Icons で UI を構築します。

Tailwind PlayでUIを作った

<main className="h-screen flex flex-col items-center bg-violet-600 pb-36">
  <div className="container bg-violet-400 h-screen overflow-y-auto">
    <ul className="space-y-2">
      <!-- 1. ループして出力されたメッセージの履歴を表示する部分 -->
      <!-- 1-1. ユーザがAIへ送ったメッセージの場合の表示 -->
      <li className="flex justify-end pt-4">
        <div className="px-4">
          <div className="bg-violet-300 relative max-w-xl px-4 py-2 rounded-lg border-[1px] border-solid my-1 border-violet-50">
            <div className="trianle-right"></div>
            <pre className="p-2 text-violet-950 break-all whitespace-pre-wrap">
              今日の天気は?
            </pre>
          </div>
        </div>
        <div className="relative p-0">
          <img
            className="w-16 h-16 rounded-full max-w-none"
            src="https://raw.githubusercontent.com/toranoana/special/master/maid-engineers/16_sorry.png?raw=true"
          />
          <span className="absolute left-0 right-0 m-auto text-[8px] text-center top-[4.125rem] text-violet-950">
            User
          </span>
        </div>
      </li>
      <!-- 1-2. AIによる回答メッセージの場合の表示 -->
      <li className="flex justify-start pt-4">
        <div className="relative p-0">
          <img
            className="w-16 h-16 rounded-full max-w-none"
            src={assistantImage}
          />
          <span className="absolute left-0 right-0 m-auto text-[8px] text-center top-[4.125rem] text-violet-950">
            AI
          </span>
        </div>
        <div className="px-4">
          <div className="bg-violet-300 relative max-w-xl px-4 py-2 rounded-lg border-[1px] border-solid my-1 border-violet-50">
            <div className="trianle-left"></div>
            <pre className="p-2 text-violet-950 break-all whitespace-pre-wrap">
              私はAIですので、現在の天気情報を表示することはできません。お住まいの地域の天気予報を確認してください。
            </pre>
          </div>
        </div>
      </li>
      <!-- 2. AIによる回答を1文字づつ出力するエリア(AIによる回答がおわったら非表示になり、履歴に移動する)-->
      <li className="flex justify-start pt-4">
        <div className="relative p-0">
          <img
            className="w-16 h-16 rounded-full max-w-none"
            src="https://raw.githubusercontent.com/toranoana/special/master/maid-engineers/38_Glasses.png?raw=true"
          />
          <span className="absolute left-0 right-0 m-auto text-[8px] text-center top-[4.125rem] text-violet-950">
            AI
          </span>
        </div>
        <div className="px-4">
          <div className="bg-violet-300 relative max-w-xl px-4 py-2 rounded-lg border-[1px] border-solid my-1 border-violet-50">
            <div className="trianle-left"></div>
            <pre className="p-2 text-violet-950 break-all whitespace-pre-wrap">
              出力中のメッセージ
            </pre>
          </div>
        </div>
      </li>
    </ul>
  </div>
  <div className="flex flex-row gap-1 items-center justify-center content-center fixed bottom-0 left-auto right-auto w-screen h-36 bg-violet-900">
    <div className="container flex flex-row gap-1 items-center justify-center content-center relative mx-2">
      <!-- チャットメッセージの入力エリア -->
      <textarea className="bg-violet-50 rounded w-full h-24 p-2 text-lg text-violet-950"></textarea>
      <!-- メッセージの送信ボタン -->
      <button className="bg-violet-600 text-white px-4 py-3 rounded w-fit absolute right-2">
        <PaperAirplaneIcon className="h-6 w-6 fill-white" />
      </button>
    </div>
  </div>
</main>

特徴としては、OpenAI の API の stream 機能を利用し回答を 1 文字づつ順次出力することをしたいので、それを前提とした UI の作りになっています。

React でチャットのロジックを実装する

いくつかのポイントを抜粋して紹介します。下記のリンクに全体のソースコードを置いています。

github.com

アイコン画像のランダム表示

虎の穴ラボのメイドちゃんの画像をランダムにするために、useEffect でランダムな画像をコンポーネントの読込み時に一度だけ設定するようにしました。

const userImages = [
  "https://raw.githubusercontent.com/toranoana/special/master/maid-engineers/16_sorry.png?raw=true",
  "https://raw.githubusercontent.com/toranoana/special/master/maid-engineers/40_HELP.png?raw=true",
];

const assistantImages = [
  "https://raw.githubusercontent.com/toranoana/special/master/maid-engineers/38_Glasses.png?raw=true",
  "https://raw.githubusercontent.com/toranoana/special/master/maid-engineers/49_cat.png?raw=true",
  "https://raw.githubusercontent.com/toranoana/special/master/maid-engineers/55_Bucket.png?raw=true",
];

function App() {
  // 省略
  const [userImage, setUserImage] = useState(userImages[0]);
  const [assistantImage, setAssistantImage] = useState(assistantImages[0]);

  useEffect(() => {
    setUserImage(userImages[Math.floor(Math.random() * userImages.length)]);
    setAssistantImage(assistantImages[Math.floor(Math.random() * assistantImages.length)]);
  }, []);

OpenAI API の回答を stream で取得する

OpenAI API の回答を stream で取得するための useEffect を作成しました。レスポンスの開始や終了を先頭や終端記号を検出して制御してるところが特徴です。また、チャットの文脈を意識してくれるようにチャットの履歴をAPIの引数に渡しています。

useEffect(() => {
  const fetchData = async () => {
    try {
      const res = await fetch("https://api.openai.com/v1/chat/completions", {
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${import.meta.env.VITE_OPENAI_API_KEY}`,
        },
        method: "POST",
        body: JSON.stringify({
          messages: outputMessages,
          model: "gpt-3.5-turbo-16k",
          stream: true,
        }),
      });
      if (!res) return;
      const reader = res.body?.getReader();
      const decoder = new TextDecoder();
      done: while (true) {
        const { done, value } = (await reader?.read()) || {};
        if (done) break;
        if (!value) continue;
        const lines = decoder.decode(value);
        const jsons = lines
          .split("data: ") // 各行は data: というキーワードで始まる
          .map((line) => line.trim())
          .filter((s) => s); // 余計な空行を取り除く
        for (const json of jsons) {
          try {
            if (json === "[DONE]") {
              break done; // 終端記号
            }
            const chunk = JSON.parse(json);
            const errorMessage = chunk?.error?.message;
            const content =
              (chunk?.choices && chunk?.choices[0]?.delta?.content) || "";
            setMessage((prev) => prev + (content || errorMessage || ""));
          } catch (error) {
            console.error(error);
          }
        }
      }
      setOutputComplete(true);
    } catch (error) {
      console.error(error);
      setMessage((prev) => prev + (error || ""));
    }
  };
  fetchData();
}, [outputMessages]);

OpenAI API の回答終了後、履歴のリストに追加する

最後に OpenAI API からの回答出力が終わったら、回答を履歴のリストに追加します。

useEffect(() => {
  if (isOutputComplete && message) {
    setMessage("");
    setOutputMessages((prev) => [
      ...prev,
      { content: message, role: "assistant" },
    ]);
    vscode.postMessage({
      command: "chat-reply",
      text: "success!!",
    });
    setOutputComplete(false);
  }
}, [isOutputComplete, message]);

VS Code へインストールする

VS Code へインストールするためのパッケージの作成は下記のように行います。

$ npm run build:webview
$ npx vsce package

できあがった、vsix ファイルは CLI コマンドまたは VS Code の GUI からインストールできます。

VS Code の CLIを使う

$ code --install-extension openai-chat-extension-0.0.1.vsix # VSCode の場合
$ code-insiders --install-extension openai-chat-extension-0.0.1.vsix # VSCode Insidersの場合

VS Code の GUIを使う

  1. 左側のアクティビティバーにある「拡張機能」(四角形が重なっているようなアイコン)をクリックします。
  2. 上部にある「...」(もっと見る)ボタンをクリックします。
  3. ドロップダウンメニューから「VSIX からインストール...」を選択します。
  4. ファイル選択ダイアログが表示されたら、インストールしたい vsix ファイルを選択して「開く」をクリックします。

動作確認

VS Code で Command + Shift + P を押してコマンドパレットを開き、Chat と入力すると「Chat Window : Show」が表示されるので選択します。その後、チャットウィンドウが開くのでチャットができます。

まとめ

意外とチャットだけならUI含めて、簡単に作成することができました。

使ってみると、AIの応答は早いですがChatGPTと違ってタブごとに履歴が残らなかったりブラウジングやプラグインの機能が無いのは気になります。 今後、自分が使いたいようにUIをカスタマイズして、将来的にはモデルに追加のデータを学習させたり、他の API やデータと連携したチャットボットが作れるのはOpen AI APIの利点と感じました。

今後、もう少し機能の追加やUIの改善してブログ等で公開ができれば良いなぁ、と思っています。

採用情報

虎の穴では一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。
カジュアル面談やエンジニア向けイベントも随時開催中です。ぜひチェックしてみてください ♪
yumenosora.co.jp