Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

エージェント型UIフレームワーク

大規模言語モデルが受動的なテキスト生成器から、計画、ツール利用、多段階推論が可能な能動的エージェントへ移行するにつれて、人間がそれらと対話するインターフェースも同時に進化しなければなりません。単一ターンまたは短いコンテキストの会話向けに設計された従来のチャットインターフェースは、長時間タスク、分岐する意思決定木、並列ツール呼び出し、意味のある人間の監督が必要なエージェントワークフローには適していません。この節では、豊かで透明性があり信頼できる人間とエージェントの協働を可能にする、エージェント型UIフレームワークの全体像、すなわち設計パラダイム、コンポーネントライブラリ、実装パターンを概観します。

動機: チャットボックスを超えて

Tip

エージェントに専用インターフェースが必要な理由

チャットバブルが伝えるのは結果です。エージェント型UIが伝えるのは、推論、呼び出したツール、行った意思決定、人間の判断が必要な地点からなるプロセスです。この可視性がなければ、ユーザーはエージェントを信頼し、訂正し、そこから学ぶことができません。

チャットインターフェースとエージェント型インターフェースの隔たりは、自動販売機と熟練した協働者の隔たりに似ています。エージェントが20段階の調査タスクを実行し、ウェブを閲覧し、コードを書いて実行し、報告書を統合するとき、ユーザーは単純なテキスト応答では得られない次の問いへの答えを必要とします。

  • エージェントは今何をしているのか。 長時間タスクには進捗フィードバックが必要であり、沈黙は不信を生みます。

  • なぜエージェントはこの決定をしたのか。 推論を透明化すると、ユーザーは早期に誤りを発見できます。

  • どのツールがどの入力で使われたのか。 ツールの来歴は、事実に関する主張の検証と挙動の監査に不可欠です。

  • どこで介入すべきか。 エージェントは、すべての微細な意思決定でユーザーを圧倒することなく、人間の判断に値する意思決定ポイントを示さなければなりません。

  • これを元に戻せるか。 不可逆なアクション(メール送信、ファイル変更、コード実行)には、明示的な確認とロールバック経路が必要です。

Warning

自動化バイアスのリスク

人間と自動化の相互作用に関する研究は、自動システムが自信ありげに、かつ不確実性のシグナルなしに出力を提示すると、ユーザーが自動システムを過信することを一貫して示しています(Parasuraman and Riley 1997)。エージェント型UIは、不確実性を示し、推論を見せ、エージェントの意思決定に疑問を呈したり上書きしたりしやすくしたりすることで、自動化バイアスに積極的に対抗しなければなりません。

したがって、エージェント型UIの設計は、ヒューマン・コンピュータ・インタラクション(HCI)、説明可能なAI(XAI)、ソフトウェアエンジニアリングの交差点に位置します。中核的な設計目標は次のとおりです。

  1. 透明性: エージェントの内部状態をユーザーに理解可能にする。

  2. 制御: 常時監督を要求せずに、意味のある介入ポイントを提供する。

  3. 信頼度の調整: ユーザーがエージェントの能力と限界について正確なメンタルモデルを形成できるようにする。

  4. 効率性: 認知負荷を最小化し、適切なタイミングで適切な情報を示す。

  5. 回復可能性: 誤りを安価に検出し、元に戻せるようにする。

エージェント向けUIパラダイム

すべてのエージェント型ユースケースに適した単一のUIパラダイムはありません。適切なインターフェースは、タスクの期間、必要な人間の関与、出力タイプ、ユーザーの専門知識によって異なります。その範囲は、完全に会話型のチャットインターフェースから、人間との相互作用が最小限の完全自律ダッシュボードまで広がります。

チャットベースのインターフェース

メッセージバブル、テキスト入力、スクロール可能な履歴からなるチャットパラダイムは、LLMとの対話で最もなじみ深い入口であり続けています。学習曲線が緩やかで、自然言語の柔軟性が高いことが強みです。エージェント型の利用では、チャットインターフェースを次の要素で拡張します。

  • ストリーミング応答: 生成されたトークンがすぐに表示され、即時のフィードバックを提供し、知覚されるレイテンシを減らします。Server-Sent Events(SSE)またはWebSocketで実装します。

  • インラインツールインジケーター: メッセージストリーム内の小さなバッジや展開可能なセクションで、ツールが呼び出されたことを示します(例: 「[ウェブで検索: climate change 2024]」)。

  • 入力中インジケーターとステータスメッセージ: 「エージェントが考えています…」「Pythonコードを実行中…」「結果を取得中…」などにより、レイテンシの空白中もユーザーに状況を知らせます。

  • メッセージスレッド: マルチターンのエージェント型タスクでは、折りたたみ可能なサブスレッドに中間ステップを収め、メインの会話を散らかさずに済みます。

Important

エージェントにおけるチャットUIの限界

チャットインターフェースは、本来並列なプロセスを直列化します。エージェントが5つのツールへ同時に処理を分岐するとき、線形のメッセージストリームは実際の実行グラフを誤って表します。複雑なエージェントワークフローでは、チャットをより豊かなパラダイムで拡張するか、置き換えるべきです。

キャンバスとアーティファクトベースのインターフェース

Claude Artifacts1とChatGPT Canvas2によって広まったキャンバスパラダイムは、分割ペインのレイアウトを導入します。左ペインに会話を置き、右ペイン(「キャンバス」または「アーティファクトパネル」)に、コード、文書、図、スプレッドシートなどの生成コンテンツを、ライブで編集可能なアーティファクトとして表示します。

主な特徴は次のとおりです。

  • 永続アーティファクト: 生成コンテンツがターンをまたいで保持され、自然言語の指示(「グラフを青くする」「関数にエラー処理を追加する」)で反復的に改善できます。

  • その場での編集: ユーザーがアーティファクトを直接編集でき、エージェントはその編集を観測して応答できます。

  • バージョン履歴: アーティファクトが改訂履歴を保持し、以前の任意の状態へロールバックできます。

  • マルチアーティファクトワークスペース: 高度な実装では、コードファイル、そのテストスイート、ドキュメントページなど、複数のアーティファクトを同時に扱えます。

キャンバスパラダイムは、会話の回答ではなく文書やアーティファクトが出力となる、執筆、コーディング、データ分析、デザインなどの共同制作タスクに特に適しています。

ワークフローの可視化

構造化された計画、つまりステップの系列やグラフを実行するエージェントでは、ワークフロー可視化UIによって計画を明示し、追跡可能にします。このパラダイムは次のような場面で一般的です。

  • エージェント型パイプライン (LangGraph、AutoGen、CrewAI): エージェントの実行グラフを有向非巡回グラフ(DAG)またはフローチャートとして描画し、ノードがステップ、エッジがデータフローまたは制御フローを表します。

  • タスク分解ビュー: エージェントの高レベル計画をチェックリストまたはガント風タイムラインで示し、各サブタスクを展開すると自身のステップを表示します。

  • ライブ進捗追跡: ノードは実行中に色を変えるかスピナーを表示し、完了したノードは出力を、失敗したノードはエラーの詳細を表示します。

