Vercel AI SDK 6 : AI SDK UI – チャットボットメッセージの永続性

チャットメッセージの保存とロードが可能であることは殆どの AI チャットボットにとって重要です。このガイドでは、useChat と streamText を使用して、メッセージの永続性を実装する方法を示します。

Vercel AI SDK 6 : AI SDK UI – チャットボットメッセージの永続性

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

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

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

 

 

Vercel AI SDK 6.x : AI SDK UI – チャットボットメッセージの永続性

チャットメッセージの保存とロードが可能であることは殆どの AI チャットボットにとって重要です。このガイドでは、useChat と streamText を使用して、メッセージの永続性を実装する方法を示します。

ℹ️ This guide does not cover authorization, error handling, or other real-world considerations. It is intended to be a simple example of how to implement message persistence.

 

新しいチャットの開始

ユーザがチャット ID を提供せずにチャットページに移動した場合、新しいチャットを作成して新しいチャット ID でチャットページにリダイレクトする必要があります。

app/chat/page.tsx

import { redirect } from 'next/navigation';
import { createChat } from '@util/chat-store';

export default async function Page() {
  const id = await createChat(); // create a new chat
  redirect(`/chat/${id}`); // redirect to chat page, see below
}

この例のチャットストア実装は、ファイルを使用してチャットメッセージを保存しています。実際のアプリケーションでは、データベースやクラウドのストレージサービスを使用し、データベースからチャット ID を取得します。とは言うものの、関数インターフェイスは他の実装と簡単に置き換えられるように設計されています。

util/chat-store.ts

import { generateId } from 'ai';
import { existsSync, mkdirSync } from 'fs';
import { writeFile } from 'fs/promises';
import path from 'path';

export async function createChat(): Promise {
  const id = generateId(); // generate a unique chat ID
  await writeFile(getChatFile(id), '[]'); // create an empty chat file
  return id;
}

function getChatFile(id: string): string {
  const chatDir = path.join(process.cwd(), '.chats');
  if (!existsSync(chatDir)) mkdirSync(chatDir, { recursive: true });
  return path.join(chatDir, `${id}.json`);
}

 

既存のチャットのロード

ユーザがチャット ID によりチャットページに移動する場合、ストレージからチャットメッセージをロードする必要があります。

ファイルベースのチャットストアの loadChat 関数は以下のように実装されます :

util/chat-store.ts

import { UIMessage } from 'ai';
import { readFile } from 'fs/promises';

export async function loadChat(id: string): Promise {
  return JSON.parse(await readFile(getChatFile(id), 'utf8'));
}

// ... rest of the file

 

サーバ上でのメッセージの検証

ツール呼び出し、カスタムメタデータ、データパーツを含むメッセージをサーバ上で処理する場合、モデルに送信する前に、validateUIMessages を使用してそれらを検証する必要があります。

 

ツールによる検証

メッセージがツール呼び出しを含む場合、ツール定義を参照して検証します :

app/api/chat/route.ts

import {
  convertToModelMessages,
  streamText,
  UIMessage,
  validateUIMessages,
  tool,
} from 'ai';
import { z } from 'zod';
import { loadChat, saveChat } from '@util/chat-store';
import { openai } from '@ai-sdk/openai';
import { dataPartsSchema, metadataSchema } from '@util/schemas';

// Define your tools
const tools = {
  weather: tool({
    description: 'Get weather information',
    parameters: z.object({
      location: z.string(),
      units: z.enum(['celsius', 'fahrenheit']),
    }),
    execute: async ({ location, units }) => {
      /* tool implementation */
    },
  }),
  // other tools
};

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

  // Load previous messages from database
  const previousMessages = await loadChat(id);

  // Append new message to previousMessages messages
  const messages = [...previousMessages, message];

  // Validate loaded messages against
  // tools, data parts schema, and metadata schema
  const validatedMessages = await validateUIMessages({
    messages,
    tools, // Ensures tool calls in messages match current schemas
    dataPartsSchema,
    metadataSchema,
  });

  const result = streamText({
    model: 'openai/gpt-5-mini',
    messages: convertToModelMessages(validatedMessages),
    tools,
  });

  return result.toUIMessageStreamResponse({
    originalMessages: messages,
    onFinish: ({ messages }) => {
      saveChat({ chatId: id, messages });
    },
  });
}

 

