5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CopilotKit を使って Generative UIに触れてみる

5
Last updated at Posted at 2026-03-30

はじめに

今回は、CopilotKitを使ってAIがUIを生成する「Generative UI」を試してみます。

CopilotKitとは、AIエージェントアプリケーションを構築するためのフレームワークで、フロントエンドの処理を抽象化しつつGenerative UIを含む様々な機能を提供しています。

↓ GenerativeUIについては別途まとめているので、以下をご参照ください。

CopilotKitでGenerativeUIを実装するには?

Generative UIのアプローチ

Generative UIは、大きく3つのアプローチに分類されます。

このうち、CopilotKitはStatic(静的)なGenerative UIにあたります。
つまり、AIエージェントのレスポンスをトリガーにフロントエンド側で定義したUIを描画します。

No アプローチ 説明
1 Open-Ended 既に用意されたUIからAIエージェントが適切なUIを選択する MCP Apps
2 Declarative AIエージェントが動的にUIの情報を生成する A2UI
3 Static AIエージェントの実行結果をもとにフロントエンドでUIを組み立てる CopilotKit, AI SDK UI

参考

CopilotKitはGenerative UIに対応したReactフックを提供する

前提としてCopilotKitでAIエージェントを構築する場合、以下のような構成になります。

simple_architecture.png

フロントエンド側では、Generative UIを実現するためにCopilotKitがReactのフックを用意しています。

No パターン ツール実行場所 フック 説明
1 ツールの実行結果をUIで表示 バックエンド useRenderTool バックエンド側でツールを実行し、結果をUIに描画する
2 フロントエンド useFrontendTool フロントエンド側でツールを実行し、結果をUIに描画する
3 Human In The Loop なし useHumanInTheLoop 処理を一時停止してユーザーの入力・承認を待つ
4 シンプルにUIを表示 なし useComponent AIエージェントが表示タイミングを決定し、表示する

※ 他にもフックは提供されていますが、代表的なものをピックアップしています。

ちなみにCopiotKitのSandboxのようなお試しサイトがあるので、触ってみるとイメージが湧きます。コードや解説もついてるので、いい感じです!

No1 & No2. ツールの実行結果をUIで表示

本記事では以下の前提のもと検証を行なっています。

  • UIはReactを使用しています
  • AIエージェント側の実装はAWSのStrandsAgentsを使用しています
  • CopilotKitはv1.54.0を使用しています

バックエンド側でツール実行 (useRenderTool)

AIエージェントを開発しているとバックエンド側でツール実行する場合が往々にしてあると思います。
そして、その実行結果を見やすいUIで表示したいケースが出てきます。

  • Web検索結果をリストアップ
  • DBから収集したデータの可視化(テーブル、グラフなど)
  • 経路探索やヒートマップを地図表示

その場合は、CopilotKitのuseRenderToolのフックを使用すると実現できます。

以下の例では、AIエージェントがTavily Searchを実行して、AWSのイベントを検索してくれています。
その結果をテキストで列挙してもいいのですが、できれば見やすくしたかったので、useRenderToolのフックを使用してカード表示しています。

useRenderToolは、AIエージェントがバックエンドで実行したツールの実行結果をUIへどう描画するかを定義します。

以下はStrandsAgentsで書いたツールの実装ですが、ツール名はフロントエンドのuseRenderToolと一致させる必要があるため注意が必要です。(以下ではsearch_eventsとしています)

@tool
def search_events(
    query: str,
    location: Optional[str] = None,
    date_from: Optional[str] = None,
    date_to: Optional[str] = None,
    platform: Optional[str] = "connpass",
    max_results: int = 10
) -> Dict[str, Any]:
詳細なツールの実装
search_tool.py
"""
検索ツール
Tavilyを使用したイベント検索機能
"""
import os
import re
import hashlib
import json
from datetime import datetime
from typing import Optional, Dict, Any
from strands import tool
from tavily import TavilyClient


TAVILY_API_KEY = os.environ.get("TAVILY_API_KEY", "")


def _is_valid_event_url(url: str, platform: str) -> bool:
    """
    URLがイベントページかどうかを判定する
    
    Args:
        url: 検証するURL
        platform: プラットフォーム名
    
    Returns:
        イベントページの場合True、それ以外False
    """
    url_lower = url.lower()
    
    # connpassの場合、/event/<数字ID>/ 形式のURLのみ有効(一覧・検索ページを除外)
    if platform == "connpass" and "connpass.com" in url_lower:
        return bool(re.search(r'/event/\d+/', url_lower))
    
    # 他のプラットフォームは全て有効
    return True


def _infer_platform(url: str) -> str:
    """URLからプラットフォーム名を推測する"""
    url_lower = url.lower()
    if "connpass.com" in url_lower:
        return "connpass"
    elif "techplay.jp" in url_lower:
        return "techplay"
    elif "doorkeeper.jp" in url_lower:
        return "doorkeeper"
    elif "meetup.com" in url_lower:
        return "meetup"
    elif "eventbrite.com" in url_lower:
        return "eventbrite"
    elif "lu.ma" in url_lower:
        return "luma"
    else:
        return "unknown"