LangGraph Studio3はこのパラダイムの代表例で、LangGraphエージェント向けのグラフベースデバッガーとビジュアライザーを提供します。ユーザーは各ノードの状態を調べ、実行を再生し、変更した状態を注入して代替経路をテストできます。

ダッシュボードと監視インターフェース

長時間実行エージェントや本番エージェントに対して、ダッシュボードUIは運用ビューを提供します。

  • リアルタイムステータス: どのエージェントが実行中、アイドル、失敗の状態か、現在のタスクとステップ。

  • リソースメトリクス: トークン消費量、API呼び出し数、レイテンシヒストグラム、コスト見積もり。

  • キュー管理: 保留中のタスク、優先順位、レート制限の状態。

  • アラートと異常検出: 異常な挙動(過剰な再試行、コストの急増、失敗の反復)を通知として表示します。

  • 履歴分析: タスク完了率、平均期間、時間経過に伴うエラー頻度。

ダッシュボードUIは通常、Grafana4、カスタムReactダッシュボード、Streamlitなどのツールで構築され、エンドユーザーではなく運用担当者を対象とします。

協働インターフェース

協働UIは、エージェントを人間の協働者と並ぶ共有ワークスペース(文書、コードベース、デザインキャンバス)への対等な貢献者として扱います。主な機能は次のとおりです。

  • プレゼンスインジケーター: 共有ワークスペースに、名前付きカーソルやアバターとしてエージェントが表示されます。

  • 変更の帰属: エージェントによる編集を人間の編集と視覚的に区別します(例: 色分けされた差分)。

  • インライン提案: エージェントが変更を変更履歴付き編集やコメントとして提案し、人間が承認、拒否、修正できます。

  • 競合解決: エージェントと人間が同じ領域を同時に編集すると、UIが競合を示し、解決を支援します。

このパラダイムは、Cursor5(AIとの協働コード編集)、Notion AI6、Gemini統合を備えたGoogle Docs7などのツールで広がりつつあります。

チェックポイント付き自律実行

自律性のスペクトルの端には、ほぼ独立して実行し、ウェブを閲覧し、コードを書き、コマンドを実行し、人間の承認が必要な事前定義されたチェックポイントでのみ表に出るエージェントがあります。このパラダイムは次で使われます。

  • コンピュータ利用エージェント (Anthropic Computer Use8、OpenAI Operator9): エージェントがブラウザーやデスクトップを操作し、UIがライブ画面を表示して、不可逆なアクションの前に承認のため一時停止します。

  • ゲート付き自動パイプライン: エージェントがフェーズを完了し、先へ進む前に人間の「マージ」を待つCI/CD風ワークフロー。

  • スケジュール実行エージェント: スケジュールに従って実行し、結果を非同期に報告するエージェント。通知ベースのUIで出力を確認し、後続アクションを承認します。

Note

実践におけるチェックポイントUI

「メール受信箱を整理して」と依頼されたエージェントは、500件のメールを自律的に分類・アーカイブした後、一時停止して次の要約を示すかもしれません。「6か月間開いていないメーリングリストからと思われるメールが23件ありました。すべて、いくつか、またはどれも購読解除しますか。」ユーザーはリストを確認して選択し、エージェントが続行します。このパターン、つまり人間の意思決定ポイントを挟んだ自律実行は、効率性と制御のバランスを取ります。

エージェント向けUIの主要コンポーネント

全体的なパラダイムにかかわらず、エージェント型UIには共通するコンポーネントがあります。この節では、最も重要なものを設計上の指針とともに整理します。

思考と推論の表示

現代のLLM、特に思考の連鎖や拡張思考で訓練されたモデル(例: OpenAI o1/o3、拡張思考を備えたAnthropic Claude)は、最終応答を生成する前に大量の内部推論を行います。この推論を表に出すことには両面があります。透明性は高まりますが、冗長な内部独白でユーザーを圧倒する可能性もあります。

ベストプラクティス:

  • 折りたたみ可能な推論ブロック: 要約(「12秒間思考しました」)を表示し、詳細を求めるユーザー向けに展開トグルを用意する。

  • 段階的開示: デフォルトでは最終結論だけを表示し、推論は要求時に利用できるようにする。

  • 構造化された推論: モデルが構造化された思考(仮説、証拠、結論)を生成する場合、テキストの壁ではなく視覚的な階層で描画する。

  • 推論と応答の区別: 誤りや試行錯誤を含む可能性がある内部推論と、最終応答を視覚的に明確に区別する。

ツール利用の可視化

ツール呼び出しは、エージェントが世界と相互作用する主要な仕組みです。信頼とデバッグのためには、その可視化が不可欠です。

Important

ツール呼び出しの構成

各ツール呼び出しには表示する価値のある4つの構成要素があります。(1)ツール名 とアイコン、(2)入力引数 (大きなJSONの可能性あり)、(3)出力/結果 (大きな可能性あり)、(4)タイミング (レイテンシ)です。UIは完全性と可読性のバランスを取らなければなりません。

ツール可視化の設計パターン:

  • インラインツールカード: メッセージストリーム内に、ツール名、入力の1行要約、状態(実行中/成功/エラー)を示すコンパクトなカード。完全な詳細へ展開できる。

  • ツールタイムライン: 1ターン内のすべてのツール呼び出しを所要時間付きで示す水平タイムライン。ボトルネックの特定を可能にする。

  • 入出力差分: 状態を変更するツール(例: ファイル編集)では、変更前後の差分を表示する。

  • ツールアイコンとブランド: 代表的なツール(ウェブ検索、コード実行、ファイルシステム、API)に分かりやすいアイコンを使うと、すばやく把握できます。

  • エラーの強調: 失敗したツール呼び出しを、エラーメッセージと再試行の試みとともに赤色で表示する。

進捗インジケーター

多段階のエージェント型タスクには、豊かな進捗フィードバックが必要です。

  • ステップ単位の進捗: 計画したステップを番号付きリストで示し、完了するたびにチェックマークを付ける。動的な計画では、エージェントの適応に応じてステップを追加・削除できる。

  • トークンストリーミングインジケーター: 生成中に点滅するカーソルやアニメーション付き省略記号を表示し、上級ユーザーには1秒あたりのトークン数を示す。

  • 完了予測: 可能なら、タスクの複雑さと過去の性能に基づくETAを示す。適切な不確実性(「約2〜5分」)とともに表示する。

  • サブタスクのネスト: 階層型タスクでは、サブタスクを展開できるツリー構造の進捗ビューを使う。

  • キャンセル: エージェントを適切に停止し、それまでに完了した作業を要約する「停止」ボタンを明確に表示する。

承認ゲート

承認ゲートはHuman-in-the-Loop制御の主要な仕組みです。情報を与える(ユーザーが適切な決定をするのに十分なコンテキストを提供する)一方で、疲れさせない(些細なアクションすべてに承認を要求しない)よう設計しなければなりません。

