LayerX エンジニアブログ

LayerX の エンジニアブログです。

CopilotKitでアプリをAI化しないか?

この記事は LayerX AI Agent ブログリレー16日目の記事です。前日はTomoakiさんによる「【独占取材】 AI-BPO事業のシフト管理をバクラクにしたAI Agentのシフト組子さん」でした。こちらの記事は複雑なシフト管理をAI Agentの力を使って解決したシフト組子さんの紹介でした。ありがとう組子。

最近鬼滅の刃の映画を見て猗窩座になっている @ta1m1kam です。お前のアプリも AI化しないか?というわけで爆速 AI 化 PoC をやって、既存のアプリケーションに AIエージェントの力が加わった世界を堪能しましょう。

イントロダクション

「たった 数分で、あなたの React アプリに AIエージェントを組み込めるとしたら?」

  • 「AI機能を追加したいけど、実装が難しそう」
  • 「既存のアプリを大幅に改修するのは避けたい」
  • 「チャットUIだけじゃなく、アプリと連動するAIが欲しい」

みたいな感覚はありませんか?

実際にAIエージェントを組み込もうとすると以下のような作業が必要です。

  • LLM API接続の実装(認証、鍵管理、エラーハンドリング、ストリーミング対応)
  • プロンプト設計と入出力スキーマ管理(テンプレ管理、JSON検証、リトライ処理)
  • RAG基盤の構築(データ抽出・埋め込み生成・ベクトルDB検索・再ランキング)
  • アプリ連動のためのツール呼び出し(外部APIやDB更新の関数化、承認フロー)
  • フロントエンド統合(ストリーミングUI、再実行UI、根拠表示UI)
  • セーフティとガードレール(プロンプトインジェクション対策、出力フィルタ)
  • 監視・運用(トークン/コスト/レイテンシのメトリクス、失敗ケース収集)

今回ご紹介する CopilotKit は、この一連の複雑な作業を大幅に省き、わずか数行のコードを追加するだけで既存の React アプリに AIエージェントを組み込むことができるんです。

本記事で実現すること

誰もが一度は触ったことがある React 公式チュートリアルの「○× ゲーム(Tic-Tac-Toe)」。このシンプルな実装にAIエージェント機能を組み込んでいきます。

  • 🤖 AI との対戦機能
  • 💬 チャットでゲームの操作
  • 🎯 次の一手のヒント機能

CopilotKit とは

CopilotKitは、アプリケーションのコンテキストを理解し、アシスタント機能であるAIエージェントをアプリケーションに簡単に追加できるフレームワークです。

www.copilotkit.ai

AI Tinkerers:One-ShotでCopiloKitのCEOのAtai Barakai氏がCopilotKitについて紹介している動画も貼っておきます。

www.youtube.com

CopilotKitはAG-UIを採用することで、アプリケーションとCopilot機能を実現するAIエージェント間の接続を抽象化しています。エージェントは、AG-UIをサポートする任意のエージェントフレームワークを使用して構築可能です。

AG-UI Overview - Agent User Interaction Protocol

Vibe Coding用のMCPサーバーも用意されています。

docs.copilotkit.ai

TicTacToe (OXゲーム)

今回AI機能を組み込む対象として、Reactを学んだことがある方なら必ず通るTicTacToeです。 これはReact公式のチュートリアルで最初に作成するアプリケーションです。

ja.react.dev

TicTacToe

AIを組み込む

それではこのTicTacToeにAIを組み込んでいきましょう。

API Keyの取得

まず、CopilotKit Cloud で無料の publicApiKey を取得します:

  1. CopilotKit Cloud にアクセス
  2. サインアップ(GitHub アカウントで簡単登録)
  3. ダッシュボードから publicApiKeyをコピー
  4. env.local ファイルに環境変数を設定

     VITE_COPILOT_CLOUD_API_KEY=your_api_key_here
    

