😤

React × Gemini × LangChainで作る画像読み取りくん

2024/01/19に公開

まず初めに

Gemini×LangChain入門で学んだ知識を生かして、ちょっとした作品を作りましょう。まだ読んでいない方は先にこちらを読んでいただきたいです。

https://zenn.dev/y_ta/articles/f17bbfe98ce462

前提知識

  • React
  • TypeScript
  • TailwindCSS
  • Gemini
  • LangChain

今回作成するもの

入れた画像とテキストに対して、AIが答えてくれる画像よみとりくんを作ります。本当はgifで掲載したかったのですが、うまくいかなかったので、画像でごめんなさい。

gifデモ

(追記:載せれたんですが、なんかバグりました。)

画像デモ

環境構築

React+ TS +ViteとTailwindの環境構築は、以下の本の環境構築部分をご参照ください。

https://zenn.dev/y_ta/books/d007090d6478dc

Gemini + Langchain

必要なライブラリをインストールしていきましょう。

$ npm install @google/generative-ai
$ npm install @langchain/google-genai

APIKeyの取得

こちらのAPIKeyの取得を参照ください。

https://zenn.dev/y_ta/articles/f17bbfe98ce462

APIKeyの設定

ルートディレクトリに.envをファイルを作成しましょう。下記のAPIKeyを設定しましょう。

.env
VITE_GOOGLE_API_KEY = "<取得したAPIKey>"

コーディング

まず、ローディングUIを作りましょう。componentsディレクトリをsrcの直下に作成し、その中にLoading.tsxを作成しましょう。

Loading.tsx
const Loading = () => {

    return(
        <div className="flex justify-center">
            <div className="h-6 w-6 animate-spin border-2 border-sky-400 rounded-full  border-t-transparent"></div>
        </div>
    )
}

export default Loading

publicディレクトリに下記の画像をno-image.pngという名前をつけて入れといてください。

次にApp.tsxに以下を貼り付けましょう。

App.tsx
import { ChangeEvent, FormEvent, useState } from "react"
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { HumanMessage } from "@langchain/core/messages";
import Loading from "./components/Loading";

const App = () => {

  const [file, setFile] = useState<File | null>(null)
  const [text, setText] = useState<string>('')
  const [preview, setPreview] = useState<string>('/no-image.png');
  const [result, setResult] = useState<string>('');
  const [isLoading, setIsLoading] = useState<boolean>(false);


  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();

    if (file && text) {
      setIsLoading(true)
      setResult('')

      const vision = new ChatGoogleGenerativeAI({
        apiKey: import.meta.env.VITE_GOOGLE_API_KEY,
        modelName: "gemini-pro-vision",
        maxOutputTokens: 2048,
      });

      const reader = new FileReader();
      reader.onloadend = async () => {

        const input2 = [
          new HumanMessage({
            content: [
              {
                type: "text",
                text: `${text}`,
              },
              {
                type: "image_url",
                image_url: reader.result as string,
              },
            ],
          }),
        ];

        const res2 = await vision.stream(input2);

        for await (const chunk of res2) {
          const chunkText = chunk.content;
          console.log(chunkText);

          setResult(prevResult => prevResult + chunkText);
        }

        setIsLoading(false)
        setText('')

      };
      reader.readAsDataURL(file);
    } else {
      alert('入力に漏れがあります。')
    }
  }
  
  const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setFile(e.target.files[0])
      setPreview(URL.createObjectURL(e.target.files[0]))
    }
  }

  return (
    <div className="container mx-auto">
      <div className="grid grid-cols-8 items-center mt-[70px]">
        <div className="col-start-3 col-span-4">
          <div className="bg-yellow-300 p-4 rounded">
            <h1 className="text-3xl font-bold mb-2">画像読み取りくん🤨</h1>

            <form onSubmit={handleSubmit}>
              <div className="mb-2">
                <img src={preview} alt="" onClick={() => document.getElementById('file-input')?.click()} className="cursor-pointer" />
                <input type="file" id="file-input" onChange={handleImageChange} className="hidden" />
              </div>
              <div className="mb-2">
                {result ? <p>{result}</p> : (isLoading ? <Loading /> : <></>)}
              </div>
              <div className="mb-2">
                <input type="text" className="w-full border border-black rounded p-2 text-2xl focus:outline-0" value={text} onChange={(e) => setText(e.target.value)} disabled={isLoading} />
              </div>
              <button className="bg-blue-500 text-white py-1 px-3 rounded" disabled={isLoading}>送信</button>
            </form>
          </div>
        </div>
      </div>
    </div>
  )
}

export default App

Gemini × Langchain入門を読んでいただけた方はほぼほぼこのコードに問題はないかと思います。少し、詰まるであろうと思う点を下記にまとめておきます。

Viteでの環境変数の扱い

create-react-appコマンドで構築したプロジェクトであれば、下記の記述で、フロントから.envの環境変数にアクセスすることができます。

process.env.VITE_GOOGLE_API_KEY

しかし、Viteを用いた構築の場合、記述が変わります。

import.meta.env.VITE_GOOGLE_API_KEY

また、環境変数を設定する際は必ず頭にVITEという文字列をつけてください。こうしないと正常にフロントからアクセスすることはできません。

フロントでのbase64へのエンコード

Gemini × Langchain入門ではサーバーサイドでのデモ実装であり、fsというライブラリを用いてのエンコードを行いました。

fsはサーバー側のみで動作するライブラリであり、今回のフロントで使うとうまくいかなくなってしまいます。そこで、FilereaderというWebAPIを用いて実装しています。

記述は至って簡単です。

readAsDataURLというメソッドでfileをエンコードし、それが完了したら、onloadedの中身が実行されるという形です。

const reader = new FileReader();
reader.onloadend = () => {

//base64にエンコード後に実行したいもの
};
reader.readAsDataURL(file);

streamを理解する

出力部分に以下の記述を行いました。

const res2 = await vision.stream(input2);

for await (const chunk of res2) {
  const chunkText = chunk.content;
  console.log(chunkText);

  setResult(prevResult => prevResult + chunkText);
}

これは、データを小さな塊として受け取り、少しずつ出力しています。この記述を行うことで、まとめて全て出力されないため、最初の出力が早くなり、ユーザー体験が向上します。

ストリーミング配信のストリーミングとはこのことを指します。

まとめ

まずは、ここまでお疲れ様でした。

今回は、Gemini × Langchain入門で学んだ知識を使って、簡単な作品を作ってみました。もし、今度ご自身で作品を作る機会があれば、今回学んだことを生かしていただければと思います。

他にもWeb制作に関する情報や疑問をわかりやすくまとめて記事を書いているので、そちらも読んでいただけたら嬉しいです。

Discussion