def _build_query(
    query: str,
    location: Optional[str],
    date_from: Optional[str] = None,
) -> str:
    """
    検索クエリを組み立てる
    
    Args:
        query: 検索キーワード
        location: 開催地
        date_from: 開始日(ISO 8601形式、例: "2026-03-04")
    
    Returns:
        組み立てられた検索クエリ
    """
    parts = [query]

    if location:
        parts.append(location)
    
    # date_fromから年月を抽出してクエリに含める
    if date_from:
        try:
            # ISO 8601形式をパース("2026-03-04" または "2026-03-04T10:00:00+09:00")
            if 'T' in date_from:
                dt = datetime.fromisoformat(date_from.replace('Z', '+00:00'))
            else:
                dt = datetime.strptime(date_from, "%Y-%m-%d")
            
            # 年月を追加(例: "2026年3月")
            year_month = f"{dt.year}{dt.month}"
            parts.append(year_month)
        except (ValueError, AttributeError):
            # パースに失敗した場合はスキップ
            pass

    return " ".join(parts)


def _platform_domain(platform: str) -> str:
    """プラットフォーム名からドメインを返す"""
    domains = {
        "connpass": "connpass.com",
        "techplay": "techplay.jp",
        "doorkeeper": "doorkeeper.jp",
        "meetup": "meetup.com",
        "eventbrite": "eventbrite.com",
        "luma": "lu.ma",
    }
    return domains.get(platform.lower(), platform)


def _is_online_event(title: str, content: str) -> bool:
    """オンラインイベントかどうか判定する"""
    keywords = ["オンライン", "online", "zoom", "teams", "webinar", "ウェビナー", "リモート", "remote", "virtual"]
    text = (title + " " + content).lower()
    return any(kw in text for kw in keywords)


def _extract_date_from_text(text: str) -> Optional[str]:
    """
    テキストから日付を抽出する(簡易版)
    
    Args:
        text: 検索対象のテキスト
    
    Returns:
        YYYY-MM-DD形式の日付文字列、見つからない場合はNone
    """
    # YYYY年MM月DD日形式
    pattern1 = r'(\d{4})年(\d{1,2})月(\d{1,2})日'
    match = re.search(pattern1, text)
    if match:
        year, month, day = match.groups()
        return f"{year}-{month.zfill(2)}-{day.zfill(2)}"
    
    # YYYY/MM/DD または YYYY-MM-DD形式
    pattern2 = r'(\d{4})[/-](\d{1,2})[/-](\d{1,2})'
    match = re.search(pattern2, text)
    if match:
        year, month, day = match.groups()
        return f"{year}-{month.zfill(2)}-{day.zfill(2)}"
    
    # MM月DD日形式(年は現在年を仮定)
    pattern3 = r'(\d{1,2})月(\d{1,2})日'
    match = re.search(pattern3, text)
    if match:
        month, day = match.groups()
        current_year = datetime.now().year
        return f"{current_year}-{month.zfill(2)}-{day.zfill(2)}"
    
    return None


def _extract_location_from_text(text: str) -> Optional[str]:
    """
    テキストから場所を抽出する(簡易版)
    
    Args:
        text: 検索対象のテキスト
    
    Returns:
        場所の文字列、見つからない場合はNone
    """
    # 「会場:」「場所:」「開催地:」などのパターン
    patterns = [
        r'会場[::]\s*([^\n\r、。]+)',
        r'場所[::]\s*([^\n\r、。]+)',
        r'開催地[::]\s*([^\n\r、。]+)',
        r'住所[::]\s*([^\n\r、。]+)',
    ]
    
    for pattern in patterns:
        match = re.search(pattern, text)
        if match:
            location = match.group(1).strip()
            # 括弧内の補足情報を除去
            location = re.sub(r'[\(\)()\[\]【】].*', '', location).strip()
            if location:
                return location
    
    return None


def _normalize_date(date_str: str) -> str:
    """
    ISO 8601形式の日付をYYYY-MM-DD形式に変換する
    
    Args:
        date_str: ISO 8601形式の日付文字列(例: "2026-03-04T10:00:00+09:00" または "2026-03-04")
    
    Returns:
        YYYY-MM-DD形式の日付文字列(例: "2026-03-04""""
    try:
        # ISO 8601形式をパース
        if 'T' in date_str:
            # タイムゾーン情報を含む場合
            dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
        else:
            # 日付のみの場合
            dt = datetime.strptime(date_str, "%Y-%m-%d")
        
        # YYYY-MM-DD形式で返す
        return dt.strftime("%Y-%m-%d")
    except (ValueError, AttributeError):
        # パースに失敗した場合は元の文字列を返す
        return date_str


def _extract_event_info(result: dict) -> dict:
    """Tavily の検索結果1件からイベント情報を抽出する"""
    title = result.get("title", "")
    url = result.get("url", "")
    content = result.get("content", "")
    score = result.get("score", 0.0)
    published_date = result.get("published_date")

    # ID はURLのハッシュ
    event_id = hashlib.md5(url.encode()).hexdigest()[:12]

    # タイトルとコンテンツから日付と場所を抽出
    combined_text = f"{title} {content}"
    extracted_date = _extract_date_from_text(combined_text)
    extracted_location = _extract_location_from_text(combined_text)

    return {
        "id": event_id,
        "title": title,
        "url": url,
        "content": content,
        "platform": _infer_platform(url),
        "score": score,
        "date": published_date or extracted_date,  # published_dateを優先
        "location": extracted_location,
        "is_online": _is_online_event(title, content),
    }