検証エラーの処理

データベースからのメッセージが現在のスキーマに一致しない場合、検証エラーを適切に処理します :

app/api/chat/route.ts

import {
  convertToModelMessages,
  streamText,
  validateUIMessages,
  TypeValidationError,
} from 'ai';
import { type MyUIMessage } from '@/types';

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

  // Load and validate messages from database
  let validatedMessages: MyUIMessage[];

  try {
    const previousMessages = await loadMessagesFromDB(id);
    validatedMessages = await validateUIMessages({
      // append the new message to the previous messages:
      messages: [...previousMessages, message],
      tools,
      metadataSchema,
    });
  } catch (error) {
    if (error instanceof TypeValidationError) {
      // Log validation error for monitoring
      console.error('Database messages validation failed:', error);
      // Could implement message migration or filtering here
      // For now, start with empty history
      validatedMessages = [];
    } else {
      throw error;
    }
  }

  // Continue with validated messages...
}

 

チャットの表示

メッセージがストレージからロードされたら、チャット UI でそれらを表示できます。ページコンポーネントと Chat 表示のセットアップ方法は以下のとおりです :

app/chat/[id]/page.tsx

import { loadChat } from '@util/chat-store';
import Chat from '@ui/chat';

export default async function Page(props: { params: Promise<{ id: string }> }) {
  const { id } = await props.params;
  const messages = await loadChat(id);
  return ;
}

chat コンポーネントは useChat フックを使用して会話を管理します :

ui/chat.tsx

'use client';

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