Developerプランであれば 1000リクエスト, 100MAUまでは無料で利用できます。

https://www.copilotkit.ai/pricing

publicApiKeyはドメインロッキングをすることも可能です。 CopilotKit Cloudを使わずに 独自のバックエンドサーバーを使用することも可能です。

CopilotKitのセットアップ

パッケージのインストール

必要なパッケージをインストール:

npm install @copilotkit/react-core @copilotkit/react-ui
  • @copilotkit/react-core: コア機能(フック、コンテキスト管理)
  • @copilotkit/react-ui: UIコンポーネント(チャットサイドバー)

CopilotKit Provider設定

CopilotKitプロバイダーでアプリケーション全体をラップします。これにより提供されるhooksやcontextがアプリケーション全体で使用できるようになります。

// main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { CopilotKit } from '@copilotkit/react-core'
import { CopilotSidebar } from '@copilotkit/react-ui'
import '@copilotkit/react-ui/styles.css'
import App from './App'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <CopilotKit runtimeUrl={import.meta.env.VITE_COPILOT_CLOUD_API_KEY}>
      <CopilotSidebar
        instructions="あなたは○×ゲームのAIアシスタントです。"
        defaultOpen={true}
        labels={{
          title: "○×ゲーム AI アシスタント",
          initial: "こんにちは!一緒に○×ゲームをプレイしましょう!",
        }}
        clickOutsideToClose={false}
      >
        <App />
      </CopilotSidebar>
    </CopilotKit>
  </React.StrictMode>
)

AIと対話をするためのチャットコンポーネントとして、CopilotSidebar をいれます。instructions に指示を追加することにより 言語モデルにコンテキストやガイダンスを提供でき、応答内容に影響させることができます。 チャットコンポーネントとしては現在以下の3つが提供されています。

アプリケーションのコンテキストをCopilotに共有する - useCopilotReadable

useCopilotReadable は、アプリケーションの状態や知識を AIエージェントに提供するためのフックです。これにより、AIエージェントはアプリケーションの現在のコンテキストを理解し、より適切で文脈に沿った応答や提案を行えるようになります。

アプリケーション状態はReactのレンダリングサイクルと連携してリアルタイムで更新され、常に最新の情報がAIに伝わります。また、複数のuseCopilotReadableを使用して、異なる種類のデータを個別に管理することも可能です。

ここでは、TicTacToeのボードの状態、現在の誰のターンなのか、ゲームの勝敗状況、選択可能な手のリストをvalueとして渡すことで Copilotはアプリケーションの状態を把握します。

useCopilotReadable({
  description: "○×ゲームの現在の状態",
  value: {
    board: currentSquares.map((value, index) => ({
      position: index,
      value: value || '空き'
    })),
    currentPlayer: xIsNext ? 'X (人間)' : 'O (AI)',
    gameStatus: winner ? `勝者: ${winner}` : '進行中',
    availableMoves: currentSquares
      .map((value, index) => value === null ? index : null)
      .filter(index => index !== null)
  }
})

重要なポイントとして、valueの変更はReactの依存配列で検知されるため、オブジェクトを更新する際は新しいオブジェクトを作成する必要があります。

https://github.com/CopilotKit/CopilotKit/blob/0eca36fc3281c7ce07188641a94162fb97467efd/CopilotKit/packages/react-core/src/hooks/use-copilot-readable.ts#L117

Copilotにアプリケーション操作をさせる - useCopilotAction

useCopilotAction は、AIエージェントがアプリケーション内で特定のアクションを実行できるようにするフックです。これにより、AIエージェントは単なる情報提供だけでなく、実際にアプリケーションを操作できるようになります。

AIエージェントとフロントエンド間のインタラクティブな連携が可能になり、パラメータ検証とエラーハンドリングもサポートされているため、安全にアクションを実行できます。

