2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI SDK UIでAIチャットアプリのUI周りの簡素化を目指す (道半ば)

Posted at

はじめに

先日、以下のような構成のAIチャットアプリをお試しで作りました。

ただ、フロントエンドの状態管理やデータ取得周りが複雑だったので、AI SDK UIを使用し、簡単にUI周りを実装できないか試してみました。

image.png

背景としては、本当はNext.sjをAmplify Hostingでホスティングしたかったのですが、Amplify HostingがNext.jsのストリーミングに対応していなかったので、上記のViteを使った構成になっています。

本記事のポイント

AI SDKを使う場合、「AI SDK UI」+「AI SDK Core」を一緒に使った方が、フロントエンドとバックエンド間のやり取りをいい感じやってくれます。

やりたかったこと

しかし、AI SDK Coreは、AgentCoreを直接呼び出せないため、AI SDK Coreを使うことは断念しました。
そのため、バックエンドは自前で実装して、AI SDK UIのみでフロントエンドが楽にならないかな?という話になります。

妥協案(本記事の内容)

概要

AI SDK とは

AI SDKは、Next.jsの開発元で有名なVercel社が開発しているAIエージェント開発用SDKです。全てTypeScriptで記述されており、Next.jsとも親和性の高いSDKです。

機能として大きく以下の2つがあります。今回は、そのうちの「AI SDK UI」を使います。

  1. AI SDK Core
    • LLMを呼び出してエージェントを実装するためのAPIを提供してくれます
  2. AI SDK UI
    • エージェントのUIを構築するためのフックを提供してくれます

AI Elements というのもあるよ
AI Elementsは、AI SDKが提供するUIコンポーネント集です。
これを使えば、簡単にAIエージェントのUIを作ることも可能です。
https://ai-sdk.dev/elements

ただし、前提条件にNext.jsとあったの、今回の構成では使えなさそう...

AI SDK UI とは

AI SDK UIは、AI SDKが提供するAIエージェントのUIを実装する際に利用できるフック集です。
フックなので、ボタンやテキストフィールドといったUIコンポーネントは含まれません。

つまり、UIデザインは好きにカスタマイズして、バックエンドとの接続や状態管理といった裏の処理を良しなにやってくれる機能群ということになります。

useChatが便利そう

AI SDK UIが提供するフックの中に「useChat」というものがあります。
これは、バックエンドへのfetch処理、状態管理などUI側のロジック周りを良しなに行ってくれます。ストリーミングにも対応しています。

Reactだと、以下のようにuseChatを定義し、バックエンドのAPIを指定します。
すると、useChatがAPIへfetchして、そのレスポンスをいい感じに処理してくれます。
結果として、チャットメッセージの内容や通信状態といった情報を返してくれます。

import { useChat } from '@ai-sdk/react';

exprot const ChatBot = () => {
  const { messages, sendMessage, status } = useChat({
    transport: new DefaultChatTransport({
      api: '/api/chat',
    }),
  });

  return(
   // チャット表示コンポーネント
  )
}

ストリーミングのやり方2種類ある

AI SDK UIでストリーム通信をしたい場合は、以下の2種類のプロトコルがあります。

  1. テキストストリームプロトコル
  2. データストリームプロトコル

テキストストリームは、文字通りテキストベースのやり取りなので、ツールやファイルなどデータのやり取りを表現することは出来なさそうです。
なので、今回は「データストリームプロトコル」を使用します。

データストリームプロトコルのやり方

Next.jsを使う場合は、フロントエンドでAI SDK UIを設定し、バックエンドでAI SDK Coreを設定すれば、このあたりの通信処理を抽象化してくれます。

ただし、今回はNext.jsを使っていないので、バックエンドでAI SDK UIが解釈できるように変換してあげる必要があります。

データストリームプロトコルは、プロトコルとある通り、この形式で返してね。という決まりがあります。
ざっくりとは...

  • ストリーミングは「サーバー送信イベント(SSE)」を使う
  • SSEの「data」には、イベント毎に決められたJSONフォーマットで定義する

例えば、テキストチャットのやり取りは、以下のようなイベントになります。

Claudeを使う場合のレスポンス形式で当てはめる

仮に、LLMにClaudeを使用した際、テキストチャットでは以下のようなレスポンス形式でチャンクが返ってきます。

Claudeレスポンス形式