@tool
def search_events(
    query: str,
    location: Optional[str] = None,
    date_from: Optional[str] = None,
    date_to: Optional[str] = None,
    platform: Optional[str] = "connpass",
    max_results: int = 10
) -> Dict[str, Any]:
    """
    Web検索を実行してイベント情報を検索します。勉強会やカンファレンスを探す際に使用してください。
    
    ## 使い方のルール
    
    【重要】ツールの結果を捏造・改変してはいけない
    - search_eventsが返した実データのみ使用
    - 架空のイベントを作成禁止
    
    【日付フィルタリング】
    - date_fromを指定すると、それより前のイベントはツール側で除外されます
    - date_fromを省略した場合は今日の日付が自動的に使用されます(過去イベントを常に除外)
    - date_toを指定すると、それより後のイベントはツール側で除外されます
    - 日付が不明なイベント(date=None)はフィルタリングされず結果に含まれます
    
    【検索クエリの工夫】
    - date_fromを指定すると、自動的に年月がクエリに追加されます
    - 例: date_from="2026-03-04" → クエリに"2026年3月"が追加
    - これにより、関連性の高いイベントが検索されやすくなります
    
    Args:
        query: 検索キーワード(例: "Python", "機械学習", "AWS")
        location: 開催地(例: "東京", "Tokyo", "大阪")
        date_from: 開始日(ISO 8601形式、例: "2026-03-01")。過去のイベントを除外するため、current_timeツールで現在日時を取得して指定すること
        date_to: 終了日(ISO 8601形式、例: "2026-03-31")
        platform: プラットフォーム指定(デフォルト: "connpass")。他の選択肢: "techplay", "doorkeeper", "meetup", "eventbrite", "luma"
        max_results: 最大取得件数(デフォルト: 10)
    
    Returns:
        検索結果の辞書:
        {
            "events": List[Dict],  # イベント情報のリスト
            "count": int,          # 取得したイベント数
            "query": str           # 実行した検索クエリ
        }
        
        各イベントは以下の構造:
        {
            "id": str,              # イベントID(URLのハッシュ)
            "title": str,           # イベントタイトル
            "url": str,             # イベントURL
            "platform": str,        # プラットフォーム名
            "content": str,         # イベント説明(抜粋)
            "score": float,         # 関連度スコア(0.0-1.0)
            "date": str,            # 開催日時(YYYY-MM-DD形式、抽出できた場合)
            "location": str,        # 開催場所(抽出できた場合)
            "is_online": bool       # オンライン/オフライン判定
        }
    """

    if not TAVILY_API_KEY:
        return {
            "error": "TAVILY_API_KEY is not set",
            "events": [],
            "count": 0
        }

    try:
        tavily = TavilyClient(api_key=TAVILY_API_KEY)

        # 検索クエリ組み立て(date_fromを渡して年月をクエリに含める)
        search_query = _build_query(query, location, date_from)

        # Tavily 検索オプション
        search_kwargs: dict = {
            "max_results": max_results,
            "topic": "general",
        }

        # プラットフォーム指定がある場合はドメイン絞り込み
        if platform:
            search_kwargs["include_domains"] = [_platform_domain(platform)]

        # 検索実行
        response = tavily.search(search_query, **search_kwargs)

        # 結果を整形してフィルタリング
        events = []
        for result in response.get("results", []):
            event = _extract_event_info(result)
            
            # プラットフォーム固有のURLフィルタリング
            if not _is_valid_event_url(event["url"], event["platform"]):
                continue

            # 日付不明なイベントは除外
            if not event.get("date"):
                continue

            # 日付フィルタリング(date_fromが未指定の場合は今日をデフォルトとして使用)
            effective_date_from = date_from or datetime.now().strftime("%Y-%m-%d")
            try:
                event_date = _normalize_date(event["date"])
                from_date = _normalize_date(effective_date_from)
                if event_date < from_date:
                    continue
            except Exception:
                continue  # 日付パース失敗時も除外

            # 日付フィルタリング(date_toが指定されている場合)
            if date_to and event.get("date"):
                try:
                    event_date = _normalize_date(event["date"])
                    to_date = _normalize_date(date_to)
                    if event_date > to_date:
                        continue
                except Exception:
                    pass

            events.append(event)

        return {
            "events": events,
            "count": len(events),
            "query": search_query
        }

    except Exception as e:
        return {
            "error": f"Search failed: {str(e)}",
            "events": [],
            "count": 0
        }


次にフロントエンド側のツールの実行結果を描画する部分をuseRenderTool
実装します。

useRenderToolに必要なパラメーターは以下になります。

