🔔

AIで問題生成する学習アプリに「待ち時間」が発生したので、pendingキュー+通知で「待たないUX」にした話

に公開

はじめに

個人開発の学習アプリ Q-Master には、AI(Claude API)で4択問題を自動生成する機能があります。

学習アプリ Q-Master AI問題生成

テキストや画像を渡すと、ひっかけ選択肢付きの本格的な4択問題が生成されます。とても便利な機能なのですが、ひとつ大きな問題がありました。

API呼び出しに10〜30秒かかる。その間、ユーザーは何もできない。

ローディング画面を頑張っても、待ち時間そのものは消せません。この記事は、「速くする」のではなく 「待たせない」 設計に切り替えた話です。


フロントエンドだけでAI APIを叩くという選択

まず前提として、Q-Masterは サーバーを持っていません

ブラウザから直接Claude APIを呼んでいます。理由はシンプルで、個人開発でサーバーの維持コスト(金銭的にも運用的にも)をゼロにしたかったからです。PWA対応でオフラインでも動く設計にしているので、サーバー依存を極力なくしたいという方針もあります。

const response = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'x-api-key': apiKey,
        'anthropic-version': '2023-06-01',
        'anthropic-dangerous-direct-browser-access': 'true'
    },
    body: JSON.stringify({
        model: 'claude-sonnet-4-5-20250929',
        max_tokens: 8192,
        system: systemPrompt,
        messages: [{ role: 'user', content: userPrompt }]
    })
});

anthropic-dangerous-direct-browser-access: true というヘッダーが必要です。名前のとおり、本来ブラウザから直接APIを叩くのは推奨されていません。APIキーがクライアントサイドに存在するためです。

Q-Masterでは、APIキーをユーザー自身が入力・管理する形にしています。localStoragebtoa() でBase64エンコードして保存しています。これは暗号化ではなく難読化に過ぎませんが、ユーザー自身のキーをユーザー自身のブラウザに保存するので、現実的には許容範囲だと判断しました。


最初の実装:同期モード

最初に実装したのは、シンプルな同期モードでした。

[生成ボタン] → ローディング画面表示 → API呼び出し → プレビューモーダル表示

ローディング中はフルスクリーンのモーダルオーバーレイが表示され、ユーザーはAPIが応答するまで何もできません。

生成が終わると、プレビューモーダルが表示されます。生成された問題の一覧が出て、チェックボックスで追加する問題を選べます。この同期モード自体は「生成結果をすぐ確認して選別したい」ケースで今も使っています。

問題は、API呼び出しに10〜30秒かかること。その間、画面の前で待つしかありません。

学習アプリにとって、待ち時間は致命的です。スキマ時間で使うアプリなので、30秒待つくらいなら問題を解きたい。「問題を生成しておいて、あとで確認する」ができれば、ユーザーの時間を無駄にしないはずです。


方針転換:「速くする」ではなく「待たせない」

API応答を速くするのは自分ではコントロールできません。であれば、待っていることをユーザーに意識させない設計に変えるべきだと考えました。

設計の方針:

  1. 生成をバックグラウンドで実行し、フォームは即座に解放する
  2. 生成結果はIndexedDBに一時保存する
  3. 完了したら通知で知らせる
  4. ユーザーは好きなタイミングで結果を確認できる

pendingキュー設計

バックグラウンド生成のために、IndexedDBに pending_questions ストアを作りました。

// db.js - onupgradeneeded 内
if (!database.objectStoreNames.contains('pending_questions')) {
    const pendingStore = database.createObjectStore('pending_questions', { keyPath: 'id' });
    pendingStore.createIndex('created_at', 'created_at', { unique: false });
    pendingStore.createIndex('status', 'status', { unique: false });
}

レコードの構造は次のとおりです。

{
    id: "uuid",
    status: "pending" | "generating" | "completed" | "error",
    questions: [],          // 生成された問題の配列
    targetSetId: null,      // 追加先の問題セットID
    newSetName: null,       // 新規セット名
    error: null,            // エラーメッセージ
    created_at: timestamp,
    completed_at: timestamp
}

status のステートマシンはこうなっています。

pending → generating → completed
                    ↘ error

バックグラウンド生成の流れ

async function generateQuestionsBackground() {
    // 1. pendingリクエストをDBに作成
    const pendingRequest = await QuizDB.addPendingRequest({
        targetSetId,
        newSetName
    });

    // 2. ステータスを「生成中」に更新
    await QuizDB.updatePendingRequest(pendingRequest.id, { status: 'generating' });

    // 3. フォームをリセットしてUIを即座に解放
    resetGeneratorForm();
    showToast('バックグラウンドで問題を生成中...完了時に通知します');

    // 4. バックグラウンドでAPI呼び出し
    try {
        const response = await callClaudeAPI(systemPrompt, userPrompt);
        const result = parseAIResponse(response);
        validateQuestions(result.questions, questionType);

        // 5. 完了 → DBに結果を保存 + 通知を追加
        await QuizDB.updatePendingRequest(pendingRequest.id, {
            status: 'completed',
            questions: result.questions,
            completed_at: Date.now()
        });

        await NotificationUI.addNotification({
            type: 'ai_generation',
            title: 'AI問題生成完了',
            message: `${result.questions.length}問の問題が生成されました`,
            data: { pendingRequestId: pendingRequest.id }
        });

    } catch (error) {
        // 6. エラー → DBにエラー情報を保存 + エラー通知
        await QuizDB.updatePendingRequest(pendingRequest.id, {
            status: 'error',
            error: error.message
        });

        await NotificationUI.addNotification({
            type: 'error',
            title: 'AI問題生成エラー',
            message: error.message
        });
    }
}