Warning

承認ゲートにおけるアラート疲れ

エージェントが頻繁に承認を求めると、ユーザーは読まずに反射的に承認し始め、ゲートの目的が失われます。意味のある監督を維持するには、段階的な承認ポリシー(節12.7を参照)が不可欠です。

承認ゲートのUI要素:

  • アクションの要約: エージェントが実行しようとしていることを平易な言葉で説明する(「添付の報告書をjohn@example.comへ送信」)。

  • リスクインジケーター: アクションの可逆性を視覚的に示す(緑=容易に元に戻せる、黄=元に戻しにくい、赤=不可逆)。

  • 承認/拒否/変更: 3択のインターフェース。「変更」を選ぶと、承認前にアクションパラメータのエディターを開く。

  • コンテキストパネル: エージェントがこのアクションを実行したい理由(関連する推論、以前のステップ)を示す展開可能なセクション。

  • タイムアウト時の挙動: ユーザーが応答しない場合に何が起きるかを明確に示す(エージェントは続行せず一時停止)。

コンテキストの表示

エージェントは、挙動に影響する内部状態、すなわちメモリ、アクティブなツール、取得文書、会話履歴を保持します。この状態を可視化すると、ユーザーがエージェントの挙動を理解し予測するのに役立ちます。

  • メモリパネル: エージェントがユーザー、タスク、過去の相互作用について現在「記憶している」ことを示す。ユーザーが編集できる。

  • アクティブツール一覧: 現在エージェントが利用できるツールと、有効化/無効化トグル。

  • 取得済みコンテキスト: 現在エージェントのコンテキストウィンドウにある文書やデータ断片と、出典引用。

  • トークン予算インジケーター: コンテキストウィンドウをどれだけ消費しているかを示し、新しいセッションを開始するタイミングの理解を助ける。

エラーと回復のUI

エージェントは失敗します。ツールがエラーを返し、モデルがハルシネーションを起こし、計画が実行不能になることがあります。UIは失敗を適切に処理しなければなりません。

  • エラーカード: エラータイプ、メッセージ、エージェントの解釈とともに失敗をインライン表示する。

  • 再試行コントロール: パラメータを任意で調整できる手動再試行ボタン。

  • 代替アプローチ: 主たるアプローチが失敗したとき、エージェントが代替案を提案し、UIが選択肢として提示する。

  • 部分結果: 多段階タスクが途中で失敗した場合、UIが完了したステップとその出力を示し、部分的な価値を保持する。

  • エスカレーション経路: エージェントが続行できない場合に、人間のサポートや手動完了へ進む明確な経路。

信頼度インジケーター

LLMは、調整された(または調整されていない)不確実性を持つ確率的システムです。信頼度を示すことで、ユーザーはいつ信頼し、いつ検証すべきかを判断しやすくなります。

  • ヘッジ表現の表示: 「確信がありません」「検証した方がよいかもしれません」のような表現を強調し、信頼度の低い主張に注意を促す。

  • 出典品質インジケーター: 取得した情報について、出典の新しさ、権威性、関連性スコアを表示する。

  • 明示的な不確実性の要求: 「どれくらい自信がありますか。」ボタンでエージェントに自己評価と不確実性の説明を促す。

  • 検証の提案: 重要な出力について、エージェントが検証手順(「この計算を独立に確認することを勧めます」)を先回りして提案する。

フレームワークとライブラリ

フレームワークのエコシステムは拡大しており、エージェント型UIの開発を加速させています。主要言語とユースケースごとに、最も広く採用されているものを概観します。

Vercel AI SDK

Vercel AI SDK(Vercel 2024)は、React、Next.js、Svelte、VueでストリーミングAIインターフェースを構築するためのTypeScript/JavaScriptライブラリです。本番のウェブベースエージェントUIで最も広く使われているフレームワークです。

中核抽象化:

  • useChat: ストリーミング、メッセージ履歴、ローディング状態をサポートしてチャット会話を管理するReactフック。

  • useCompletion: ストリーミングに対応した単一ターンのテキスト補完用フック。

  • useObject: 構造化JSONオブジェクトをストリーミングし、複雑な出力の段階的な描画を可能にする。

  • streamText / streamObject: HTTP経由でLLM応答をストリーミングするサーバーサイド関数。

生成UI(AI SDK RSC): Vercel AI SDKの最も特徴的な機能は、React Server Components(RSC)を介した生成UIのサポートです。テキストを返す代わりに、LLMはツールを呼び出し、その結果を任意のReactコンポーネント、たとえば天気ウィジェット、株価チャート、予約フォームとして描画し、UIへ直接ストリーミングできます。詳細は節12.5で説明します。

Chainlit

Chainlit(Chainlit 2024)は、最小限の定型コードで本番対応のエージェントUIを構築するPythonフレームワークです。LangChainとLlamaIndexのエコシステムで特に人気があります。

主な機能:

  • ステップ可視化: ChainlitはLangChainとLlamaIndexの実行ステップを折りたたみ可能なツリーとしてネイティブに描画し、各チェーン呼び出し、検索、ツール呼び出しを表示します。

  • マルチモーダル対応: ファイルアップロード、画像表示、音声再生、PDF描画を標準で利用できます。

  • 認証とセッション: ユーザー認証、永続的な会話履歴、マルチユーザー対応を組み込みで提供します。

  • カスタム要素: Reactコンポーネントを登録してPythonから描画でき、豊かなカスタム可視化を可能にします。

  • フィードバック収集: コメントを任意で付けられる組み込みの賛成/反対フィードバックをデータベースに保存します。

import chainlit as cl
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langgraph.prebuilt import create_react_agent

@tool
def search(query: str) -> str:
    """Search for information."""
    return f"Results for: {query}"

agent = create_react_agent(
    ChatOpenAI(model="gpt-4o"), tools=[search]
)

@cl.on_message
async def on_message(message: cl.Message):
    # Chainlit automatically renders each step as a collapsible UI element
    # when using the callback handler
    async with cl.Step(name="Agent", type="run") as step:
        step.input = message.content
        result = await agent.ainvoke(
            {"messages": [{"role": "user", "content": message.content}]},
            config={"callbacks": [cl.LangchainCallbackHandler()]}
        )
        output = result["messages"][-1].content
        step.output = output

    await cl.Message(content=output).send()

Gradio

Gradio(Abid et al. 2019)は、MLデモとエージェントインターフェースをすばやく構築するPythonライブラリです。gr.ChatInterfacegr.Blocks APIにより、最小限のコードで会話エージェントを迅速にプロトタイピングできます。

エージェント型UIにおける強み:

  • 設定不要の配備: Hugging Face Spacesを介して1行で共有できます。

  • カスタムコンポーネント: Gradio Custom Componentsシステムにより、Pythonバックエンドとシームレスに統合するReactコンポーネントを構築できます。

  • マルチモーダル入力: 最小限の設定でファイル、画像、音声、動画、ウェブカメラから入力できます。

  • ストリーミング: ジェネレータベースのストリーミング応答をネイティブにサポートします。