パラメーター 説明
name エージェント側のツール名と一致させる必要があります。
CopilotKitはこの名称を使って、どのツールの実行結果をどう描画するか判断します
parameters AIエージェントへリクエストする際の情報を設定します
render 実際にツールの実行結果として表示したいUIを定義します。
result にツールの実行結果が渡ってきます
export function useSearchEventsRenderer() {
  useRenderTool({
    name: 'search_events',
    parameters: z.object({
      query: z.string().optional(),
      location: z.string().optional(),
      date_from: z.string().optional(),
      date_to: z.string().optional(),
      platform: z.string().optional(),
      max_results: z.number().optional(),
    }),
    render: ({ parameters: args, status, result }) => {
      console.log('[useSearchEventsRenderer] Render called:', { status, args });

      if (status === ToolCallStatus.Complete && result) {
        try {
          const parsedResult = typeof result === 'string' ? JSON.parse(result) : result;
          const events = parsedResult.events || [];
          const count = parsedResult.count ?? events.length;
          const query = parsedResult.query || args?.query || '';

          return (
            <EventSearchResultsView
              events={events}
              query={query}
              count={count}
            />
          );
        } catch (error) {
          console.error('[useSearchEventsRenderer] Error parsing result:', error);
          return (
            <div className="p-4 bg-red-50 border border-red-200 rounded">
              <p className="text-red-800">{i18n.t('searchResults.parseError')}</p>
              <pre className="text-xs mt-2 text-red-600">{String(error)}</pre>
            </div>
          );
        }
      }

      return <></>;
    },
  });
}
詳細なuseRenderToolの実装
useSearchEvents.tsx

/**
 * イベント検索ツールのRender Tool
 */
import { useRenderTool } from '@copilotkit/react-core/v2';
import { ToolCallStatus } from '@copilotkit/react-core/v2';
import { z } from 'zod';
import i18n from '@/i18n';
import { Calendar, MapPin, Users, ExternalLink } from 'lucide-react';

interface EventResult {
  title: string;
  url: string;
  date?: string;
  location?: string;
  description?: string;
  platform?: string;
  content?: string;
}

function EventSearchResultsView({
  events,
  query,
  count,
}: {
  events: EventResult[];
  query: string;
  count: number;
}) {
  return (
    <div className="space-y-4">
      <div className="flex items-center justify-between border-b pb-3">
        <h3 className="text-lg font-semibold text-gray-900">
          {i18n.t('searchResults.header', { query })}
        </h3>
        <span className="text-sm text-gray-500">{i18n.t('searchResults.count', { count })}</span>
      </div>

      <div className="space-y-3">
        {events.map((event, index) => (
          <div
            key={`${event.url}-${index}`}
            className="border rounded-lg p-4 hover:shadow-md transition-shadow bg-white"
          >
            <div className="flex items-start justify-between mb-2">
              <h4 className="font-medium text-gray-900 flex-1">{event.title}</h4>
              <a
                href={event.url}
                target="_blank"
                rel="noopener noreferrer"
                className="text-blue-600 hover:text-blue-800 ml-2"
              >
                <ExternalLink className="w-4 h-4" />
              </a>
            </div>

            <div className="flex flex-wrap gap-3 text-sm text-gray-600 mb-2">
              {event.date && (
                <div className="flex items-center gap-1">
                  <Calendar className="w-4 h-4" />
                  <span>{event.date}</span>
                </div>
              )}
              {event.location && (
                <div className="flex items-center gap-1">
                  <MapPin className="w-4 h-4" />
                  <span>{event.location}</span>
                </div>
              )}
              {event.platform && (
                <div className="flex items-center gap-1">
                  <Users className="w-4 h-4" />
                  <span>{event.platform}</span>
                </div>
              )}
            </div>

            {(event.description || event.content) && (
              <p className="text-sm text-gray-700 line-clamp-2">
                {event.description || event.content}
              </p>
            )}
          </div>
        ))}
      </div>

      {count === 0 && (
        <div className="text-center py-8 text-gray-500">
          <p>{i18n.t('searchResults.noResults')}</p>
          <p className="text-sm mt-2">{i18n.t('searchResults.noResultsHint')}</p>
        </div>
      )}
    </div>
  );
}

/**
 * search_eventsツールをuseRenderToolで登録するフック
 */
export function useSearchEventsRenderer() {
  useRenderTool({
    name: 'search_events',
    parameters: z.object({
      query: z.string().optional(),
      location: z.string().optional(),
      date_from: z.string().optional(),
      date_to: z.string().optional(),
      platform: z.string().optional(),
      max_results: z.number().optional(),
    }),
    render: ({ parameters: args, status, result }) => {
      console.log('[useSearchEventsRenderer] Render called:', { status, args });

      if (status === ToolCallStatus.Complete && result) {
        try {
          const parsedResult = typeof result === 'string' ? JSON.parse(result) : result;
          const events = parsedResult.events || [];
          const count = parsedResult.count ?? events.length;
          const query = parsedResult.query || args?.query || '';

          return (
            <EventSearchResultsView
              events={events}
              query={query}
              count={count}
            />
          );
        } catch (error) {
          console.error('[useSearchEventsRenderer] Error parsing result:', error);
          return (
            <div className="p-4 bg-red-50 border border-red-200 rounded">
              <p className="text-red-800">{i18n.t('searchResults.parseError')}</p>
              <pre className="text-xs mt-2 text-red-600">{String(error)}</pre>
            </div>
          );
        }
      }

      return <></>;
    },
  });
}