export default function Chat({
  id,
  initialMessages,
}: { id?: string | undefined; initialMessages?: UIMessage[] } = {}) {
  const [input, setInput] = useState('');
  const { sendMessage, messages } = useChat({
    id, // use the provided chat ID
    messages: initialMessages, // load initial messages
    transport: new DefaultChatTransport({
      api: '/api/chat',
    }),
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (input.trim()) {
      sendMessage({ text: input });
      setInput('');
    }
  };

  // simplified rendering code, extend as needed:
  return (
    <div>
      {messages.map(m => (
        <div key={m.id}>
          {m.role === 'user' ? 'User: ' : 'AI: '}
          {m.parts
            .map(part => (part.type === 'text' ? part.text : ''))
            .join('')}
        </div>
      ))}

      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          placeholder="Type a message..."
        />
        <button type="submit">Send
      </form>
    </div>
  );
}

 

メッセージの保存

useChat はチャット id とメッセージをバックエンドに送信します。

ℹ️ useChat メッセージ形式は ModelMessage 形式とは異なります。useChat メッセージ形式はフロントエンドの表示用に設計されていて、id や createdAt のような追加フィールドを含みます。メッセージを useChat メッセージ形式で保存することを勧めます。

ツール、メタデータ、カスタム・データパーツを含むメッセージをストレージからロードする場合、処理前に validateUIMessages を使用してそれらを検証します。

メッセージの保存は、toUIMessageStreamResponse 関数の onFinish コールバックで行われます。onFinish は、新しい AI レスポンスを含む完全なメッセージを UIMessage[] として受信します。

app/api/chat/route.ts

import { openai } from '@ai-sdk/openai';
import { saveChat } from '@util/chat-store';
import { convertToModelMessages, streamText, UIMessage } from 'ai';

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

  const result = streamText({
    model: 'openai/gpt-5-mini',
    messages: await convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse({
    originalMessages: messages,
    onFinish: ({ messages }) => {
      saveChat({ chatId, messages });
    },
  });
}

メッセージの実際の保存は saveChat 関数で行われ、これはファイルベースのチャットストアでは以下のように実装されます :

util/chat-store.ts

import { UIMessage } from 'ai';
import { writeFile } from 'fs/promises';

export async function saveChat({
  chatId,
  messages,
}: {
  chatId: string;
  messages: UIMessage[];
}): Promise {
  const content = JSON.stringify(messages, null, 2);
  await writeFile(getChatFile(chatId), content);
}

// ... rest of the file

 

メッセージ ID

Chat ID に加えて、各メッセージは ID を持ちます。このメッセージ ID を使用して、例えば、個々のメッセージを操作できます。

 

クライアント側 vs サーバ側 ID 生成

デフォルトでは、メッセージ ID はクライアント側で生成されます :

  • ユーザメッセージ ID はクライアント側の useChat フックにより生成されます

  • AI 応答メッセージ ID はサーバ上の streamText により生成されます

永続性のないアプリケーションについては、クライアント側 ID 生成は完全に機能します。しかし、永続性については、セッション間の一貫性を保証し、メッセージが保存され取得される際の ID の衝突を防ぐために、サーバ側で生成された ID を必要とします

 

サーバ側 ID 生成のセットアップ

永続性を実装する場合、サーバ側 ID を生成するには 2 つの方法があります :

  • toUIMessageStreamResponse の generateMessageId を使用する

  • createUIMessageStream を使用して start メッセージパートに ID を設定する

 

オプション 1: toUIMessageStreamResponse の generateMessageId を使用する

createIdGenerator() を使用して ID ジェネレータを提供することで ID 形式を制御できます :

app/api/chat/route.ts

import { createIdGenerator, streamText } from 'ai';

export async function POST(req: Request) {
  // ...
  const result = streamText({
    // ...
  });

  return result.toUIMessageStreamResponse({
    originalMessages: messages,
    // Generate consistent server-side IDs for persistence:
    generateMessageId: createIdGenerator({
      prefix: 'msg',
      size: 16,
    }),
    onFinish: ({ messages }) => {
      saveChat({ chatId, messages });
    },
  });
}

 

Option 2: createUIMessageStream を使用して ID を設定する

あるいは、createUIMessageStream を使用して、start メッセージパートを記述することで、メッセージ ID を制御できます :

app/api/chat/route.ts

import {
  generateId,
  streamText,
  createUIMessageStream,
  createUIMessageStreamResponse,
} from 'ai';

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

  const stream = createUIMessageStream({
    execute: ({ writer }) => {
      // Write start message part with custom ID
      writer.write({
        type: 'start',
        messageId: generateId(), // Generate server-side ID for persistence
      });

      const result = streamText({
        model: 'openai/gpt-5-mini',
        messages: await convertToModelMessages(messages),
      });

      writer.merge(result.toUIMessageStream({ sendStart: false })); // omit start message part
    },
    originalMessages: messages,
    onFinish: ({ responseMessage }) => {
      // save your chat here
    },
  });

  return createUIMessageStreamResponse({ stream });
}

 

最後のメッセージだけを送信

メッセージの永続性を実装したら、最後のメッセージだけをサーバに送信したいかもしれません。これはリクエスト毎にサーバに送信されるデータの総量を削減し、パフォーマンスを向上することができます。

これを実現するために、prepareSendMessagesRequest 関数をトランスポートに提供できます。この関数はメッセージとチャット ID を受け取り、サーバに送信されるリクエストボディを返します。

ui/chat.tsx

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

const {
  // ...
} = useChat({
  // ...
  transport: new DefaultChatTransport({
    api: '/api/chat',
    // only send the last message to the server:
    prepareSendMessagesRequest({ messages, id }) {
      return { body: { message: messages[messages.length - 1], id } };
    },
  }),
});

サーバ側では、以前のメッセージをロードして、新しいメッセージを以前のメッセージに追加できます。メッセージがツール、メタデータ、カスタムデータパートを含む場合、それらを検証する必要があります :

app/api/chat/route.ts

import { convertToModelMessages, UIMessage, validateUIMessages } from 'ai';
// import your tools and schemas

export async function POST(req: Request) {
  // get the last message from the client:
  const { message, id } = await req.json();

  // load the previous messages from the server:
  const previousMessages = await loadChat(id);

  // validate messages if they contain tools, metadata, or data parts:
  const validatedMessages = await validateUIMessages({
    // append the new message to the previous messages:
    messages: [...previousMessages, message],
    tools, // if using tools
    metadataSchema, // if using custom metadata
    dataSchemas, // if using custom data parts
  });

  const result = streamText({
    // ...
    messages: convertToModelMessages(validatedMessages),
  });

  return result.toUIMessageStreamResponse({
    originalMessages: validatedMessages,
    onFinish: ({ messages }) => {
      saveChat({ chatId: id, messages });
    },
  });
}

 

以上