Open5

Next.js+API RoutesでOpenAI ChatAPIのレスポンスをストリーミングする

t-yngt-yng

サーバー送信イベントを実装してみる

https://azisava.sakura.ne.jp/programming/0041.html#sec2

サーバー側の実装

// server.js
const http = require("http");
const url = require("url");

const sendServerEvent = (req, res) => {
  // 接続を受けたら「Content-Type: text/event-stream」で応答する
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    Connection: "keep-alive",
    "Cache-Control": "no-cache",
  });

  // 3秒ごとにメッセージを送信する
  const timer1 = setInterval(() => {
    // 「data: 」に続けて文字列と空行を出力すると、クライアント側でmessageイベントが発生する
    res.write("data: サーバーからのメッセージです。\n\n");
  }, 3000);

  // 5秒ごとに名前付きメッセージを送信する
  const timer2 = setInterval(() => {
    // 「data: 」の前に「event: 」でイベント名を出力すると、クライアント側でその名前のイベントが発生する
    res.write("event: originalEvent\n");
    res.write("data: サーバーからの名前付きメッセージです。\n\n");
  }, 5000);

  // 接続が切断されたら終了する
  req.connection.addListener(
    "close",
    () => {
      clearInterval(timer1);
      clearInterval(timer2);
    },
    false
  );
};

const server = http.createServer((req, res) => {
  // CORSの全て許可
  res.setHeader("Access-Control-Allow-Origin", "*");

  const pathname = url.parse(req.url).pathname;
  if (pathname === "/sse") {
    sendServerEvent(req, res);
  } else {
    // 指定パス以外は404を返す
    res.writeHead(404);
    res.end("Not Found");
    return;
  }
});

server.listen(8081);

フロントエンド側の実装(EventSource)

<!--  index.html -->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div style="display: flex; gap: 16px;">
    <button id="start-sse">サーバーイベント送信の接続開始</button>
    <button id="stop-sse">サーバーイベント送信の接続を閉じる</button>
  </div>
  <div id="messages"></div>
  <script>
    const messages = document.getElementById('messages');

    document.getElementById('start-sse').addEventListener('click', () => {
      // Server-Sent Eventsを実装したサーバーに接続する。
      const evtSource = new EventSource('http://localhost:8081/sse');

      // 接続成功時に発生するイベント
      evtSource.addEventListener('open', (e) => {
          console.log('接続しました。');
      });

      // メッセージ受信時に発生するイベント
      evtSource.addEventListener('message', (e) => {
          console.log('メッセージを受信しました。');
          const message = e.data;

          // 受信したメッセージを表示する
          const messageElement = document.createElement('p');
          messageElement.textContent = message;
          messages.appendChild(messageElement);
      });

      // 名前付きメッセージ受信時に発生するイベント
      evtSource.addEventListener('originalEvent', (e) => {
          console.log('名前付きメッセージ: originalEventを受信しました。');
          const message = e.data;

          // 受信したメッセージを表示する
          const messageElement = document.createElement('p');
          messageElement.textContent = message;
          messages.appendChild(messageElement);
      });

      // 接続失敗時に発生するイベント
      evtSource.addEventListener('error', (e) => {
          console.error('接続できません。');
      });

      // 切断するにはclose()を呼び出す
      document.getElementById('stop-sse').addEventListener('click', () => {
          evtSource.close();
          console.log('切断しました。');
      });
    });
  </script>
</body>
</html>

フロント側の実装(Fetch API)

EventSourceだとサーバー側にデータを渡すことができないので、Fetch API で実装すると良さそう。

<!--  index.html -->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div style="display: flex; gap: 16px;">
    <button id="start-sse">サーバーイベント送信の接続開始</button>
    <button id="stop-sse">サーバーイベント送信の接続を閉じる</button>
  </div>
  <div id="messages"></div>
  <script>
    const messages = document.getElementById('messages');

    const readStreamByFetch = async () => {
      const res = await fetch('http://localhost:8081/sse');
      // TextDecoderStreamを通して、Uint8Arrayを文字列に変換する
      const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();

      const decoder = new TextDecoder();
      const read = async () => {
        const { done, value } = await reader.read();
        if (done) {
          return;
        }

        // 受け取ったデータをJSオブジェクトにフォーマット
        const message = value.split('\n').reduce((acc, line) => {
          if (line.startsWith('data: ')) {
            acc['data'] = line.replace('data: ', '');
            return acc;
          } else if (line.startsWith('event: ')) {
            acc['event'] = line.replace('event: ', '');
            return acc;
          }

          return acc;
        }, {data: '', event: null});

        // 受信したメッセージを表示する
        const messageElement = document.createElement('p');
        messageElement.textContent = message.data;
        messages.appendChild(messageElement);

        console.log(message.data);
        if(message.event === 'originalEvent') {
          console.log('名前付きメッセージ: originalEventを受信しました。');
        }

        // 次のイベントを読み取る
        read();
      };

      read();

      // 切断するにはcancel()を呼び出す
      document.getElementById('stop-sse').addEventListener('click', () => {
        reader.cancel();
        console.log('切断しました。');
      });
    }

    document.getElementById('start-sse').addEventListener('click', readStreamByFetch);
  </script>
</body>
</html>
t-yngt-yng

Next.jsのAPI RoutesでOpenAI APIの結果をストリーミングで返す

https://github.com/vercel/next.js/discussions/48427#discussioncomment-5624604

const chat = async (
  messages: string[],
  onStreamChunk: (message: string) => void,
  onStreamEnd: () => void
) => {
  const postMessages = [
    ...messages.map<{ role: "user" | "assistant"; content: string }>(
      (message, i) => {
        return {
          role: i % 2 === 0 ? "user" : "assistant",
          content: message,
        };
      }
    ),
  ];

  const stream = await openai.chat.completions.create({
    messages: postMessages,
    model: "gpt-4-0613",
    stream: true,
  });

  for await (const chunk of stream) {
    onStreamChunk(chunk.choices[0].delta.content ?? "");
  }

  onStreamEnd();
};

export async function POST(req: Request) {
  const body = await req.json();
  const responseStream = new TransformStream();
  const writer = responseStream.writable.getWriter();

  chat(
    body.messages,
    (message) => {
      writer.write(`data: ${message}\n\n`);
    },
    () => writer.close()
  );

  return new Response(responseStream.readable, {
    headers: {
      "Content-Type": "text/event-stream",
      Connection: "keep-alive",
      "Cache-Control": "no-cache, no-transform",
    },
  });
}