Vercel AI SDK 6 : AI SDK UI – チャットボット (2)

useChat フックは、チャットボットアプリケーション用の会話型ユーザーインターフェイスの作成を簡単にします。AI プロバイダーからのチャットメッセージのストリーミングを可能にし、チャット状態を管理し、新しいメッセージを受け取ると同時に UI を自動的に更新します。

Vercel AI SDK 6 : AI SDK UI – チャットボット (2)

作成 : Masashi Okumura (@classcat.com)
作成日時 : 02/11/2026
バージョン : ai@6.0.79

* 本記事は ai-sdk.dev/docs の以下のページを参考にしています :

* サンプルコードの動作確認はしておりますが、必要な場合には適宜、追加改変しています。
* ご自由にリンクを張って頂いてかまいませんが、sales-info@classcat.com までご一報いただけると嬉しいです。

 

クラスキャット AI 研究開発支援サービス ⭐️ 創立30周年(30th Anniversary)🎉💐

クラスキャット は AI に関する各種サービスを提供しています。お気軽にご相談ください :

  • AI 研究開発支援 [詳細]

    1. AI エージェント構築支援
    2. 画像認識 (医療系含む) / 画像生成

  • AI 導入個別相談会(無償)実施中! [詳細]

  • PoC(概念実証)を失敗させないための支援 [詳細]

お問合せ : 下記までお願いします。

  • クラスキャット セールス・インフォメーション
  • sales-info@classcat.com
  • ClassCatJP

 

 

Vercel AI SDK 6.x : AI SDK UI – チャットボット (2)

Vercel AI SDK 6.x : AI SDK UI – チャットボット の続きです。

リクエスト構成設定

カスタムヘッダ、ボディ、認証情報

デフォルトでは、useChat フックはメッセージリストをリクエストボディとして含む、HTTP POST リクエストを /api/chat エンドポイントに送信します。リクエストを 2 つの方法でカスタマイズできます :

 

フックレベルの設定 (すべてのリクエストに適用)

フックによるすべてのリクエストに適用される、トランスポートレベルのオプションを設定できます :

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

const { messages, sendMessage } = useChat({
  transport: new DefaultChatTransport({
    api: '/api/custom-chat',
    headers: {
      Authorization: 'your_token',
    },
    body: {
      user_id: '123',
    },
    credentials: 'same-origin',
  }),
});

 

動的なフックレベルの設定

設定値を返す関数を提供することもできます。これは、認証トークンの更新が必要な場合や、実行時の条件に依存する設定に対して有用です :

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

const { messages, sendMessage } = useChat({
  transport: new DefaultChatTransport({
    api: '/api/custom-chat',
    headers: () => ({
      Authorization: `Bearer ${getAuthToken()}`,
      'X-User-ID': getCurrentUserId(),
    }),
    body: () => ({
      sessionId: getCurrentSessionId(),
      preferences: getUserPreferences(),
    }),
    credentials: () => 'include',
  }),
});

 

リクエストレベルの設定 (推奨)

推奨: より良い柔軟性と制御のためにはリクエストレベルのオプションを使用します。リクエストレベルのオプションはフックレベルのオプションよりも優先され、各リクエストを個別にカスタマイズできます。

// Pass options as the second parameter to sendMessage
sendMessage(
  { text: input },
  {
    headers: {
      Authorization: 'Bearer token123',
      'X-Custom-Header': 'custom-value',
    },
    body: {
      temperature: 0.7,
      max_tokens: 100,
      user_id: '123',
    },
    metadata: {
      userId: 'user123',
      sessionId: 'session456',
    },
  },
);

リクエストレベルのオプションはフックレベルのオプションとマージされ、リクエストレベルのオプションが優先されます。サーバ側では、この追加情報を含むリクエストを処理できます。

 

リクエスト毎にカスタム・ボディフィールドを設定

sendMessage 関数の 2 番目のパラメータを使用して、リクエスト毎にカスタム・ボディフィールドを設定できます。これは、メッセージリストの一部ではない追加の情報をバックエンドに渡したい場合に有用です。

app/page.tsx

'use client';

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

export default function Chat() {
  const { messages, sendMessage } = useChat();
  const [input, setInput] = useState('');

  return (
    <div>
      {messages.map(m => (
        <div key={m.id}>
          {m.role}:{' '}
          {m.parts.map((part, index) =>
            part.type === 'text' ? <span key={index}>{part.text} : null,
          )}
        </div>
      ))}

      <form
        onSubmit={event => {
          event.preventDefault();
          if (input.trim()) {
            sendMessage(
              { text: input },
              {
                body: {
                  customKey: 'customValue',
                },
              },
            );
            setInput('');
          }
        }}
      >
        <input value={input} onChange={e => setInput(e.target.value)} />
      </form>
    </div>
  );
}

リクエストボディを分解することで、サーバ側でこれらのカスタムフィールドを取得できます :