フロントエンド側でツール実行 (useFrontendTool)

エージェントがフロントエンド側でツールを実行したい場合、useFrontendToolが提供されています。

手元に実装例がないため、公式ページのサンプルで説明します。

AIエージェント側では、フロントエンド側のツールを紐づけるため、スタブのツールを定義します。
スタブのツールではNoneをreturnします。

from strands import Agent, tool

@tool
def getWeather(city: str, units: str):
    """
    Get the current weather for a city.
    Args:
        city: The name of the city to get weather for.
        units: The unit system to use (e.g. "metric" or "imperial").
    Returns:
        None - execution happens on the frontend
    """
    return None

フロントエンド側では、useFrontendToolを定義します。

本来は、エージェントがフロントエンド側でツール実行するためのフックになります。そのオプションとしてrenderというUI描画の機能も提供されているため、GenerativeUIも可能となります。

useFrontendToolに必要なパラメーターは以下になります。

パラメーター 説明
name エージェント側のツール名と一致させる必要があります。
useRenderToolと同様です。
description AIエージェントへツールの説明を設定します。
parameters AIエージェントへリクエストする際の情報を設定します。
handler フロントエンド側で実行するツールのロジックを定義します。
render ツールの実行結果として表示したいUIを定義します。
import { z } from "zod";
import { useFrontendTool, ToolCallStatus } from "@copilotkit/react-core/v2";

function WeatherWidget() {
  useFrontendTool({
    name: "getWeather",
    description: "Fetch and display weather information for a city",
    parameters: z.object({
      city: z.string().describe("City name"),
      units: z.enum(["celsius", "fahrenheit"]).default("celsius"),
    }),
    handler: async ({ city, units }, { signal }) => {
      const response = await fetch(`/api/weather?city=${city}&units=${units}`, { signal });
      const data = await response.json();
      return JSON.stringify(data);
    },
    render: ({ args, status, result }) => {
      if (status === ToolCallStatus.InProgress) {
        return <div className="animate-pulse">Fetching weather for {args.city}...</div>;
      }
      if (status === ToolCallStatus.Complete && result) {
        const data = JSON.parse(result);
        return (
          <div className="p-4 border rounded">
            <h3>{data.city}</h3>
            <p>{data.temperature}&deg; {data.units}</p>
            <p>{data.conditions}</p>
          </div>
        );
      }
      return null;
    },
  }, []);

  return null;
}

No3. Human In The Loop

AIエージェントとユーザーとの対話の中で、エージェントがユーザーに判断を委ねたいケースがあります。(Human In The Loop)
CopilotKitではuseHumanInTheLoopが用意されています。

以下の例では、AIエージェントがイベントの日程や目標といった計画を立て、妥当性をユーザーに判断してもらいます。

useHumanInTheLoopは、useFrontendToolと同様AIエージェントにスタブのツールを定義する必要があります。
ツールでは必ずNoneを返却し、nameやパラメータはuseHumanInTheLoop側と一致する必要があります。

詳細なバックエンド側の実装
@tool
def propose_plan(
    event_title: str,
    event_date: str,
    goals: List[str],
    schedule: List[Dict[str, Any]],
    event_description: Optional[str] = None,
    event_type: Optional[str] = None,
    event_end_date: Optional[str] = None,
    event_location: Optional[Dict[str, Any]] = None,
    event_url: Optional[str] = None,
    event_tags: Optional[List[str]] = None,
    event_organizer: Optional[Dict[str, Any]] = None,
    event_level: Optional[str] = None,
    actions: Optional[List[Dict[str, Any]]] = None,
    detours: Optional[List[Dict[str, Any]]] = None,
) -> None:
    """
    ユーザーに計画内容を提案して、承認を得ます。
    承認されたら、その内容でcreate_planを実行します。
    
    【重要】このツールはcreate_planの前に必ず実行してください。
    【重要】ユーザーが承認した場合のみ、返された内容でcreate_planを実行してください。
    
    Args:
        event_title: イベントタイトル(必須)
        event_date: イベント開催日時(ISO 8601形式、必須)
        goals: 目標のリスト(必須)
        schedule: スケジュールの辞書のリスト(必須)
        detours: 寄り道情報の辞書のリスト(オプション)
        event_description: イベント説明(オプション)
        event_type: イベントタイプ(オプション)
        event_end_date: イベント終了日時(ISO 8601形式、オプション)
        event_location: イベント開催場所(オプション)
        event_url: イベントURL(オプション)
        event_tags: イベントタグ(オプション)
        event_organizer: 主催者情報(オプション)
        event_level: イベントレベル(オプション)
        actions: アクションの辞書のリスト(オプション)
    
    Returns:
        None(フロントエンドのhandlerが実際の処理を行います)
        ユーザーの承認結果:
        {
            "approved": bool,  # 承認されたかどうか
            ... # 承認された場合は、すべての引数がそのまま返される
        }
    """
    return None

次にフロントエンド側を useHumanInTheLoop で実装します。

useHumanInTheLoopに必要なパラメーターは以下になります。