順番 イベント名 内容
1 メッセージ開始 {
 "event": {
  "messageStart": {
   "role": "assistant"
  }
 }
}
2 テキストデルタ {
 "event": {
  "contentBlockDelta": {
   "delta": {"text": "Hello"},
   "contentBlockIndex": 0
  }
 }
}
3 テキストデルタ {
 "event": {
  "contentBlockDelta": {
   "delta": {"text": "Every"},
   "contentBlockIndex": 0
  }
 }
}
4 テキストデルタ {
 "event": {
  "contentBlockDelta": {
   "delta": {"text": "One"},
   "contentBlockIndex": 0
  }
 }
}
5 テキスト終了 {
 "contentBlockStop": {
  "contentBlockIndex": 0
 }
}
6 メッセージ終了 {
 "event": {
  "messageStop": {
   "stopReason": "end_turn"
  }
 }
}
7 使用量 {
 "type": "finish",
 "finishReason": "stop",
 "usage": {
  "promptTokens": 50,
  "completionTokens": 20,
  "totalTokens": 70
 }
}

このClaudeのレスポンスをデータストリームの形式に沿ってJSONで返す必要があります。

データストリーム形式

順番 イベント名 内容
1 メッセージ開始 {
 "type":"text-start",
 "id":"msg_6867"
}
2 テキスト開始 {
 "type":"text-start",
 "id":"msg_6867"
}
3 テキストデルタ {
 "type":"text-delta",
 "id":"msg_6867",
 "delta":"Hello"
}
4 テキストデルタ {
 "type":"text-delta",
 "id":"msg_6867",
 "delta":"Every"
}
5 テキストデルタ {
 "type":"text-delta",
 "id":"msg_6867",
 "delta":"One!!"
}
6 テキスト終了 {
 "type":"text-end",
 "id":"msg_6867"
}
7 メッセージ終了 {
 "type":"finish"
}
8 ストリーム終了 [DONE]

Claudeからはストリーム終了テキスト開始のイベントは返ってこないので、自前で用意します。
はい。とても面倒ですね。

別案もありそう

AI SDKからAgentCoreを呼び出せれば、変換する必要はない!
以下はAI SDKからAgentCoreを呼び出すサンプルのコードですね。最高です。

やってみる

最終的に以下のような感じなりました。(ドシンプルなテキストチャットです)
demo6.gif

前提

以下の構成を構築済みの状態を前提とします。

フロントエンド

AI SDKを追加します。

npm i ai

さっそく、useChatを使っていきます。
DefaultChatTransportは、useChatを拡張するためのインターフェースです。
HTTPヘッダをカスタムする必要があったので、使っています。

抜粋
function useCustomChat(apiUrl: string) {
  const [idToken, setIdToken] = useState<string>('');
  const [accessToken, setAccessToken] = useState<string>('');
  const tokensRef = useRef({ idToken: '', accessToken: '' });
  const { getAuthTokens } = useAuth();

  useEffect(() => {
    const initializeTokens = async () => {
      const { idToken, accessToken } = await getAuthTokens();
      setIdToken(idToken || '');
      setAccessToken(accessToken || '');
      tokensRef.current = {
        idToken: idToken || '',
        accessToken: accessToken || '',
      };
    };

    initializeTokens();
  }, [getAuthTokens]);

  useEffect(() => {
    tokensRef.current = { idToken, accessToken };
  }, [idToken, accessToken]);

  return useChat({
    transport: new DefaultChatTransport({
      api: apiUrl,
      headers: () => ({
        'Authorization': `Bearer ${tokensRef.current.idToken}`,
        'Accept': 'text/event-stream',
        'X-Access-Token': tokensRef.current.accessToken,
      }),
    }),
  });
}

あとは、上記のカスタムフックを呼び出します。

  • sendMessageを呼び出すとfetchが実行されます
  • messagesにレスポンスのメッセージが入ってくるので、画面へ表示します。

statusには、通信中、エラーなど、状態が入ってきます。
このように、useChatを使うと、UI周りのお仕事を良しなにやってくれます。便利!

  const { messages, sendMessage, status, error } = useCustomChat(
    'Lambdaの関数URL'
  );
コードはこちら
import { useState, useEffect, useRef } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import type { UIMessage } from 'ai';
import { useAuth } from '../../../hooks/useAuth';