useCopilotAction({
  name: "makeMove",
  description: "○×ゲームのボードに手を打つ",
  parameters: [
    {
      name: "position",
      type: "number",
      description: "AIがマークを置きたい位置(0-8)",
      required: true
    }
  ],
  handler: ({ position }) => {
    // バリデーション
    if (xIsNext) {
      return "今は人間プレイヤーのターンです"
    }
    if (currentSquares[position] !== null) {
      return `位置 ${position} はすでに埋まっています`
    }

    // アクション実行
    const nextSquares = currentSquares.slice()
    nextSquares[position] = 'O'
    handlePlay(nextSquares)

    return `AIが位置 ${position} にOを置きました`
  }
})

ハンドラー関数の戻り値はAIエージェントへのフィードバックとして使用されるため、エラー時は適切なエラーメッセージを返すことが重要です。また、非同期処理もサポートされているため、async/awaitを使用したデータベース操作やAPIコールも可能です。

コードからCopilotと対話させる - useCopilotChat

useCopilotChat フックを使うことで、プログラムからのメッセージ送信、チャット履歴の管理と操作、自動化されたチャットフローの実装が可能になります。

ここでは ユーザーが手を打った際に useCopilotChat フックを利用して自動でメッセージを送信することで AIエージェントに自動で指示をさせています。

const { appendMessage } = useCopilotChat()

// AIのターンになったら自動的にメッセージを送信
useEffect(() => {
  const sendAITurnMessage = async () => {
    if (!xIsNext && !winner) {
      const message = new TextMessage({
        role: MessageRole.User,
        content: 'あなたのターンです。最善の手を打ってください。',
      })

      await appendMessage(message)
    }
  }

  sendAITurnMessage()
}, [xIsNext, winner])

完成

これで、既存のTic-Tac-ToeがAI対戦ゲームに進化しました!

AIを組み込んだTicTacToe

ここまでのソースコードは以下を参照してください。 github.com

より高度にしていく

AIエージェントを追加しましたが、今のままではAIが弱く楽しくありません。

以下の機能を追加してAIエージェントをより賢くしていきましょう。

🆕 追加する機能

  • 🎯 難易度選択: 初級・中級・エキスパート
  • 🧠 戦略分析: Minimaxアルゴリズムを用いて AIに戦略を持たせる
  • 👁️ ヒント表示: 各マスの勝率を可視化

AIに戦略を持たせる

useCopilotReadable に戦略的なアルゴリズムの情報をもたせることでより賢くすることができます。ここではMinimaxアルゴリズムを実装して、Copilotに次の最適な手のヒントとしてのアルゴリズムを搭載します。

Minimaxアルゴリズムとは

ゲームの展開を木構造のように枝分かれさせて、将来起こりうる様々な局面を先読みし、最善の手を探し出す手法です。

基本的な考え方 - プレイヤーA(Max側):自分の得点を最大化したい。 - プレイヤーB(Min側):相手の得点を最小化したい(=自分に有利にしたい)。 - 双方が最適に動くと仮定して、次の手を決定する方法。

つまり、自分の手番では「もっとも良い手(スコア最大)」を選び、相手の手番では「相手が自分にとって最悪な手(スコア最小)」を選ぶと仮定して計算します。

MinimaxAIクラスは、○×ゲームにおいて最適な手を計算するためのアルゴリズム実装です。このクラスは以下の特徴を持ちます:

  • 戦略的評価: センターやコーナーの価値を考慮した局面評価
  • 効率的な探索: 深さ制限により、適切な計算時間で最適解を提供
  • 難易度調整機能: 初級・中級・エキスパートの3段階で強さを調整可能
class MinimaxAI {
  private maxDepth: number

  constructor(difficulty: Difficulty) {
    // 難易度によって探索の深さを調整
    // expert: 完全探索(深さ9)で高度なプレイ
    // intermediate: 深さ5で戦略的だが時々ミスをする
    // beginner: 深さ2で基本的な先読みのみ
    this.maxDepth = difficulty === 'expert' ? 9 :
                    difficulty === 'intermediate' ? 5 : 2
  }