app/api/chat/route.ts

export async function POST(req: Request) {
  // Extract additional information ("customKey") from the body of the request:
  const { messages, customKey }: { messages: UIMessage[]; customKey: string } =
    await req.json();
  //...
}

 

メッセージ・メタデータ

タイムスタンプ、モデル詳細、トークンの使用状況のような情報を追跡するために、メッセージにカスタムメタデータを添付できます。

// Server: Send metadata about the message
return result.toUIMessageStreamResponse({
  messageMetadata: ({ part }) => {
    if (part.type === 'start') {
      return {
        createdAt: Date.now(),
        model: 'gpt-5.1',
      };
    }

    if (part.type === 'finish') {
      return {
        totalTokens: part.totalUsage.totalTokens,
      };
    }
  },
});
// Client: Access metadata via message.metadata
{
  messages.map(message => (
    <div key={message.id}>
      {message.role}:{' '}
      {message.metadata?.createdAt &&
        new Date(message.metadata.createdAt).toLocaleTimeString()}
      {/* Render message content */}
      {message.parts.map((part, index) =>
        part.type === 'text' ? <span key={index}>{part.text}</span> : null,
      )}
      {/* Show token count if available */}
      {message.metadata?.totalTokens && (
        <span>{message.metadata.totalTokens} tokens</span>
      )}
    </div>
  ));
}

 

トランスポート設定

transport オプションを使用して、メッセージが API に送信される方法をカスタマイズするためにカスタム・トランスポートの動作を設定できます :

app/page.tsx

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

export default function Chat() {
  const { messages, sendMessage } = useChat({
    id: 'my-chat',
    transport: new DefaultChatTransport({
      prepareSendMessagesRequest: ({ id, messages }) => {
        return {
          body: {
            id,
            message: messages[messages.length - 1],
          },
        };
      },
    }),
  });

  // ... rest of your component
}

対応する API route はカスタム・リクエスト形式を受信します :

Provider app/api/chat/route.ts