const Header = ({ status }: { status: string }) => {
  return (
    <div className="backdrop-blur-md bg-white/80 dark:bg-gray-900/80 border-b border-gray-200/50 dark:border-gray-800/50 sticky top-0 z-10 shadow-sm">
      <div className="w-full px-6 py-4">
        <div className="flex items-center justify-between">
          <div>
            <h1 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent dark:from-blue-400 dark:to-purple-400">
              AI Assistant
            </h1>
          </div>
          <div className="flex items-center gap-2">
            <div className={`w-3 h-3 rounded-full ${
              status === 'streaming' ? 'bg-green-500 animate-pulse' :
              status === 'submitted' ? 'bg-yellow-500 animate-pulse' :
              status === 'error' ? 'bg-red-500' :
              'bg-gray-400'
            }`}></div>
            <span className="text-sm text-gray-600 dark:text-gray-400">
              {status === 'streaming' ? 'ストリーミング中...' :
                status === 'submitted' ? '送信済み...' :
                status === 'error' ? 'エラー' :
                ' 準備完了'}
            </span>
          </div>
        </div>
      </div>
    </div>
)
}
const StartMessage = () => {
  return (
    <div className="flex items-center justify-center h-full">
      <div className="text-center space-y-4">
        <div className="text-5xl">💬</div>
        <div>
          <p className="text-lg font-semibold text-gray-700 dark:text-gray-300">
            始めましょう
          </p>
          <p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
            下のボックスにメッセージを入力して送信してください
          </p>
        </div>
      </div>
    </div>
  )
}

function useCustomChat(apiUrl: string) {
  const [idToken, setIdToken] = useState<string>('');
  const [accessToken, setAccessToken] = useState<string>('');
  const tokensRef = useRef({ idToken: '', accessToken: '' });
  const { getAuthTokens } = useAuth();

  useEffect(() => {
    const initializeTokens = async () => {
      const { idToken, accessToken } = await getAuthTokens();
      setIdToken(idToken || '');
      setAccessToken(accessToken || '');
      tokensRef.current = {
        idToken: idToken || '',
        accessToken: accessToken || '',
      };
    };

    initializeTokens();
  }, [getAuthTokens]);

  useEffect(() => {
    tokensRef.current = { idToken, accessToken };
  }, [idToken, accessToken]);

  return useChat({
    transport: new DefaultChatTransport({
      api: apiUrl,
      headers: () => ({
        'Authorization': `Bearer ${tokensRef.current.idToken}`,
        'Accept': 'text/event-stream',
        'X-Access-Token': tokensRef.current.accessToken,
      }),
    }),
  });
}