限界: Gradioのレイアウトシステムは完全なReactフレームワークより柔軟性が低く、状態管理もセッション単位なので、複雑なマルチエージェント調整は難しくなります。

Streamlit

Streamlit(S. Inc 2024)は、エージェントのダッシュボードや監視UIに広く採用されているデータアプリケーション向けPythonフレームワークです。リアクティブな実行モデル、つまり操作ごとにスクリプト全体を再実行する方式は単純ですが、複雑なエージェントワークフローでは制約になる場合があります。

エージェント型のユースケース:

  • エージェントダッシュボード: st.metricst.dataframest.statusを使ったリアルタイムメトリクス、タスクキュー、ステータス表示。

  • セッション状態: st.session_stateが再実行をまたいでエージェント状態を保持し、マルチターンの会話を可能にします。

  • ストリーミング: st.write_streamがジェネレータ出力を段階的に描画します。

  • フラグメント: @st.fragmentデコレーターが部分的な再実行を可能にし、ライブ更新ダッシュボードの性能を向上させます。

OpenAI Assistants Playground

OpenAI Assistants Playgroundは、エージェント型UI設計のリファレンス実装です。次を実演します。

  • 永続履歴を備えたスレッドベースの会話管理。

  • ファイル添付と検索の可視化。

  • 出力(標準出力、画像、ファイル)を表示するコードインタープリター実行。

  • 入出力を検査できる関数呼び出し表示。

  • モデル呼び出しとツール呼び出しの順序を示す実行ステップの可視化。

カスタムUIを構築するためのフレームワークではありませんが、Playgroundの設計パターンは広く模倣されています。

LangGraph Studio

LangGraph Studio(L. Inc 2024a)は、LangGraphエージェント向けのビジュアルIDEを提供するデスクトップアプリケーションです。現在利用できるツール利用とワークフロー可視化環境の中で、最も高度なものです。

機能:

  • グラフ可視化: エージェントの状態機械をインタラクティブに描画し、ノードがエージェントのステップ、エッジが遷移を表します。

  • 状態検査: 実行中の任意の時点で、完全なエージェント状態(全変数、メモリ、ツール結果)を構造化JSONとして検査できます。

  • タイムトラベルデバッグ: 以前の任意の実行ステップを再生し、状態を変更して、その地点から再実行できます。

  • Human-in-the-loop統合: 任意のノードにブレークポイントを設定でき、実行が一時停止して人間の入力を待ってから続行します。

  • マルチエージェント対応: スーパーバイザーとサブエージェントの階層、およびエージェント間のメッセージパッシングを可視化します。

フレームワークの比較

12.1に、上記で扱ったフレームワークの主な特徴をまとめます。

フレームワーク言語ストリームツール可視化マルチエージェント生成UI本番
Vercel AI SDKTypeScriptPartialPartial
ChainlitPythonPartialPartial
GradioPython\(\circ\)\(\times\)\(\circ\)
StreamlitPython\(\circ\)\(\times\)\(\times\)
OAI PlaygroundN/A (hosted)\(\times\)\(\times\)\(\times\)
LangGraph StudioPython/TS\(\times\)Partial

エージェント型UIフレームワークの比較。 {#tab:ui-framework-comparison}

生成UI

Tip

生成UIの概念

従来のLLMインターフェースは、モデルの出力をテキストやMarkdownとして描画します。生成UIはこれを逆転させます。モデルのツール呼び出しがUIコンポーネントを生成します。モデルは何を言うかだけでなく、コンテンツタイプとユーザーの意図に基づいて、グラフ、フォーム、地図、カレンダーウィジェットなど、どのように提示するかも決定します。

生成UIは、LLMとインターフェースの関係における根本的な転換です。開発者が考えられるすべてのUI状態を事前に指定するのではなく、モデルが現在のコンテキストに適したUIコンポーネントを動的に選択し、パラメータ化します。

動的インターフェースのためのReact Server Components

Vercel AI SDKのRSC(React Server Components10)統合は、生成UIの最も成熟した実装です。アーキテクチャは次のように動作します。

  1. ユーザーがNext.js11のサーバーアクションへメッセージを送信する。

  2. サーバーが、各ツールにReactコンポーネントを関連付けたツールセットとともにLLMを呼び出す。

  3. LLMがツールを呼び出すと(例: show_weather)、サーバーがツールの出力をpropsとして対応するReactコンポーネントを描画する。

  4. 描画されたコンポーネントがReact Server Componentとしてクライアントへストリーミングされ、チャット内にインラインで表示される。

// app/actions.tsx (Server Action)
import { streamUI } from 'ai/rsc';
import { openai } from '@ai-sdk/openai';
import { WeatherCard } from '@/components/WeatherCard';
import { StockChart } from '@/components/StockChart';

export async function chat(userMessage: string) {
  const result = await streamUI({
    model: openai('gpt-4o'),
    messages: [{ role: 'user', content: userMessage }],
    tools: {
      show_weather: {
        description: 'Display current weather for a location',
        parameters: z.object({
          location: z.string(),
          unit: z.enum(['celsius', 'fahrenheit']),
        }),
        // Tool result rendered as a React component
        generate: async ({ location, unit }) => {
          const data = await fetchWeather(location, unit);
          return <WeatherCard data={data} />;
        },
      },
      show_stock: {
        description: 'Display stock price chart',
        parameters: z.object({ ticker: z.string() }),
        generate: async ({ ticker }) => {
          const data = await fetchStockData(ticker);
          return <StockChart ticker={ticker} data={data} />;
        },
      },
    },
  });
  return result.value;
}

コンテンツタイプに基づく適応型インターフェース

生成UIは、提示するコンテンツの性質に適応するインターフェースを可能にします。

  • 表形式データ \(\rightarrow\) 並べ替えとフィルタリングが可能で、エクスポート機能を備えたデータテーブル。

  • 地理データ \(\rightarrow\) マーカーとレイヤーを備えたインタラクティブな地図。

  • 時系列 \(\rightarrow\) 注釈付きでズーム可能な折れ線グラフ。

  • コード \(\rightarrow\) シンタックスハイライト付きエディターと実行ボタン。

  • 文書 \(\rightarrow\) 注釈ツールを備えた書式付き文書ビューアー。

  • フォーム/構造化入力 \(\rightarrow\) 動的に生成されるフォームフィールド。

モデルはUIオーケストレータとして働き、情報の各部分に最も適した提示方法を選択します。これにより、開発者が考えられるすべての出力タイプを予想して対応するコンポーネントを事前構築する必要が減ります。

Note

生成UIの限界

UI生成をどこまでモデルに委譲すべきでしょうか。完全にモデル駆動のUIは、一貫性の欠如、アクセシビリティの失敗、セキュリティ脆弱性を招くリスクがあります(例: モデルが予期しないエンドポイントへデータを送信するフォームを生成する)。実際には、任意のHTMLやJSXを生成するよりも、モデルが事前構築済みでアクセシブルかつ安全なコンポーネントのキュレーション済みライブラリから選択するとき、生成UIは最もよく機能します。

ストリーミングとリアルタイムのパターン

ストリーミングはエージェント型UIの基盤です。「結果を待つ」体験を「エージェントの作業を見る」体験へ変えます。この節では、主要なストリーミングパターンと実装上の考慮事項を扱います。

トークンストリーミング

トークンストリーミングは、完全な応答を待つのではなく、トークンが生成されるたびにLLM出力を段階的に届けます。一般的に2つの転送メカニズムが使われます。

  • Server-Sent Events(SSE) 12: サーバーからクライアントへの一方向HTTPストリーム。各イベントがトークンのチャンクを運びます。SSEは単純で標準HTTP/1.1上で動作し、ブラウザーが自動的に再接続します。LLMストリーミングAPIの主要なメカニズムであり、OpenAI、Anthropic、Googleがすべて使っています。

  • WebSocket: 双方向の永続接続。実装はより複雑ですが、クライアントがストリーム途中でデータを送る必要があるインタラクティブなストリーミングシナリオ(例: エージェントの中断、生成途中のフィードバック提供)に必要です。

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from openai import AsyncOpenAI
import json

app = FastAPI()
client = AsyncOpenAI()

async def token_stream(prompt: str):
    """Generator that yields SSE-formatted token chunks."""
    stream = await client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}],
        stream=True,
    )
    async for chunk in stream:
        delta = chunk.choices[0].delta
        if delta.content:
            # SSE format: "data: <json>\n\n"
            yield f"data: {json.dumps({'token': delta.content})}\n\n"
        elif chunk.choices[0].finish_reason:
            yield f"data: {json.dumps({'done': True})}\n\n"