  // 最適な手を計算するメインメソッド
  getBestMove(board: SquareValue[], isMaximizing: boolean): {
    position: number;
    score: number
  } {
    // 利用可能なマスをリストアップ
    const availableMoves = board
      .map((val, idx) => val === null ? idx : null)
      .filter(idx => idx !== null) as number[]

    if (availableMoves.length === 0) {
      return { position: -1, score: 0 }
    }

    let bestMove = availableMoves[0]
    let bestScore = isMaximizing ? -Infinity : Infinity

    // 各可能な手に対してMinimaxを実行
    for (const move of availableMoves) {
      const newBoard = [...board]
      newBoard[move] = isMaximizing ? 'O' : 'X'
      // 深さ0から探索開始、相手のターンとして評価
      const score = this.minimax(newBoard, 0, !isMaximizing)

      // 最大化プレイヤー(AI)の場合は最高スコアを選択
      if (isMaximizing && score > bestScore) {
        bestScore = score
        bestMove = move
      }
      // 最小化プレイヤー(人間)の場合は最低スコアを選択
      else if (!isMaximizing && score < bestScore) {
        bestScore = score
        bestMove = move
      }
    }

    return { position: bestMove, score: bestScore }
  }

  // Minimaxアルゴリズムの再帰的な実装
  private minimax(board: SquareValue[], depth: number, isMaximizing: boolean): number {
    const winner = calculateWinner(board)

    // 終端状態の評価
    if (winner === 'O') return 10 - depth  // AI勝利: 早い勝利ほど高評価
    if (winner === 'X') return depth - 10  // AI敗北: 遅い敗北ほどマシ
    if (board.every(cell => cell !== null)) return 0  // 引き分け

    // 深さ制限に達したら局面を評価
    if (depth >= this.maxDepth) return this.evaluatePosition(board)

    const availableMoves = board
      .map((val, idx) => val === null ? idx : null)
      .filter(idx => idx !== null) as number[]

    if (isMaximizing) {
      // AIのターン: 最大スコアを目指す
      let maxScore = -Infinity
      for (const move of availableMoves) {
        const newBoard = [...board]
        newBoard[move] = 'O'
        const score = this.minimax(newBoard, depth + 1, false)
        maxScore = Math.max(maxScore, score)
      }
      return maxScore
    } else {
      // 人間のターン: 最小スコアを目指す(AIにとって最悪の手)
      let minScore = Infinity
      for (const move of availableMoves) {
        const newBoard = [...board]
        newBoard[move] = 'X'
        const score = this.minimax(newBoard, depth + 1, true)
        minScore = Math.min(minScore, score)
      }
      return minScore
    }
  }

  // 中間局面の評価関数
  private evaluatePosition(board: SquareValue[]): number {
    let score = 0
    const lines = [
      [0, 1, 2], [3, 4, 5], [6, 7, 8], // 横の列
      [0, 3, 6], [1, 4, 7], [2, 5, 8], // 縦の列
      [0, 4, 8], [2, 4, 6] // 斜めの列
    ]

    // 各ラインの評価を集計
    for (const line of lines) {
      const lineScore = this.evaluateLine(board, line)
      score += lineScore
    }

    // 位置の戦略的価値を評価
    // センター(最も価値が高い)
    if (board[4] === 'O') score += 3
    if (board[4] === 'X') score -= 3

    // コーナー(2番目に価値が高い)
    const corners = [0, 2, 6, 8]
    for (const corner of corners) {
      if (board[corner] === 'O') score += 1
      if (board[corner] === 'X') score -= 1
    }

    return score
  }