export default function FullScreenChat() {
  const [input, setInput] = useState('');
  const messagesEndRef = useRef<HTMLDivElement>(null);
  const chatContainerRef = useRef<HTMLDivElement>(null);
  const { messages, sendMessage, status, error } = useCustomChat(
    'Lambdaの関数URL'
  );

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (input.trim()) {
      sendMessage({ text: input });
      setInput('');
    }
  };
  useEffect(() => {
    console.log("error:", error);
  }, [error]);

  const isLoading = status === 'streaming' || status === 'submitted';

  return (
    <div className="flex flex-col w-screen h-screen bg-gradient-to-br from-gray-50 via-gray-50 to-gray-100 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950">
      <Header status={status} />
      {/* チャット領域 */}
      <div ref={chatContainerRef} className="flex-1 overflow-y-auto px-6 py-6 scroll-smooth">
        <div className="w-full max-w-3xl mx-auto space-y-4">
          {messages.length === 0 && (
            <StartMessage />
          )}

          {messages.map((message: UIMessage) => (
            <div
              key={message.id}
              className={`flex gap-3 animate-in fade-in slide-in-from-bottom-2 ${
                message.role === 'user' ? 'justify-end' : 'justify-start'
              }`}
            >
              {message.role === 'assistant' && (
                <div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center flex-shrink-0 text-white text-sm font-bold">
                  AI
                </div>
              )}
              
              <div
                className={`max-w-xl px-4 py-3 rounded-2xl ${
                  message.role === 'user'
                    ? 'bg-gradient-to-br from-blue-500 to-blue-600 text-white rounded-br-none shadow-lg'
                    : 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none shadow-md border border-gray-200/50 dark:border-gray-700/50'
                }`}
              >
                <div className="whitespace-pre-wrap break-words text-sm leading-relaxed">
                  {message.parts && message.parts.length > 0 ? (
                    message.parts.map((part, i) => {
                      switch (part.type) {
                        case 'text':
                          console.log("Rendering message part:", part);
                          return (
                            <span key={`${message.id}-${i}`}>
                              {part?.text}
                            </span>
                          );
                        default:
                          return null;
                      }
                    })
                  ) : (
                    <span>...</span>
                  )}
                </div>
              </div>

              {message.role === 'user' && (
                <div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-600 flex items-center justify-center flex-shrink-0 text-white text-sm font-bold">
                  U
                </div>
              )}
            </div>
          ))}

          {isLoading && (
            <div className="flex gap-3 justify-start animate-in fade-in">
              <div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center flex-shrink-0">
                <div className="flex gap-1">
                  <div className="w-1.5 h-1.5 bg-white rounded-full animate-bounce"></div>
                  <div className="w-1.5 h-1.5 bg-white rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
                  <div className="w-1.5 h-1.5 bg-white rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
                </div>
              </div>
            </div>
          )}

          <div ref={messagesEndRef} />
        </div>
      </div>

      {/* 入力エリア */}
      <div className="backdrop-blur-md bg-white/80 dark:bg-gray-900/80 border-t border-gray-200/50 dark:border-gray-800/50 sticky bottom-0 px-6 py-4 shadow-lg">
        <div className="w-full max-w-3xl mx-auto">
          <form onSubmit={handleSubmit} className="flex gap-3 items-end">
            <div className="flex-1 relative">
              <input
                type="text"
                value={input}
                onChange={e => setInput(e.target.value)}
                placeholder="メッセージを入力..."
                disabled={isLoading}
                className="w-full px-4 py-3 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder-gray-500 dark:placeholder-gray-400 disabled:opacity-50 transition-all duration-200 shadow-sm"
              />
            </div>
            <button
              type="submit"
              disabled={isLoading || !input.trim()}
              className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 disabled:from-gray-400 disabled:to-gray-500 text-white font-semibold px-6 py-3 rounded-xl transition-all duration-200 shadow-md hover:shadow-lg disabled:shadow-none flex items-center gap-2 whitespace-nowrap"
            >
              {isLoading ? (
                <>
                  <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
                    <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
                    <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
                  </svg>
                  {status === 'streaming' ? 'ストリーミング中' : '送信中'}
                </>
              ) : (
                <>
                  送信
                  <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7m0 0l-7 7m7-7H6" />
                  </svg>
                </>
              )}
            </button>
          </form>
        </div>
      </div>
    </div>
  );
}

バックエンド(AWS Lambda)側の実装

バックエンドは、関数URLとストリーミングレスポンスを設定したAWS Lambdaです。
サーバー送信イベント(SSE)などには、Honoを使っています。

ここでは、以下のようなことをやっています。

  • フロントエンドからリクエストを受ける
  • AgentCoreを呼び出し、レスポンスを受け取る
  • 呼び出したLLMの形式でレスポンスが返ってくるので、データストリームの形式へ変換する
  • サーバー送信イベント(SSE)でフロントエンドへ返す

例えば...チャットの応答として、以下のようなレスポンスがAgentCoreが返ってきます。

{
  "event": {
    "contentBlockDelta": {
      "delta": {"text": "Hello"},
      "contentBlockIndex": 0
    }
  }
}

それを判定して...

if (parsed.event?.contentBlockStart) {
  await handleContentBlockStart(stream, messageId);
}

データストリーム形式のデータをサーバー通信イベント(SSE)で返してあげます。
これを各イベント毎に実装する感じになります。

/**
 * コンテンツブロック開始イベントを処理する
 * @param stream SSEストリーミングAPI
 * @param messageId メッセージID
 */
async function handleContentBlockStart(stream: SSEStreamingApi, messageId: string) {
  console.log("Detected: contentBlockStart");

  await stream.writeSSE({
    data: JSON.stringify({
      type: "text-start",
      id: messageId
    })
  });
}

うまくレスポンス出来ているかは、ブラウザの開発者ツール(Networkタブ)から確認できます。
image.png