@app.get("/stream")
async def stream_endpoint(prompt: str):
    return StreamingResponse(
        token_stream(prompt),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )

ツール呼び出しのストリーミング

現代のLLM APIはツール呼び出しのストリーミングをサポートします。ツール名と引数が段階的にストリーミングされるため、ツールがまだ呼び出されていない段階でも、UIに「エージェントが検索クエリ『climate change 2024』でsearch_webを呼び出しています…」と表示できます。これには部分的なJSONのパースが必要で、ストリーミングJSONパーサーで実行できます。

ツール呼び出しストリーミングのパターン:

  • 引数の段階的表示: 呼び出しが完了する前でも、ストリーミングされるツール引数を表示する。

  • 並列ツール呼び出しインジケーター: モデルが複数のツールを同時に呼び出すとき、すべてを保留中として表示し、結果が到着するたびに更新する。

  • ツール結果のストリーミング: ツールによっては(例: コード実行、ウェブスクレイピング)結果自体をストリーミングできるため、これをUIへ段階的に流す。

マルチエージェントストリーミング

マルチエージェントシステムでは、複数のエージェントが同時に出力を生成することがあります。UIは並列ストリームを処理しなければなりません。

  • エージェントラベル付きストリーム: 各ストリームにエージェントの識別情報を付け、UIが別々のレーンやパネルに描画する。

  • ストリームのマージ: スーパーバイザー・サブエージェントパターンでは、スーパーバイザーのストリームとサブエージェントのストリームが交互に現れることがあるため、UIが一貫した順序を維持しなければならない。

  • バックプレッシャー: UIがストリームの到着速度に合わせて描画できない場合(例: 複数エージェントが同時に生成)、バックプレッシャー機構でバッファーのオーバーフローを防がなければなりません。戦略には、中間トークンを捨てて最新だけを表示する、更新をバッチ化する、遅いストリームを一時停止する、などがあります。

楽観的UI更新

楽観的UI更新は、サーバーの確認前にユーザーアクションをUIへ即時反映し、知覚される応答性を高めます。

  • ユーザーがメッセージを送ると、リクエスト処理中でもチャット履歴に(楽観的に)即座に表示される。

  • 承認ゲートが承認されると、サーバーが承認を処理する前でも、UIがアクションを「承認済み」と即座に表示し、エージェントの次のステップを表示し始める。

  • サーバーがエラーを返した場合、楽観的更新をロールバックし、エラー状態を表示する。

バックプレッシャーの処理

高スループットのエージェント型シナリオでは、受信データの速度がUIの描画能力を超えることがあります。バックプレッシャーを管理する戦略は次のとおりです。

  • トークンのバッチ化: トークンを50〜100ミリ秒バッファーし、1つずつではなくバッチで描画してDOM更新頻度を減らす。

  • 仮想スクロール: 長い出力では、コンテンツの表示部分だけを描画し、画面外のDOMノードを破棄する。

  • スロットル更新: メトリクスとステータス表示を、受信データの速度にかかわらず固定レート(例: 10Hz)で更新する。

  • 段階的な詳細表示: 高スループットの期間は要約ビューを表示し、完全な詳細は要求時に利用できるようにする。

Human-in-the-Loop UI設計

Human-in-the-Loop(HITL)インタラクションは、エージェント型UIで最も影響の大きい設計課題の1つです。自動化の効率性を損なうボトルネックを作らずに、意味のある人間の監督を維持することが目標です。

エージェントを中断するタイミング

すべてのエージェントアクションが人間のレビューに値するわけではありません。原則に基づく中断ポリシーでは、次を考慮します。

  • 可逆性: 不可逆なアクション(ファイル削除、メール送信、購入)は常に承認に値します。可逆なアクション(ファイル読み取り、ウェブ検索)は通常、承認を必要としません。

  • 範囲: 外部システムや他人に影響するアクションは、純粋にローカルなアクションより厳しい精査に値します。

  • 信頼度: ユーザーの意図の解釈に対するエージェントの信頼度が低い場合、続行するのではなく明確化を求めるべきです。

  • コスト: 高コストのアクション(大規模なAPI呼び出し、高価な計算)は承認に値します。

  • 新規性: このコンテキストでエージェントが以前に実行したことのないアクションは、定常的なアクションより厳しい精査に値します。

段階的な承認ワークフロー

段階的な承認ポリシーは、監督と効率性のバランスを取ります。

Important

3段階承認モデル

Tier 1(自動承認): 低リスクで可逆的な定常アクション。例: ウェブ検索、ファイル読み取り、読み取り専用APIの呼び出し。エージェントは中断せず続行し、アクションは監査のために記録します。

Tier 2(通知): 中リスクのアクション。UIはユーザーが非同期に確認できるノンブロッキング通知(「エージェントが下書きメールを下書きフォルダーに移しました」)を表示します。短い時間枠(例: 30秒)で、アクション確定前にキャンセルできます。

Tier 3(承認必須): 高リスク、不可逆、または高コストのアクション。エージェントが一時停止し、ブロッキング承認ゲートを提示します。エージェントが続行する前に、ユーザーは明示的に承認、拒否、または変更しなければなりません。

