はじめに
自己紹介と記事の目的
つい先日大学院を修了し、既卒としてエンジニア就活してます。
現在Webアプリケーションについて勉強中です。
時間があるので応用情報技術者を取得しようと思ったのですが、せっかくなら生成AIを使ってそれをサポートできないかと考えて、生成AIを組み込んだWebアプリケーションを作ることにしました。
最終的には生成AIを生かした複数の機能を実装し、自分以外のユーザーにも使ってみてほしいので、その足がかりを作るためにも制作過程を記事にしていこうと思っています。
同じようにWebアプリ開発を勉強している方々の一助になれば幸いです。
今回実装するクイズ機能の完成イメージ
こんな感じで、応用情報に出現しそうなIT用語に関するクイズをAIに出題してもらう機能を実装していきます。
こんな人向け
- Gemini APIを使ってWebアプリケーションを作ってみたい
今回説明しないこと
- React+Viteでの環境構築やプロジェクトの作成方法
- CSSなどのレイアウトに関係する部分
- TypescriptやReactの基本知識
使用技術・開発環境
- React 19.0.0(Vite v6.2.6)
- TypeScript
- Google Gemini API
- MaterialUI
- Tailwind CSS
- Vscode
- Github Copilot
特にレイアウトの部分はCopilotにバリバリ書いてもらっています...
事前準備
Tainwind CSSのインストール
npm install tailwindcss @tailwindcss/vite
index.cssに以下を記述しておいてください。
@import "tailwindcss";
vite.confingにも追加が必要です。
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react-swc'
export default defineConfig({
plugins: [react(), tailwindcss()],
})
MaterialUIのインストール
npm install @mui/material @emotion/react @emotion/styled
Gemini APIの使い方
参考(公式サイト):
1. npmインストール
npm install @google/genai
以前は @google/generative-ai というパッケージが主に使われていましたが、現在では @google/genai がリリースされこちらへの移行を推奨しているそうです。(generative-aiは2025年8月にサポート終了)
2. APIキーの取得
Gemini APIに限らず、各種APIを利用するためにはAPIキーと呼ばれるAPI提供者から発行される一意の認証情報が必要です。
今回はGoggle AI Stdioの無料枠を使用します。APIキーはサイトの指示に従っていけば簡単に発行できます。
3. APIキーの隠蔽
APIキーをコード上にそのまま記述していると、Githubなどでプロジェクトを公開したとき他のユーザーにキーが漏洩してしまいます。
そのため、APIキーは環境変数に隠し、使用する際に変数を読み込む形にします。
詳しくは他の方が書いた記事などを参照してください。
プロジェクト直下に.envファイルを作成
VITE_GEMINI_KEY = ここにAPIキー
.gitignoreに以下を追加
.env
4. gemini.tsの実装
APIを使用したロジック部分は、gemini.tsというファイルを作成し記述していきます。
まずは環境変数からAPIキーを受け取りインスタンスを生成します。
import { GoogleGenAI} from "@google/genai";
// 環境変数の読み込み
const GEMINI_API_KEY: string = import.meta.env.VITE_GEMINI_KEY;
// APIキーを使用してGoogleGenerativeAIのインスタンスを作成
const genAI = new GoogleGenAI({apiKey: GEMINI_API_KEY});
次に、ロジック部分を実装します。
今回想定される使い方は以下の2つです。
- 特定の用語に対しクイズを生成してもらう
- クイズに対し、ユーザーの回答が合っているか間違っているかを判定してもらう
2つ目に関しては単純な文字列比較などを利用してもよいのですが、複数の呼び方が許される場合などを考えると面倒なので、どうせならAIにやってもらいましょう。
それぞれ必要となるプロンプトは異なりますが、用途に合ったプロンプトを受け取りAIモデルに回答してもらう処理は共通しているので、プロンプトを受け取って、AIの回答テキストを返す関数を実装します。
const callGeminiAPI = async (prompt: string): Promise<string> => {
// プロンプトに基づいてGemini APIを呼び出し 出力を取得
const response = await genAI.models.generateContent({
model: "gemini-2.0-flash",
contents: prompt,
config: {
maxOutputTokens: 500,
temperature: 0.1,
},
});
// テキストを抽出する前に undefined の可能性をチェック
if (response.text === undefined) {
console.error("Geminiモデルからの応答にテキストが含まれていませんでした。")
throw new Error("Failed to generate quiz text: No text in response.");
}
// 生成されたテキストを取得
return response.text;
}
これでgemini.tsの実装は完了です。本体部分でプロンプトを用意し、関数を呼び出すようにします。
クイズの解答となる用語の準備
クイズの解答候補となるIT用語を用意します。
試しに、応用情報に出現するIT用語をGeminiに10個生成してもらいました。
実際のアプリ開発ではデータベースに保存したいところですが、
今回は簡単のためプロジェクト内に直接定義しておきます。
const itTermsForExam: string[] = [
"ITIL", // サービスマネジメント
"PaaS", // クラウドサービスモデル
"ブロックチェーン", // 新技術・分散システム
"SCM", // 経営戦略・システム戦略
"SLA", // サービスレベル管理
"アジャイル開発", // ソフトウェア開発手法
"DDoS攻撃", // セキュリティ・脅威
"IoT", // ハードウェア・ネットワーク
"正規化", // データベース設計(より専門的)
"BCP" // リスクマネジメント・事業戦略
];
各種プロンプトの用意
特定の用語を解答とするようなクイズの問題文を生成してもらうプロンプト
const quizGeneratePrompt = `${answerWord}を解答とするようなクイズの問題文を一問生成してください。\n
問題文以外は出力しないでください。\n
${answerWord}を使用した問題ではなく、必ず${answerWord}という用語名自体が解答となるような問題にしてください。\n`;
クイズの正解判定をしてもらうプロンプト
const quizCheckPrompt = `次のクイズに対するユーザーの回答が正しいかどうかを判定してください。
問題文: ${questionText}\n
正解: ${answerWord}\n
ユーザーの回答: ${userAnswer}\n
正しい場合は「正解」、間違っている場合は「不正解」とだけ出力してください。`;
クイズ画面の実装
Reactらしく、以下の機能ごとにcomponentを分割して簡単に実装していきます。
- 問題文
- 回答フォーム
- 次の問題へボタン
問題文の表示
import ReactMarkdown from "react-markdown";
type Props = {
questionText: string;
isLoading: boolean;
};
const QuestionDisplay = ({ questionText, isLoading }: Props) => (
// 問題文のコンテナ
// 生成中状態と問題文表示状態で異なる内容を表示
<div className="flex justify-center items-center w-3/4 mt-20 bg-[#f5faff] rounded-xl p-4 min-h-[120px] mb-4 border-2 border-[#1976d2] shadow-lg">
{isLoading ? (
<p className="text-lg">生成中...</p>
) : (
<div className="text-lg">
<ReactMarkdown>{questionText}</ReactMarkdown>
</div>
)}
</div>
);
export default QuestionDisplay;
回答フォームと送信ボタン
import TextField from "@mui/material/TextField";
import IconButton from "@mui/material/IconButton";
import SendIcon from "@mui/icons-material/Send";
type Props = {
userAnswer: string;
setUserAnswer: (value: string) => void;
checkAnswer: () => void;
isLoading: boolean;
inputRef: React.RefObject<HTMLInputElement | null>;
};
const AnswerForm = ({ userAnswer, setUserAnswer, checkAnswer, isLoading, inputRef }: Props) => (
<div className="mt-8 flex items-center space-x-2">
{/* 回答入力フィールド */}
<TextField
label="あなたの回答"
variant="outlined"
value={userAnswer}
onChange={(e) => setUserAnswer(e.target.value)}
disabled={isLoading}
size="medium"
className="min-w-[240px]"
inputRef={inputRef}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!isLoading &&
userAnswer.trim() !== ""
) {
checkAnswer();
}
}}
/>
{/* 送信ボタン */}
<IconButton
color="primary"
onClick={checkAnswer}
disabled={isLoading || !userAnswer}
className="h-10 w-10 ml-1 mt-[7px] border border-[#1976d2] bg-[#f5faff] flex items-center justify-center"
>
<SendIcon className="text-[28px] align-middle" />
</IconButton>
</div>
);
export default AnswerForm;
次の問題へボタン
import Button from "@mui/material/Button";
type Props = {
nextAction: () => void;
};
const ControlButtons = ({ nextAction }: Props) => (
<div className="w-full flex justify-center gap-4 mt-6">
{/* 次の問題へボタン */}
<Button
variant="contained"
color="primary"
onClick={nextAction}
className="min-w-[200px] font-bold text-sm"
>
次の問題へ
</Button>
</div>
);
export default ControlButtons;
全体のソースコード
import { useState, useEffect, useRef } from 'react';
import { callGeminiAPI } from './utils/gemini';
import './App.css'
// コンポーネントのインポート
import AnswerForm from './components/AnswerForm';
import QuestionDisplay from './components/QuestionDisplay';
import ControlButtons from './components/ControlButtons';
function App() {
const [questionText, setQuestionText] = useState(''); // 生成されたクイズ問題文
const [answerWord, setAnswerWord] = useState(''); // クイズの解答となる用語
const [userAnswer, setUserAnswer] = useState(''); // ユーザーの回答
const [isLoading, setIsLoading] = useState(false); // クイズ生成中のローディング状態の管理
const inputRef = useRef<HTMLInputElement>(null); // 入力フィールドの参照
// クイズの解答となる用語の候補
const itTermsForExam: string[] = [
"ITIL", // サービスマネジメント
"PaaS", // クラウドサービスモデル
"ブロックチェーン", // 新技術・分散システム
"SCM", // 経営戦略・システム戦略
"SLA", // サービスレベル管理
"アジャイル開発", // ソフトウェア開発手法
"DDoS攻撃", // セキュリティ・脅威
"IoT", // ハードウェア・ネットワーク
"正規化", // データベース設計(より専門的)
"BCP" // リスクマネジメント・事業戦略
];
//配列からランダムに単語を選んで返す関数
const pickRandomWord = (wordList: string[]): string => {
if (wordList.length === 0) {
console.error("Answer words list is empty");
return "";
}
const randomIndex = Math.floor(Math.random() * wordList.length);
return wordList[randomIndex];
};
// Gemini APIを呼び出してクイズを一問生成しステートに保存する関数
const generateQuiz = async () => {
const answerWord: string = pickRandomWord(itTermsForExam)// 候補からランダムに選ばれた用語を取得
setAnswerWord(answerWord); // 正解判定用のStateにセット
setIsLoading(true); // ローディング状態を開始
setUserAnswer(""); // ユーザーの回答をリセット
// プロンプトを生成
const quizGeneratePrompt = `${answerWord}を解答とするようなクイズの問題文を一問生成してください。\n
問題文以外は出力しないでください。\n
${answerWord}を使用した問題ではなく、必ず${answerWord}という用語名自体が解答となるような問題にしてください。\n`;
try {
const response = await callGeminiAPI(quizGeneratePrompt);
setQuestionText(response);
} catch (error) {
console.error("Error calling Gemini API:", error);
setQuestionText("エラーが発生しました");
} finally {
setIsLoading(false); // ローディング状態を終了
}
};
// クイズの回答を送信し正解判定を行う関数
const checkAnswer = async () => {
if (userAnswer === "") {
alert("回答を入力してください");
return;
}
//setIsLoading(true); // ローディング状態を開始
const quizCheckPrompt = `次のクイズに対するユーザーの回答が正しいかどうかを判定してください。
問題文: ${questionText}\n
正解: ${answerWord}\n
ユーザーの回答: ${userAnswer}\n
正しい場合は「正解」、間違っている場合は「不正解」とだけ出力してください。`;
try {
const result = await callGeminiAPI(quizCheckPrompt);
alert(`${result}\n正解: ${answerWord}`);
generateQuiz();
setUserAnswer("");
} catch (error) {
console.error("Error checking answer:", error);
alert("エラーが発生しました");
generateQuiz();
setUserAnswer("");
} finally {
//setIsLoading(false); // ローディング状態を終了
}
}
//クイズスタート時に一問目を生成するためのuseEffect
useEffect(() => {
if (itTermsForExam.length > 0) {
generateQuiz();
}
}, []);
// 問題が変わるたびに回答フォームにフォーカスするためのuseEffect
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, [questionText, isLoading]); //
return (
<div className="w-full flex flex-col justify-center items-center">
{/* 問題文の表示 */}
<QuestionDisplay questionText={questionText} isLoading={isLoading} />
{/* 回答フォームと送信ボタン */}
<AnswerForm
userAnswer={userAnswer}
setUserAnswer={setUserAnswer}
checkAnswer={checkAnswer}
isLoading={isLoading}
inputRef={inputRef} // 入力フィールドの参照を渡す
/>
{/* 次の問題へボタン */}
<ControlButtons
nextAction={generateQuiz}
/>
</div>
)
}
export default App
完成画面
現時点ではシンプルな作りですし、生成されたクイズが実際に有用かどうかはわかりませんが、ひとまずは形になったと思います。
おわりに
今回は、応用情報技術者試験に向けて、Gemini APIを利用した簡単なクイズアプリケーションについて実装しました。
次回は、Geminiがマルチモーダルな入力を可能な点を生かして、音声を受け取って解釈してもらう機能について実装・解説しようと思います。