コードはこちら
import { Hono } from 'hono';
import { streamHandle } from 'hono/aws-lambda';
import { streamSSE, SSEStreamingApi } from 'hono/streaming';
import { verifyJWT } from './lib/auth-utils';
import { getErrorMessage } from './lib/error-utils';


/**
 * リクエストからIDトークンとアクセストークンを抽出・検証する
 * @param req Lambda eventオブジェクト
 * @returns 検証済みのIDトークンとアクセストークン
 */
async function authenticate(req: any): Promise<{ idToken: string; accessToken: string }> {
  // AuthorizationヘッダーからIDトークンを取得
  const authHeader = req.header('authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    throw new Error('Missing ID token');
  }

  // IDトークンを抽出してJWT検証
  const idToken = authHeader.substring(7);
  const isValid = await verifyJWT(idToken);
  if (!isValid) {
    throw new Error('Invalid ID token');
  }

  // カスタムヘッダーからアクセストークンを取得
  const accessToken = req.header('x-access-token');
  if (!accessToken) {
    throw new Error('Missing access token');
  }

  return { idToken, accessToken };
}

/**
 * メッセージ開始イベントを処理する
 * @param stream SSEストリーミングAPI
 * @param messageId メッセージID
 */
async function handleMessageStart(stream: SSEStreamingApi, messageId: string): Promise<void> {
  console.log("Detected: messageStart");
  await stream.writeSSE({
    data: JSON.stringify({
      type: 'start',
      messageId: messageId
    })
  });
  await stream.writeSSE({
    data: JSON.stringify({
      type: "text-start",
      id: messageId
    })
  });
}

/**
 * コンテンツブロック開始イベントを処理する
 * @param stream SSEストリーミングAPI
 * @param messageId メッセージID
 */
async function handleContentBlockStart(stream: SSEStreamingApi, messageId: string) {
  console.log("Detected: contentBlockStart");

  await stream.writeSSE({
    data: JSON.stringify({
      type: "text-start",
      id: messageId
    })
  });
}

/**
 * コンテンツブロックデルタイベントを処理する
 * @param stream SSEストリーミングAPI
 * @param parsed パースされたイベントデータ
 */
async function handleContentBlockDelta(stream: SSEStreamingApi, parsed: any, messageId: string): Promise<void> {
  console.log("Detected: contentBlockDelta");
  const contentBlockDelta = parsed.event.contentBlockDelta;
  const text = contentBlockDelta?.delta?.text;

  if (!text) return;
  
  console.log("Sending text delta:", text, messageId);
  await stream.writeSSE({
    data: JSON.stringify({
      type: 'text-delta',
      id: messageId,
      delta: text
    })
  });
}

/**
 * コンテンツブロック終了イベントを処理する
 * @param stream SSEストリーミングAPI
 * @param messageId メッセージID
 */
async function handleContentBlockStop( stream: SSEStreamingApi, messageId: string): Promise<void> {
  console.log("Detected: contentBlockStop");
  stream.writeSSE({
    data: JSON.stringify({
      type: 'text-end',
      id: messageId
    })
  });
}

/**
 * メッセージ終了イベントを処理する
 * @param stream SSEストリーミングAPI
 */
async function handleMessageStop(stream: SSEStreamingApi): Promise<void> {
  console.log("Detected: messageStop");
  stream.writeSSE({
    data: JSON.stringify({
      type: 'finish'
    })
  });
}

/**
 * AWS Bedrock AgentCoreとの通信を処理し、Data Stream Protocol形式でストリーミングする
 * @param accessToken AWS Cognitoアクセストークン
 * @param prompt ユーザーからの入力プロンプト
 * @param stream SSEストリーミングAPI
 */