段階間のしきい値はユーザーが設定(「メール送信前は常に確認」)することも、ユーザーの行動から学習(ユーザーが常にウェブ検索を承認するなら、今後は自動承認)することもできます。

フィードバックメカニズム

承認ゲートに加えて、エージェント型UIはエージェントが時間とともに改善するのを助ける豊かなフィードバックメカニズムを提供すべきです。

  • 賛成/反対: 応答に対する単純な二値フィードバック。保存してRLHFのファインチューニングや選好学習に使う。

  • インライン修正: ユーザーがエージェント出力を直接編集でき、元の出力と修正後の出力の差分が訓練信号になる。

  • 選好選択: エージェントが複数の選択肢を提示したとき、ユーザーの選択が選好信号になる。

  • 明示的な指示: 「もう二度とこれをしないで」「Xの前には必ず確認して」「ZよりYのアプローチを優先して」など、エージェントの挙動ポリシーを更新する自然言語指示。

  • 理由付き評価: 評価に添える任意の自由記述説明で、二値フィードバックより豊かな信号を提供する。

UIインタラクションを通じたエージェントへの教示

最も高度なHITL UIは、すべてのインタラクションを教示の機会として扱います。

  • デモンストレーション: ユーザーがタスクを手動で実行し、エージェントが観測して好ましいアプローチを学ぶ。

  • 一般化を伴う修正: ユーザーがエージェントのアクションを修正すると、UIが「いつも違う方法で実行しますか」と尋ね、修正を一般化する。

  • 選好の引き出し: ユーザーに2つのエージェント挙動を比較させ、どちらが好ましいかを示してもらう定期的なプロンプト。

  • 挙動プロファイル: UIがユーザーが確認・編集できる「選好」プロファイルを見える形で保持し、エージェントが学習した挙動を透明かつ制御可能にする。

アクセシビリティと信頼

信頼は機能ではありません。期待どおりに一貫して挙動し、自らを明確に説明し、失敗から適切に復旧するシステムの創発的な性質です。エージェント型UIは、信頼を第一級の関心事として設計しなければなりません。

エージェントの意思決定を説明する

エージェント型UIにおける説明可能性は、思考の連鎖を表示するだけではありません。次が必要です。

  • 意思決定の理由: 重要な意思決定では、エージェントは何を決めたかだけでなく、なぜ決めたか、つまり考慮した要因、却下した代替案、置いた前提を説明すべきです。

  • 出典の帰属: 主張を出典にリンクし、取得文書を引用可能にする。

  • 反実仮想説明: 「YではなくXと言っていたら、Zを実行していました」と説明し、ユーザーがエージェントの意思決定境界を理解できるようにする。

  • 不確実性の定量化: 信頼度を明示し、不確実性を生む要因も示す。

信頼度レベルの表示

信頼度インジケーターは調整され、意味のあるものでなければなりません。

  • 言語による信頼度: 「かなり自信があります」「これについては確信がありません」のような自然言語表現は、ほとんどのユーザーにとって数値確率より解釈しやすい。

  • 視覚的な信頼度: 色分け(緑/黄/赤)、アイコンのバリエーション、フォントの太さで、テキストを追加せず信頼度を符号化できる。

  • 主張ごとの信頼度: 複数の主張を含む応答では、主張ごとの信頼度インジケーター(例: インライン脚注)の方が、応答全体の単一スコアより有益です。

元に戻す機能とロールバック機能

技術的に可能な限り、重要なエージェントアクションはすべて元に戻せるべきです。

  • 元に戻せるアクションログ: すべてのエージェントアクションを時系列で記録し、可逆な各アクションに「元に戻す」ボタンを付ける。

  • スナップショットベースのロールバック: 状態を持つタスク(例: コード編集、文書執筆)では、定期的なスナップショットにより以前の任意の状態へロールバックできる。

  • ドライランモード: 計画を実行する前に、エージェントが計画をシミュレートして予測される状態変更を表示し、実際のアクションを取る前にユーザーが承認・変更できるようにする。

  • グレースフルデグラデーション: 元に戻せない場合(例: メールを送信済み)には、UIがそのことを明確に伝え、利用可能な最善の代替案(例: フォローアップを送信)を提示する。

UIの監査証跡

エンタープライズや規制対象のユースケースでは、監査証跡が不可欠です。

  • 不変アクションログ: すべてのエージェントアクション、ツール呼び出し、人間の承認を、タイムスタンプ、ユーザー識別情報、完全なパラメータとともに記録する。

  • エクスポート可能な履歴: コンプライアンス報告のため、監査証跡をJSON、CSV、PDFとしてエクスポートできる。

  • 差分ビュー: 文書やコードの変更では、監査証跡に変更前後の差分を含める。

  • セッション再生: デバッグやコンプライアンスレビューのため、エージェントセッション全体をステップごとに再生できる。

ユーザーの期待の管理

調整されていない期待は、ユーザーの不信の主な原因です。エージェント型UIは期待を積極的に管理すべきです。

  • 能力の開示: エージェントができることとできないことを、明確でアクセシブルな文書として示す。

  • 限界の認識: エージェントが能力外のタスクに遭遇したら、試行して黙って失敗するのではなく、そのことを明確に伝える。

  • 不確実性の伝達: ユーザーが誤りを発見するまで待つのではなく、不確実性を先回りして伝える。

  • 一貫したペルソナ: 一貫したエージェントのアイデンティティとコミュニケーションスタイルが、親しみやすさと予測可能性を築く。

Note

透明性による信頼構築: ケーススタディ

航空券の予約を任されたエージェントを考えてみましょう。信頼度の低いUIは「航空券を予約しました。確認番号: AA1234」と表示します。信頼度の高いUIは、(1)使用した検索パラメータの要約、(2)検討した代替案とこのフライトを選んだ理由、(3)実行した正確なアクション(予約システムへのAPI呼び出し)、(4)予約へのリンク付き確認詳細、(5)次の24時間有効な取り消しオプション、(6)エージェントにできないことの注記(例:「この予約は変更できません。航空会社へ直接電話してください」)を提示します。後者のUIはより多くの画面領域を使いますが、エージェントが正しく行動したというユーザーの信頼を築き、必要に応じて検証・復旧するための情報を与えます。

完全な実装例: フルスタックのエージェント型UI

ここでは、Python/Reactスタックでストリーミング、ツール可視化、承認ゲートを組み合わせた具体的な実装例を示します。バックエンドはLangGraphとFastAPIを使い、フロントエンドはカスタムバックエンド向けに適応したVercel AI SDKパターンとReactを使います。

Backend: FastAPI + LangGraph with Streaming and Approval Gates

# backend/main.py
import asyncio
import json
from typing import AsyncGenerator
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool

app = FastAPI()

# -- Tool definitions ----------------------------------------------------------

@tool
def web_search(query: str) -> str:
    """Search the web for information."""
    return f"Search results for '{query}': [simulated results]"