パラメーター 説明
name バックエンド側のツール名と一致させる必要があります。
description AIエージェントへのツールの説明文を設定します
parameters AIエージェントへリクエストする際の情報を設定します
render 表示したいUIを定義します。statusにツールの実行状況(inProgress / executing / complete)、respond にエージェントへユーザーの操作を渡す関数が渡ってきます

設定はuseFrontendToolと大体一緒ですが、useHumanInTheLoophandlerがありません。
そのため、useHumanInTheLoopではロジックを実行することが出来ません。

詳細なフロントエンド側の実装
import { useHumanInTheLoop } from '@copilotkit/react-core/v2';
import { z } from 'zod';
import i18n from '@/i18n';
import { useTranslation } from 'react-i18next';
import { CheckCircle, XCircle, Calendar, MapPin, Target, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button';

// 日時フォーマット
function formatDateTime(isoString: string) {
  const date = new Date(isoString);
  return date.toLocaleString('ja-JP', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
  });
}

interface ProposePlanFormProps {
  args: {
    event_title: string;
    event_date: string;
    event_description?: string;
    event_type?: 'meetup' | 'conference' | 'workshop' | 'handson' | 'seminar';
    event_end_date?: string;
    event_location?: { name: string; address: string; coordinates: [number, number] };
    event_url?: string;
    event_tags?: string[];
    event_organizer?: { name: string; url?: string; contact?: string };
    event_level?: 'beginner' | 'intermediate' | 'advanced';
    goals: string[];
    schedule: { time: string; activity: string; location: string; duration: number; notes?: string }[];
    actions?: { goalId: string; phase: 'preparation' | 'event_day'; action: string; description?: string; priority?: 'high' | 'medium' | 'low'; estimatedTime?: number }[];
    detours?: { timing: 'before' | 'after'; place: string; purpose: string; latitude: number; longitude: number; notes?: string }[];
  };
  onApprove: () => void;
  onReject: () => void;
}

function ProposePlanForm({ args, onApprove, onReject }: ProposePlanFormProps) {
  const { t } = useTranslation();

  return (
    <div className="bg-white rounded-lg shadow-lg p-6 my-4 max-w-4xl">
      <div className="flex items-center gap-2 mb-6">
        <CheckCircle className="w-6 h-6 text-blue-600" />
        <h2 className="text-2xl font-semibold text-gray-900">{t('proposePlan.title')}</h2>
      </div>

      <div className="mb-6 p-4 bg-blue-50 rounded-lg">
        <h3 className="text-lg font-semibold text-gray-900 mb-3">{args.event_title}</h3>
        <div className="space-y-2 text-sm">
          <div className="flex items-center gap-2">
            <Calendar className="w-4 h-4 text-blue-600" />
            <span className="text-gray-700">
              {formatDateTime(args.event_date)}
              {args.event_end_date && ` 〜 ${formatDateTime(args.event_end_date)}`}
            </span>
          </div>
          {args.event_location && (
            <div className="flex items-center gap-2">
              <MapPin className="w-4 h-4 text-blue-600" />
              <span className="text-gray-700">{args.event_location.name}</span>
            </div>
          )}
          {args.event_type && (
            <div className="inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
              {t(`eventType.${args.event_type}`)}
            </div>
          )}
          {args.event_level && (
            <div className="inline-block px-3 py-1 bg-green-100 text-green-800 rounded-full text-xs font-medium ml-2">
              {t(`eventLevel.${args.event_level}`)}
            </div>
          )}
        </div>
        {args.event_description && (
          <p className="mt-3 text-sm text-gray-600">{args.event_description}</p>
        )}
      </div>

      <div className="mb-6">
        <div className="flex items-center gap-2 mb-3">
          <Target className="w-5 h-5 text-green-600" />
          <h3 className="text-lg font-semibold text-gray-900">{t('proposePlan.goals')}</h3>
        </div>
        <ul className="space-y-2">
          {args.goals.map((goal, index) => (
            <li key={index} className="flex items-start gap-2 text-sm text-gray-700">
              <span className="text-green-600 font-semibold">{index + 1}.</span>
              <span>{goal}</span>
            </li>
          ))}
        </ul>
      </div>

      <div className="mb-6">
        <div className="flex items-center gap-2 mb-3">
          <Clock className="w-5 h-5 text-purple-600" />
          <h3 className="text-lg font-semibold text-gray-900">{t('proposePlan.schedule')}</h3>
        </div>
        <div className="space-y-3">
          {args.schedule.map((item, index) => (
            <div key={index} className="flex gap-3 p-3 bg-gray-50 rounded-lg">
              <div className="text-sm font-semibold text-purple-600 min-w-[60px]">{item.time}</div>
              <div className="flex-1">
                <div className="text-sm font-medium text-gray-900">{item.activity}</div>
                <div className="text-xs text-gray-600 mt-1">
                  📍 {item.location}  ⏱️ {i18n.t('tool.durationMin', { min: item.duration })}
                </div>
                {item.notes && <div className="text-xs text-gray-500 mt-1">💡 {item.notes}</div>}
              </div>
            </div>
          ))}
        </div>
      </div>

      {args.actions && args.actions.length > 0 && (
        <div className="mb-6">
          <h3 className="text-lg font-semibold text-gray-900 mb-3">{t('proposePlan.actions')}</h3>
          <div className="space-y-2">
            {args.actions.map((action, index) => (
              <div key={index} className="flex gap-2 p-2 bg-gray-50 rounded text-sm">
                <span className={`px-2 py-1 rounded text-xs font-medium ${
                  action.phase === 'preparation' ? 'bg-blue-100 text-blue-800' : 'bg-orange-100 text-orange-800'
                }`}>
                  {action.phase === 'preparation' ? t('proposePlan.preparation') : t('proposePlan.eventDay')}
                </span>
                <span className="text-gray-700">{action.action}</span>
              </div>
            ))}
          </div>
        </div>
      )}

      {args.detours && args.detours.length > 0 && (
        <div className="mb-6">
          <div className="flex items-center gap-2 mb-3">
            <MapPin className="w-5 h-5 text-teal-600" />
            <h3 className="text-lg font-semibold text-gray-900">{t('proposePlan.detours')}</h3>
          </div>
          <div className="space-y-2">
            {args.detours.map((detour, index) => (
              <div key={index} className="flex gap-2 p-3 bg-teal-50 rounded-lg text-sm">
                <span className={`px-2 py-1 rounded text-xs font-medium ${
                  detour.timing === 'before' ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'
                }`}>
                  {detour.timing === 'before' ? t('proposePlan.before') : t('proposePlan.after')}
                </span>
                <div>
                  <div className="font-medium text-gray-900">{detour.place}</div>
                  <div className="text-gray-600 mt-1">{detour.purpose}</div>
                  {detour.notes && <div className="text-gray-500 text-xs mt-1">💡 {detour.notes}</div>}
                </div>
              </div>
            ))}
          </div>
        </div>
      )}

      <div className="flex gap-3 pt-4 border-t">
        <Button onClick={onApprove} className="flex-1">
          <CheckCircle className="w-5 h-5 mr-2" />
          {t('proposePlan.approve')}
        </Button>
        <Button variant="outline" onClick={onReject}>
          <XCircle className="w-5 h-5 mr-2" />
          {t('proposePlan.reject')}
        </Button>
      </div>
    </div>
  );
}