ポイントは ステップ3 です。API呼び出しの await前に フォームをリセットしてトースト通知を出しています。JavaScriptのイベントループを利用して、UIスレッドを即座に解放します。

ユーザーから見ると:

  1. 「バックグラウンド生成」ボタンを押す
  2. 「バックグラウンドで生成中...」のトースト通知が一瞬出る
  3. フォームが即座にクリアされ、次の操作ができる
  4. 数十秒後、ベルアイコンに通知バッジが表示される
  5. 好きなタイミングで通知を開いて結果を確認する

待ち時間がゼロになったわけではなく、待ち時間を意識しなくてよくなったのです。


通知システムの実装

完了を知らせるために、通知システムを作りました。

ベルアイコンとバッジ

ヘッダーにベルアイコンを配置し、未読の通知数をバッジで表示します。

async updateBadge() {
    const count = await QuizDB.getUnreadNotificationCount();
    const badge = document.getElementById('notification-badge');
    if (badge) {
        badge.textContent = count > 99 ? '99+' : count;
        badge.style.display = count > 0 ? 'flex' : 'none';
    }
}

通知パネル

ベルアイコンをクリックすると通知パネルが開き、通知の一覧が表示されます。各通知にはタイプに応じたアイコンと、AI生成完了の場合は「確認する」アクションボタンが付きます。

// 通知をクリック → 保留中のリクエストからプレビュー表示
async handleConfirmClick(notificationId, pendingId) {
    const pendingRequest = await QuizDB.getPendingRequest(pendingId);
    if (pendingRequest && pendingRequest.questions) {
        // 同期モードと同じプレビューモーダルを再利用
        showPreviewModal(pendingRequest.questions);
    }
    await QuizDB.markNotificationAsRead(notificationId);
    await this.updateBadge();
}

ここで重要なのは、プレビューモーダルを同期モードと共有していることです。バックグラウンド生成の結果確認画面を新たに作るのではなく、同期モードで使っているプレビューモーダルをそのまま再利用しています。

通知のデータ構造

通知はIndexedDBの notifications ストアに保存されます。

{
    id: "uuid",
    type: "ai_generation" | "error",    // 通知タイプ
    title: "AI問題生成完了",
    message: "5問の問題が生成されました",
    data: { pendingRequestId: "uuid" },  // 関連データへのリンク
    read: false,                         // 既読フラグ
    created_at: timestamp
}

通知は30日後に自動削除されます。


AI出力の揺れに対応する守備的バリデーション

AI(LLM)の出力は、プロンプトで指定しても100%同じ形式にはなりません。「このJSON形式で出力して」と指定しても、微妙に異なる構造が返ってくることがあります。

Q-Masterでは validateQuestions() で複数のフォールバック処理を入れています。

// 問題文のフィールド名の揺れに対応
if (!q.body_md) {
    if (q.question) q.body_md = q.question;
    else if (q.body) q.body_md = q.body;
    else if (q.text) q.body_md = q.text;
}

// 選択肢が配列で返ってきた場合、オブジェクトに変換
if (Array.isArray(q.choices)) {
    q.choices = {
        A: q.choices[0] || '',
        B: q.choices[1] || '',
        C: q.choices[2] || '',
        D: q.choices[3] || ''
    };
}

// 選択肢の値がオブジェクトの場合、textプロパティを抽出
if (typeof q.choices.A === 'object') {
    q.choices.A = q.choices.A.text || q.choices.A.label || String(q.choices.A);
}

// 解説フィールドの揺れに対応
if (!q.explanation_md) {
    if (q.explanation) q.explanation_md = q.explanation;
    else if (q.rationale) q.explanation_md = q.rationale;
}

実際に遭遇したAI出力の揺れパターン:

期待するフィールド 実際に返ってきたケース
body_md question, body, text
choices: {A: "...", B: "..."} choices: ["...", "..."](配列形式)
choices: {A: "..."} choices: {A: {text: "...", isCorrect: false}}(オブジェクト形式)
explanation_md explanation, rationale

プロンプトで指定しても起きるので、受け取る側で正規化するのが現実的な対策です。


この設計が向いているケース / 向かないケース

向いている

  • 結果をリアルタイムに使わなくていい操作(問題生成、バッチ処理など)
  • フロントエンドだけで完結させたいプロジェクト
  • 「ユーザーの手が空く」ことが価値になるアプリ(学習アプリ、生産性ツールなど)

向かない

  • 結果を即座に使いたい操作(チャットの返答、リアルタイム翻訳など)
  • 複数のバックグラウンド処理を同時に管理する必要がある場合(キューの管理が複雑になる)
  • サーバーサイドでのバッチ処理が適切な規模のもの

まとめ

AI機能を組み込んだときに直面する「待ち時間」は、APIの速度改善では解決しないことが多いです。

今回のアプローチは:

  1. 同期モードと非同期モードの2つを用意する — 用途に応じて使い分け
  2. 非同期の結果はIndexedDBに一時保存する — ブラウザを閉じても消えない
  3. 通知システムで完了を知らせる — ユーザーは好きなタイミングで確認
  4. 既存のUIコンポーネントを再利用する — プレビューモーダルの共有

「待ち時間を短くする」のではなく 「待っていることを意識させない」 設計にしたことで、AI生成中も学習を続けられる体験になりました。

フロントエンドだけでも、IndexedDBと通知UIの組み合わせで十分実用的な非同期体験は作れます。


Q-Master: https://hiroe28.github.io/Q-Master/
GitHub: https://github.com/Hiroe28/Q-Master

Discussion