  // 1つのライン(横/縦/斜め)を評価
  private evaluateLine(board: SquareValue[], positions: number[]): number {
    let oCount = 0
    let xCount = 0

    for (const pos of positions) {
      if (board[pos] === 'O') oCount++
      if (board[pos] === 'X') xCount++
    }

    // 両方のマークがある = このラインでは勝てない
    if (xCount > 0 && oCount > 0) return 0

    // スコアリング:多く並んでいるほど高得点
    if (oCount === 2) return 5   // AIがリーチ
    if (oCount === 1) return 1   // AIが1つ配置
    if (xCount === 2) return -5  // 人間がリーチ(危険)
    if (xCount === 1) return -1  // 人間が1つ配置

    return 0
  }
}

MinimaxとCopilotKitの統合

Minimax の計算結果を AI の戦略分析と組み合わせて表示

// 戦略的分析をuseCopilotReadableで共有
// 高度な戦略情報をCopilotKitと共有
  const copilotReadableValue = useMemo(
    () => ({
      board: currentSquares.map((value, index) => ({
        position: index,
        value: value || "空き",
        coordinates: { row: Math.floor(index / 3), col: index % 3 },
        isCenter: index === 4,
        isCorner: [0, 2, 6, 8].includes(index),
        isEdge: [1, 3, 5, 7].includes(index),
      })),

      gamePhase,
      difficulty,
      currentPlayer: xIsNext ? "X (人間)" : "O (AI)",

      strategicAnalysis: {
        winProbability: calculateWinProbability(currentSquares),
        criticalPositions: strategicAnalysis.criticalPositions,
        bestMove: !xIsNext ? ai.getBestMove(currentSquares, true) : null,
        threatLevel,
        possibleOutcomes: analyzePossibleOutcomes(),
      },

      patterns: {
        hasFork: detectFork(),
        hasBlockableFork: detectBlockableFork(),
        controlsCenter: currentSquares[4] !== null,
        cornerControl: countCornerControl(currentSquares),
      },

      recommendation: generateStrategicRecommendation(
        gamePhase,
        strategicAnalysis.criticalPositions
      ),

      debug: {
        moveScores: Array.from(strategicAnalysis.moveScores.entries()),
      },
    }),
    [
      currentSquares,
      gamePhase,
      difficulty,
      xIsNext,
      strategicAnalysis,
      threatLevel,
      ai,
    ]
  );

  useCopilotReadable({
    description: "高度な○×ゲーム戦略分析",
    value: copilotReadableValue,
  });

  // AIアクション
  useCopilotAction({
    name: "makeStrategicMove",
    description: "高度な戦略に基づいて○×ゲームに手を打つ",
    parameters: [
      {
        name: "position",
        type: "number",
        description: "AIが選択した位置(0-8)",
        required: true,
      },
      {
        name: "strategy",
        type: "string",
        description: "この手の戦略的理由",
        required: false,
      },
    ],
    handler: ({ position, strategy }) => {
      if (xIsNext) {
        return "人間のターンです";
      }
      if (winner || currentSquares.every((square) => square !== null)) {
        return "ゲーム終了";
      }
      if (currentSquares[position] !== null) {
        return `位置 ${position} は埋まっています`;
      }

      const nextSquares = currentSquares.slice();
      nextSquares[position] = "O";
      handlePlay(nextSquares);

      return `AI: 位置 ${position} に配置${strategy ? ` (${strategy})` : ""}`;
    },
  });

完成

この実装により、AIは単にランダムに手を打つのではなく、Minimaxアルゴリズムに基づいた高度な戦略でプレイし、その思考過程をリアルタイムでUIに表示できます。

高度なTicTacToe

完全版のソースコードはこちら:

github.com

まとめ

CopilotKitを使えば、既存のアプリケーションに簡単にAI機能を追加できます。 本記事で紹介したシンプルな例を参考に、ぜひあなたのアプリケーションにもAIの力を取り入れてみてください。


今回の記事を読んで気になった方やLayerXについて知りたい方は、ぜひカジュアル面談でおしゃべりしましょう!

jobs.layerx.co.jp

採用情報についてはこちらです。 jobs.layerx.co.jp