/**
 * プラン提案ツール
 */
export function useProposePlanTool() {
  useHumanInTheLoop({
    name: 'propose_plan',
    description: 'ユーザーに計画内容を提案して、承認を得ます。承認されたら、その内容でcreate_planを実行します。',
    parameters: z.object({
      event_title: z.string().describe('イベントタイトル'),
      event_date: z.string().describe('イベント開催日時(ISO 8601形式)'),
      event_description: z.string().optional().describe('イベント説明'),
      event_type: z.enum(['meetup', 'conference', 'workshop', 'handson', 'seminar']).optional().describe('イベントタイプ'),
      event_end_date: z.string().optional().describe('イベント終了日時(ISO 8601形式)'),
      event_location: z.object({
        name: z.string(),
        address: z.string(),
        coordinates: z.tuple([z.number(), z.number()]),
      }).optional().describe('イベント開催場所'),
      event_url: z.string().optional().describe('イベントURL'),
      event_tags: z.array(z.string()).optional().describe('イベントタグ'),
      event_organizer: z.object({
        name: z.string(),
        url: z.string().optional(),
        contact: z.string().optional(),
      }).optional().describe('主催者情報'),
      event_level: z.enum(['beginner', 'intermediate', 'advanced']).optional().describe('イベントレベル'),
      goals: z.array(z.string()).describe('目標のリスト'),
      schedule: z.array(z.object({
        time: z.string(),
        activity: z.string(),
        location: z.string(),
        duration: z.number(),
        notes: z.string().optional(),
      })).describe('スケジュール'),
      actions: z.array(z.object({
        goalId: z.string(),
        phase: z.enum(['preparation', 'event_day']),
        action: z.string(),
        description: z.string().optional(),
        priority: z.enum(['high', 'medium', 'low']).optional(),
        estimatedTime: z.number().optional(),
      })).optional().describe('アクション'),
      detours: z.array(z.object({
        timing: z.enum(['before', 'after']),
        place: z.string(),
        purpose: z.string(),
        latitude: z.number(),
        longitude: z.number(),
        notes: z.string().optional(),
      })).optional().describe('寄り道情報'),
    }),
    render: ({ status, args, respond }) => {
      if (status === 'inProgress') {
        return (
          <div className="flex items-center gap-2 text-sm text-gray-500 p-3 bg-gray-50 rounded-lg my-2">
            <div className="animate-spin h-4 w-4 border-2 border-indigo-500 border-t-transparent rounded-full flex-shrink-0"></div>
            <span>{i18n.t('tool.preparingPlan')}</span>
          </div>
        );
      }

      if (status === 'executing') {
        return (
          <ProposePlanForm
            args={args}
            onApprove={() => respond({ approved: true, ...args })}
            onReject={() => respond({ approved: false })}
          />
        );
      }

      if (status === 'complete') {
        return (
          <div className="flex items-center gap-2 text-sm text-gray-500 p-3 bg-gray-50 rounded-lg my-2">
            <div className="h-4 w-4 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0">
              <svg className="h-2.5 w-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
                <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
              </svg>
            </div>
            <span>{i18n.t('tool.planConfirmed')}</span>
          </div>
        );
      }
    },
  });
}