export async function POST(req: Request) {
  const { id, message } = await req.json();

  // Load existing messages and add the new one
  const messages = await loadMessages(id);
  messages.push(message);

  const result = streamText({
    model: openai("gpt-4o-mini"),
    messages: await convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}

 

レスポンスストリームの制御

streamText を使用して、エラーメッセージと使用量情報がクライアントに送信される方法を制御できます。

 

エラーメッセージ

デフォルトでは、エラーメッセージはセキュリティ上の理由でマスクされます。デフォルトのエラーメッセージは “An error occurred.” です。getErrorMessage 関数を提供することで、エラーメッセージを転送したり独自のエラーメッセージを送信できます :

Provider app/api/chat/route.ts

import { convertToModelMessages, streamText, UIMessage } from 'ai';
import { openai } from "@ai-sdk/openai";

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: openai("gpt-4o-mini"),
    messages: await convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse({
    onError: error => {
      if (error == null) {
        return 'unknown error';
      }

      if (typeof error === 'string') {
        return error;
      }

      if (error instanceof Error) {
        return error.message;
      }

      return JSON.stringify(error);
    },
  });
}

 

使用量情報

メッセージメタデータ によりトークン消費量とリソース使用量を追跡できます :

  1. usage フィールドを含むカスタム・メタデータ型を定義します (型安全性のためにオプション)

  2. レスポンスで messageMetadata を使用して使用量データを添付します

  3. UI コンポーネントで使用量メトリクスを表示します

使用量データはメタデータとしてメッセージに添付され、モデルがレスポンス生成を完了すれば利用可能になります。

Provider

import { openai } from '@ai-sdk/openai';
import {
  convertToModelMessages,
  streamText,
  UIMessage,
  type LanguageModelUsage,
} from 'ai';
import { openai } from "@ai-sdk/openai";

// Create a new metadata type (optional for type-safety)
type MyMetadata = {
  totalUsage: LanguageModelUsage;
};

// Create a new custom message type with your own metadata
export type MyUIMessage = UIMessage;

export async function POST(req: Request) {
  const { messages }: { messages: MyUIMessage[] } = await req.json();

  const result = streamText({
    model: openai("gpt-4o-mini"),
    messages: await convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse({
    originalMessages: messages,
    messageMetadata: ({ part }) => {
      // Send total usage when generation is finished
      if (part.type === 'finish') {
        return { totalUsage: part.totalUsage };
      }
    },
  });
}

そしてクライアントでは、メッセージレベルのメタデータにアクセスできます。

'use client';

import { useChat } from '@ai-sdk/react';
import type { MyUIMessage } from './api/chat/route';
import { DefaultChatTransport } from 'ai';

export default function Chat() {
  // Use custom message type defined on the server (optional for type-safety)
  const { messages } = useChat({
    transport: new DefaultChatTransport({
      api: '/api/chat',
    }),
  });

  return (
    
{messages.map(m => (
{m.role === 'user' ? 'User: ' : 'AI: '} {m.parts.map(part => { if (part.type === 'text') { return part.text; } })} {/* Render usage via metadata */} {m.metadata?.totalUsage && (
Total usage: {m.metadata?.totalUsage.totalTokens} tokens
)}
))}
); }

useChat の onFinish コールバックからメタデータにアクセスすることもできます :

'use client';

import { useChat } from '@ai-sdk/react';
import type { MyUIMessage } from './api/chat/route';
import { DefaultChatTransport } from 'ai';

export default function Chat() {
  // Use custom message type defined on the server (optional for type-safety)
  const { messages } = useChat({
    transport: new DefaultChatTransport({
      api: '/api/chat',
    }),
    onFinish: ({ message }) => {
      // Access message metadata via onFinish callback
      console.log(message.metadata?.totalUsage);
    },
  });
}

 

テキスト・ストリーム

useChat は streamProtocol オプション text に設定することで、plain テキストストリームを処理できます :

app/page.tsx

'use client';

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

export default function Chat() {
  const { messages } = useChat({
    transport: new TextStreamChatTransport({
      api: '/api/chat',
    }),
  });

  return <>...</>;
}

 

アタッチメント

useChat フックはメッセージとともにファイル添付の送信と、クライアント上でのそれらのレンダリングをサポートしています。これは、画像、ファイル、その他のメディア・コンテンツを AI プロバイダーに送信するようなアプリケーションを構築するために有用です。

メッセージとともにファイルを送信するには 2 つの方法があります : ファイル入力から FileList オブジェクトを使用するか、ファイルオブジェクトの配列を使用します。

 

FileList

FileList を使用することで、ファイル入力要素を使用して、複数のファイルをアタッチメントとしてメッセージとともに送信できます。useChat フックはそれらを自動的にデータ URL に変換して、AI プロバイダーに送信します。

app/page.tsx

'use client';

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

export default function Page() {
  const { messages, sendMessage, status } = useChat();

  const [input, setInput] = useState('');
  const [files, setFiles] = useState(undefined);
  const fileInputRef = useRef(null);

  return (
    <div>
      <div>
        {messages.map(message => (
          <div key={message.id}>
            <div>{`${message.role}: `}</div>

            <div>
              {message.parts.map((part, index) => {
                if (part.type === 'text') {
                  return {part.text};
                }

                if (
                  part.type === 'file' &&
                  part.mediaType?.startsWith('image/')
                ) {
                  return {part.filename};
                }

                return null;
              })}
            </div>
          </div>
        ))}
      </div>

      <form
        onSubmit={event => {
          event.preventDefault();
          if (input.trim()) {
            sendMessage({
              text: input,
              files,
            });
            setInput('');
            setFiles(undefined);

            if (fileInputRef.current) {
              fileInputRef.current.value = '';
            }
          }
        }}
      >
        <input
          type="file"
          onChange={event => {
            if (event.target.files) {
              setFiles(event.target.files);
            }
          }}
          multiple
          ref={fileInputRef}
        />
        <input
          value={input}
          placeholder="Send message..."
          onChange={e => setInput(e.target.value)}
          disabled={status !== 'ready'}
        />
      </form>
    </div>
  );
}

 

ファイルオブジェクト

ファイルをオブジェクトとしてメッセージとともに送信することもできます。これは、事前アップロードされたファイルやデータ URL を送信するために便利です。

app/page.tsx

'use client';

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

export default function Page() {
  const { messages, sendMessage, status } = useChat();

  const [input, setInput] = useState('');
  const [files] = useState([
    {
      type: 'file',
      filename: 'earth.png',
      mediaType: 'image/png',
      url: 'https://example.com/earth.png',
    },
    {
      type: 'file',
      filename: 'moon.png',
      mediaType: 'image/png',
      url: 'data:image/png;base64,iVBORw0KGgo...',
    },
  ]);

  return (
    <div>
      <div>
        {messages.map(message => (
          <div key={message.id}>
            <div>{`${message.role}: `}</div>

            <div>
              {message.parts.map((part, index) => {
                if (part.type === 'text') {
                  return <span key={index}>{part.text};
                }

                if (
                  part.type === 'file' &&
                  part.mediaType?.startsWith('image/')
                ) {
                  return <img key={index} src={part.url} alt={part.filename} />;
                }

                return null;
              })}
            </div>
          </div>
        ))}
      </div>

      <form
        onSubmit={event => {
          event.preventDefault();
          if (input.trim()) {
            sendMessage({
              text: input,
              files,
            });
            setInput('');
          }
        }}
      >
        <input
          value={input}
          placeholder="Send message..."
          onChange={e => setInput(e.target.value)}
          disabled={status !== 'ready'}
        />
      </form>
    </div>
  );
}

 

以上