async function streamFromAgentCore(
  accessToken: string,
  prompt: string,
  stream: SSEStreamingApi
): Promise<void> {

  // 環境変数からエージェントエンドポイントIDを取得
  const agentEndpoint = process.env.AGENT_CORE_ENDPOINT || '';
  const awsRegion = process.env.AWS_REGION;
  const fullUrl = `https://bedrock-agentcore.${awsRegion}.amazonaws.com/runtimes/${encodeURIComponent(agentEndpoint)}/invocations`;

  // AgentCoreにPOSTリクエストを送信
  console.log("Sending request to AgentCore:", fullUrl);
  const response = await fetch(fullUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${accessToken}`,
    },
    body: JSON.stringify({ prompt: prompt.trim() }),
  });
  console.log("AgentCore response received");

  // レスポンスステータスをチェック
  if (!response.ok) {
    console.error("AgentCore returned error:", response.status, response.statusText);
    throw new Error(`AgentCore returned ${response.status}: ${response.statusText}`);
  }

  // レスポンスボディの存在確認
  if (!response.body) {
    console.error("No response body from AgentCore");
    throw new Error('No response body from AgentCore');
  }

  // ストリーミングレスポンスの処理開始
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  const messageId = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;

  try {
    while (true) {
      const { done, value } = await reader.read();

      if (done) {
        console.log("Reader done");
        break;
      }

      // バイナリデータをテキストに変換してバッファに追加
      buffer += decoder.decode(value, { stream: true });

      // 改行区切りでデータを処理
      let newlineIndex;
      while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
        const line = buffer.slice(0, newlineIndex).trim();
        buffer = buffer.slice(newlineIndex + 1);

        if (!line) continue;

        if (!line.startsWith('data: ')) {
          console.log("Received non-SSE line:", line.substring(0, 100));
          continue;
        }

        const data = line.slice(6).trim();
        const parsed = JSON.parse(data);
        console.log("Parsed AgentCore event:", JSON.stringify(parsed, null, 2));
        
        // メッセージ開始 
        if (parsed.event?.messageStart) {
          await handleMessageStart(stream, messageId);
        }

        // テキスト開始
        if (parsed.event?.contentBlockStart) {
          await handleContentBlockStart(stream, messageId);
        }

        // テキストデルタ
        if (parsed.event?.contentBlockDelta) {
          await handleContentBlockDelta(stream, parsed, messageId);
        }

        // テキスト終了
        if (parsed.event?.contentBlockStop) {
          await handleContentBlockStop(stream, messageId);
        }

        // 終了メッセージ
        if (parsed.event?.messageStop) {
          await handleMessageStop(stream);
        }
        console.log("==========Event processed successfully==========");
      }
    }
  } catch (error) {
    console.error("Error while streaming from AgentCore:", getErrorMessage(error));
    throw error;
  } finally {
    await stream.writeSSE({ data: '[DONE]' });
    // リソース解放
    reader.releaseLock();
    console.log("AgentCore streaming completed");
  }
}

const app = new Hono();

app.post('/', async (c) => {
  c.header('x-vercel-ai-data-stream', 'v1');
  return streamSSE(c, async (stream: SSEStreamingApi) => {
    try {
      // ユーザー認証の実行
      const { accessToken } = await authenticate(c.req);

      // // リクエストボディからプロンプトを取得
      const body = await c.req.json();
      console.log("Request body:", body);

      // messages 配列から最新のユーザーメッセージを取得
      const messages = body.messages || [];
      const userMessage = messages
        .reverse()
        .find((msg: any) => msg.role === 'user');
      console.log("User message:", userMessage);
      
      if (!userMessage) {
        console.error("No user message found in request");
        return;
      }

      // メッセージの parts から text を抽出
      const textPart = userMessage.parts?.find((part: any) => part.type === 'text');
      const prompt = textPart?.text?.trim();
      console.log("Prompt:", prompt);

      if (!prompt) {
        console.error("No text content in user message");
        return;
      }

      // SSE(Server-Sent Events)ストリームを作成
      console.log("Creating SSE Stream");
      await streamFromAgentCore(accessToken, prompt, stream);

    } catch (error) {
      console.error("Error in SSE handler:", error);

    } finally {
      console.log("Closing SSE stream");
      stream.close();
    }
  })
})

export const handler = streamHandle(app);

まとめ

AI SDK UIのおかげで、以下のフロントエンドの実装がスッキリしました。

  • バックエンドへのfetch処理
  • バックエンドからのレスポンス処理
  • 通信やメッセージの状態管理

ただ...
バックエンドのデータの変換が面倒くさい!
LLMごとに形式が異なるので、例えば、ClaudeのLLMモデルからOpenAIのLLMモデルに変えようと思ったら、変換処理も書き換えないとなんですよね。

フロントエンドとしては、AI SDKめっちゃ便利なので使いたいんですけどね...
ただAIエージェント環境は、AWSのAgentCoreめっちゃ便利ですよね...

AI SDKからAgenCoreを呼びたい!!

以上です。

2
0
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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?