No4. シンプルにUIを表示

最後のケースはuseComponentです。
こちらのフックは、AIエージェントがUI表示を必要と判断した際に使用されます。
そのため、useRenderToolはツールとフックが1対1の関係である必要がありますが、useComponentはツールに紐づきません。

例えば、以下の例ではイベント会場から近いカフェをAIエージェントが検索しています。
カフェごとにGoogleのPlaceAPIを実行しているため、複数回のツール実行が行われています。
そのためuseComponentを使用して、UIのレンダリングタイミングをAIエージェントに委ねました。
その結果をuseComponentでGenerativeUIとして地図に表示しています。

実際のコードですが、前述した通りツールと紐づかないため、useComponentの定義のみ実装します。

useRenderNearbyFacilitiesRenderer.tsx
/**
 * 周辺施設地図表示ツール
 * render_nearby_facilitiesツールのGenerative UI実装(地図表示専用)
 */
import { useComponent } from '@copilotkit/react-core/v2';
import { z } from 'zod';
import i18n from '@/i18n';
import type { PlaceResult } from '@/stores/locationStore';
import MapView from '@/components/map/MapView';

// 施設データのスキーマ
const facilitySchema = z.object({
  id: z.string(),
  title: z.string(),
  url: z.string().optional(),
  content: z.string().optional(),
  address: z.string().optional().nullable(),
  latitude: z.number().optional().nullable(),
  longitude: z.number().optional().nullable(),
  phone: z.string().optional().nullable(),
  opening_hours: z.array(z.string()).optional().nullable(),
  rating: z.number().optional().nullable(),
  google_maps_url: z.string().optional().nullable(),
});

type FacilityData = z.infer<typeof facilitySchema>;

/**
 * render_nearby_facilitiesツールをuseComponentで登録するフック
 * エージェントがsearch_nearby_facilitiesの結果を渡して地図に表示する
 */
export function useRenderNearbyFacilitiesRenderer() {
  useComponent({
    name: 'render_nearby_facilities',
    description: '周辺施設を地図に表示します。search_nearby_facilitiesで取得した施設情報を地図上に可視化するために使用します。',
    parameters: z.object({
      query: z.string().describe('検索キーワード(例: "カフェ", "レストラン")'),
      location: z.string().describe('検索地域(例: "東京駅", "渋谷")'),
      facilities: z.array(facilitySchema).default([]).describe('表示する施設情報のリスト'),
      max_results: z.number().optional().describe('最大取得件数'),
    }),
    render: ({ query, facilities = [] }: { query: string; location: string; facilities: FacilityData[]; max_results?: number }) => {
      const facilitiesWithPosition = facilities.filter(
        (f) =>
          typeof f.latitude === 'number' &&
          typeof f.longitude === 'number' &&
          !isNaN(f.latitude) &&
          !isNaN(f.longitude)
      );

      if (facilitiesWithPosition.length === 0) {
        return (
          <div className="flex items-center gap-2 text-sm text-gray-500 p-3 bg-gray-50 rounded-lg my-2">
            <span>{i18n.t('tool.nearbyNotFoundWithPosition', { query: query || '施設' })}</span>
          </div>
        );
      }

      const places: PlaceResult[] = facilitiesWithPosition.map((f) => ({
        name: f.title,
        position: [f.latitude!, f.longitude!] as [number, number],
        address: f.address ?? f.content,
        phone: f.phone ?? undefined,
        rating: f.rating ?? undefined,
        openingHours: f.opening_hours ?? undefined,
        url: f.url,
        googleMapsUrl: f.google_maps_url ?? undefined,
      }));

      return (
        <div className="space-y-4">
          <div className="flex items-center gap-2 text-sm text-gray-500 p-3 bg-gray-50 rounded-lg my-2">
            <div className="h-4 w-4 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0">
              <svg className="h-2.5 w-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
                <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
              </svg>
            </div>
            <span>
              {i18n.t('tool.nearbyMapped', { count: facilitiesWithPosition.length, query: query || '施設' })}
              {facilities.length > facilitiesWithPosition.length && `(${facilities.length - facilitiesWithPosition.length}件は位置情報なし)`}
            </span>
          </div>

          <div className="w-full h-[400px] rounded-lg overflow-hidden border">
            <MapView
              nearbyPlaces={places}
              nearbyPlacesQuery={query || '施設'}
            />
          </div>
        </div>
      );
    },
  });
}


まとめ

今回は、CopilotKitでGenerative UIを試してみました。

まとめとしては...

  • useRenderTool / useFrontendTool はツールとUIが1対1の関係が前提
  • 複数ツールの結果をまとめて表示したい場合や、ツール実行なしで表示したい場合は useComponent
  • ユーザーの承認・入力を挟みたい場合は useHumanInTheLoop

また使ってみた感想としては、思ったより苦戦する場面が多かったです。
useHumanInTheLoopの直後に再度Generative UIを実行できない制約があるなど、痒いところ手が届かない場面があり、もう少し勉強が必要そうです。

まだ使い始めたばかりなので、使い倒して知見なども記事にできたらと思います。

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?