@tool
def send_email(to: str, subject: str, body: str) -> str:
    """Send an email. REQUIRES HUMAN APPROVAL."""
    return f"Email sent to {to} with subject '{subject}'"

@tool
def read_file(path: str) -> str:
    """Read a file from the filesystem."""
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError:
        return f"Error: File not found: {path}"

# Tools requiring approval (Tier 3)
APPROVAL_REQUIRED_TOOLS = {"send_email"}

# -- Approval gate store (in-memory; use Redis in production) ------------------

approval_store: dict[str, asyncio.Event] = {}
approval_results: dict[str, dict] = {}

# -- LLM setup -----------------------------------------------------------------

llm = ChatOpenAI(model="gpt-4o", streaming=True)
tools = [web_search, send_email, read_file]
llm_with_tools = llm.bind_tools(tools)

def should_request_approval(tool_name: str) -> bool:
    return tool_name in APPROVAL_REQUIRED_TOOLS

# -- Streaming endpoint --------------------------------------------------------

async def agent_stream(
    session_id: str,
    user_message: str,
) -> AsyncGenerator[str, None]:
    """Stream agent events as SSE."""

    def sse(event_type: str, data: dict) -> str:
        return f"data: {json.dumps({'type': event_type, **data})}\n\n"

    yield sse("status", {"message": "Agent starting..."})

    # Simulate multi-step agent execution
    steps = [
        ("thinking", {"content": "Analyzing the request..."}),
        ("tool_call", {
            "tool": "web_search",
            "input": {"query": user_message},
            "tier": 1,  # Auto-approve
        }),
        ("tool_result", {
            "tool": "web_search",
            "output": f"Results for: {user_message}",
            "duration_ms": 342,
        }),
    ]

    for event_type, data in steps:
        await asyncio.sleep(0.5)  # Simulate processing time
        yield sse(event_type, data)

    # Simulate a Tier 3 action requiring approval
    approval_id = f"{session_id}_email_001"
    approval_event = asyncio.Event()
    approval_store[approval_id] = approval_event

    yield sse("approval_required", {
        "approval_id": approval_id,
        "tool": "send_email",
        "tier": 3,
        "risk": "irreversible",
        "action_summary": "Send summary email to user@example.com",
        "parameters": {
            "to": "user@example.com",
            "subject": f"Research results: {user_message}",
            "body": "Here are the findings...",
        },
    })

    # Wait for human approval (timeout after 5 minutes)
    try:
        await asyncio.wait_for(approval_event.wait(), timeout=300)
        result = approval_results.get(approval_id, {})

        if result.get("approved"):
            yield sse("tool_call", {
                "tool": "send_email",
                "input": result.get("parameters", {}),
                "tier": 3,
                "approved_by": "human",
            })
            await asyncio.sleep(0.3)
            yield sse("tool_result", {
                "tool": "send_email",
                "output": "Email sent successfully",
                "duration_ms": 128,
            })
        else:
            yield sse("action_rejected", {
                "tool": "send_email",
                "reason": result.get("reason", "User rejected"),
            })
    except asyncio.TimeoutError:
        yield sse("approval_timeout", {
            "approval_id": approval_id,
            "message": "Approval timed out; action skipped",
        })

    # Final response
    yield sse("token", {"content": "I've completed the research. "})
    yield sse("token", {"content": "Here's a summary of what I found..."})
    yield sse("done", {"total_tokens": 847, "duration_ms": 2341})

@app.get("/chat/stream")
async def chat_stream(session_id: str, message: str):
    return StreamingResponse(
        agent_stream(session_id, message),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )

class ApprovalRequest(BaseModel):
    approval_id: str
    approved: bool
    parameters: dict | None = None
    reason: str | None = None

@app.post("/chat/approve")
async def handle_approval(req: ApprovalRequest):
    if req.approval_id not in approval_store:
        raise HTTPException(status_code=404, detail="Approval not found")
    approval_results[req.approval_id] = {
        "approved": req.approved,
        "parameters": req.parameters,
        "reason": req.reason,
    }
    approval_store[req.approval_id].set()
    return {"status": "ok"}

Frontend: React with Streaming and Tool Visualization

// frontend/AgentChat.tsx
import { useState, useEffect, useRef } from 'react';

// -- Types ---------------------------------------------------------------------

type AgentEvent =
  | { type: 'status'; message: string }
  | { type: 'thinking'; content: string }
  | { type: 'token'; content: string }
  | { type: 'tool_call'; tool: string; input: object; tier: number }
  | { type: 'tool_result'; tool: string; output: string; duration_ms: number }
  | { type: 'approval_required'; approval_id: string; tool: string;
      tier: number; risk: string; action_summary: string; parameters: object }
  | { type: 'action_rejected'; tool: string; reason: string }
  | { type: 'done'; total_tokens: number; duration_ms: number };

// -- Tool Card Component -------------------------------------------------------

function ToolCard({ event }: { event: AgentEvent & { type: 'tool_call' } }) {
  const [expanded, setExpanded] = useState(false);
  const tierColors = { 1: '#22c55e', 2: '#f59e0b', 3: '#ef4444' };
  const color = tierColors[event.tier as keyof typeof tierColors] || '#6b7280';

  return (
    <div style={{ border: `1px solid ${color}`, borderRadius: 8, padding: 8,
                  margin: '4px 0', fontSize: 13 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <span style={{ color, fontWeight: 600 }}>[gear] {event.tool}</span>
        <span style={{ color: '#6b7280', fontSize: 11 }}>
          Tier {event.tier} . {event.tier === 1 ? 'Auto' : 'Approved'}
        </span>
        <button onClick={() => setExpanded(!expanded)}
                style={{ marginLeft: 'auto', fontSize: 11 }}>
          {expanded ? 'Hide' : 'Details'}
        </button>
      </div>
      {expanded && (
        <pre style={{ marginTop: 8, fontSize: 11, background: '#f3f4f6',
                      padding: 8, borderRadius: 4, overflow: 'auto' }}>
          {JSON.stringify(event.input, null, 2)}
        </pre>
      )}
    </div>
  );
}

// -- Approval Gate Component ---------------------------------------------------

function ApprovalGate({
  event,
  onDecision,
}: {
  event: AgentEvent & { type: 'approval_required' };
  onDecision: (approved: boolean, params?: object) => void;
}) {
  const riskColors = { reversible: '#22c55e', 'hard-to-undo': '#f59e0b',
                       irreversible: '#ef4444' };
  const riskColor = riskColors[event.risk as keyof typeof riskColors] || '#6b7280';

  return (
    <div style={{ border: `2px solid ${riskColor}`, borderRadius: 8,
                  padding: 16, margin: '8px 0', background: '#fef9f0' }}>
      <div style={{ fontWeight: 700, color: riskColor, marginBottom: 8 }}>
        [!] Approval Required: {event.tool}
      </div>
      <div style={{ marginBottom: 8 }}>{event.action_summary}</div>
      <div style={{ fontSize: 12, color: '#6b7280', marginBottom: 12 }}>
        Risk level: <span style={{ color: riskColor }}>{event.risk}</span>
      </div>
      <div style={{ display: 'flex', gap: 8 }}>
        <button
          onClick={() => onDecision(true, event.parameters)}
          style={{ background: '#22c55e', color: 'white', border: 'none',
                   borderRadius: 6, padding: '8px 16px', cursor: 'pointer' }}>
          [OK] Approve
        </button>
        <button
          onClick={() => onDecision(false)}
          style={{ background: '#ef4444', color: 'white', border: 'none',
                   borderRadius: 6, padding: '8px 16px', cursor: 'pointer' }}>
          [X] Reject
        </button>
      </div>
    </div>
  );
}

// -- Main Chat Component -------------------------------------------------------

export function AgentChat() {
  const [events, setEvents] = useState<AgentEvent[]>([]);
  const [response, setResponse] = useState('');
  const [isStreaming, setIsStreaming] = useState(false);
  const [input, setInput] = useState('');
  const sessionId = useRef(`session_${Date.now()}`);

  const sendMessage = async () => {
    if (!input.trim() || isStreaming) return;
    setEvents([]);
    setResponse('');
    setIsStreaming(true);

    const url = `/chat/stream?session_id=${sessionId.current}`
              + `&message=${encodeURIComponent(input)}`;
    const es = new EventSource(url);

    es.onmessage = (e) => {
      const event: AgentEvent = JSON.parse(e.data);
      if (event.type === 'token') {
        setResponse(prev => prev + event.content);
      } else if (event.type === 'done') {
        setIsStreaming(false);
        es.close();
      } else {
        setEvents(prev => [...prev, event]);
      }
    };

    es.onerror = () => { setIsStreaming(false); es.close(); };
    setInput('');
  };

  const handleApproval = async (
    approvalId: string,
    approved: boolean,
    parameters?: object,
  ) => {
    await fetch('/chat/approve', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ approval_id: approvalId, approved, parameters }),
    });
  };

  return (
    <div style={{ maxWidth: 800, margin: '0 auto', padding: 16 }}>
      <div style={{ minHeight: 400, border: '1px solid #e5e7eb',
                    borderRadius: 8, padding: 16, marginBottom: 16 }}>
        {events.map((event, i) => {
          if (event.type === 'tool_call')
            return <ToolCard key={i} event={event} />;
          if (event.type === 'approval_required')
            return (
              <ApprovalGate key={i} event={event}
                onDecision={(approved, params) =>
                  handleApproval(event.approval_id, approved, params)} />
            );
          if (event.type === 'status' || event.type === 'thinking')
            return (
              <div key={i} style={{ color: '#6b7280', fontSize: 12,
                                    fontStyle: 'italic', margin: '4px 0' }}>
                {event.type === 'thinking' ? event.content : event.message}
              </div>
            );
          return null;
        })}
        {response && (
          <div style={{ marginTop: 8, lineHeight: 1.6 }}>
            {response}
            {isStreaming && <span className="cursor-blink">|</span>}
          </div>
        )}
      </div>
      <div style={{ display: 'flex', gap: 8 }}>
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          onKeyDown={e => e.key === 'Enter' && sendMessage()}
          placeholder="Ask the agent..."
          style={{ flex: 1, padding: '8px 12px', borderRadius: 6,
                   border: '1px solid #d1d5db', fontSize: 14 }}
        />
        <button onClick={sendMessage} disabled={isStreaming}
                style={{ padding: '8px 16px', background: '#3b82f6',
                         color: 'white', border: 'none', borderRadius: 6,
                         cursor: isStreaming ? 'not-allowed' : 'pointer' }}>
          {isStreaming ? 'Running...' : 'Send'}
        </button>
      </div>
    </div>
  );
}

Note

この実装が実演すること

上のコードは、エージェント型UIの主要なパターンが連携して動作する様子を示しています。

  • SSEストリーミング: バックエンドは、異なる種類(ステータス、思考、ツール呼び出し、トークン)のイベントを、単一のHTTP接続上でストリーミングします。

  • 型付きイベントプロトコル: イベント型の判別可能な共用体によって、フロントエンドは各イベントを適切に描画できます。

  • ツールの可視化: ToolCardは、ティアインジケーターと展開可能な入力詳細を付けてツール呼び出しを描画します。

  • 承認ゲート: ApprovalGateはストリームをブロックし、エージェントが不可逆なアクションを進める前に人間の入力を待ちます。

  • 非同期承認: バックエンドはasyncio.Eventを使って、フロントエンドからの承認POSTリクエストを待つ間ストリームを一時停止し、承認UIとストリーミングロジックをきれいに分離します。

まとめ

エージェント型UIフレームワークは、人間とコンピューターのインタラクションにおける新たなフロンティアであり、インターフェース設計を第一原理から再考することを求めます。この節の主な洞察は次のとおりです。

  1. パラダイムの選択が重要: 適切なUIパラダイム(チャット、キャンバス、ワークフロー、ダッシュボード、協調型、自律型)は、タスクの構造、人間の関与の必要性、出力の種類によって決まります。本番システムの多くは複数のパラダイムを組み合わせます。

  2. 透明性は譲れない: ユーザーは見えないものを信頼できません。思考の表示、ツールの可視化、コンテキストパネルはオプション機能ではなく、信頼できるエージェント型システムの基盤です。

  3. ストリーミングが基準: ユーザーはエージェントがリアルタイムに動作する様子を期待します。トークンストリーミング、ツール呼び出しのストリーミング、マルチエージェントストリーミングは、今や必須の能力です。

  4. 承認ゲートは段階化すべき: 一律の承認ポリシー(すべて承認するか、何も承認しないか)は実際には機能しません。安全なアクションを自動承認し、危険なアクションをゲートする段階的なポリシーなら、ボトルネックを生じさせずに監督を維持できます。

  5. 生成UIがフロンティア: LLMがテキストだけでなく、チャート、フォーム、地図、ウィジェットなどのUIコンポーネントも生成できれば、コンテンツを固定テンプレートに押し込むのではなく、コンテンツに適応するインターフェースが可能になります。

  6. 信頼は一貫性と回復可能性によって得られる: ユーザーの信頼を築くには、取り消し機能、監査証跡、調整された信頼度インジケーターが、能力そのものと同じくらい重要です。

Important

設計原則: 透明な協働者としてのエージェント

エージェント型UI設計の北極星は、透明な協働者です。つまり、アクションが常に見え、推論に常にアクセスでき、間違いから常に回復でき、能力と限界が常に明確なエージェントです。すべてのUI上の判断は、この基準に照らして評価すべきです。

この節で説明したフレームワークとパターン(Vercel AI SDK、Chainlit、Gradio、Streamlit、LangGraph Studio)は、構成要素を提供します。実務者にとっての課題は、ユーザー固有のニーズと、それぞれの領域固有のリスクを指針として、これらを慎重に組み合わせることです。