5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

KDDI エンジニア&デザイナーAdvent Calendar 2024

Day 15

OpenAIのRealtimeAPIをチャットで利用して理解を深める!

Posted at

はじめに

  • 今年の10月にリリースされた、Realtime APIだが、WebSocketのイベントハンドリングが前提となっていて、なかなか取っつきづらいですよね
  • しかも、イベントの種類も大量にあって、なにがきっかけで会話が始まるのか、いろいろわかりづらかったりします
  • 今回は、音声がメインのRealtime APIをテキストベースのチャットで利用して、イベント処理を完璧に理解しましょう

シーケンス図

  • 追ってソースコードと解説を入れていきます

動作のポイント(ハマりポイント)

セッションの概念

  • まずOpenAI公式の絵が、素直にとてもわかりやすい
  • Conversationには、ユーザーの発言、OpenAIの回答に加え、Function_call(Functionを呼べ)や、Function_callの回答(今回の例でいうとgetCurrentWeatherのレスポンス)が入る
  • Responseというのがわかりづらいが、「まさに今答えている回答」といえばわかりやすいか。いったん答えきったら「Done」になる

image.png

  • Responseは、Active(in progress)なものは1個しか存在できず、Activeなものがある状態で「Response返して」とリクエストを送ると「Conversation already has an active response」というエラーが返るので、ここのステータス管理は、クライアント側でしてあげるのが良さそうだ
// リアルタイム状態を管理するクラス
class RealtimeState {
    constructor() {
        this.response = null; // サーバーからのレスポンス状態を管理
    }

    updateResponseStatus(responseId, status) {
        if (!this.response || this.response.id !== responseId) {
            this.response = { id: responseId, status }; // 新しいレスポンスを登録
        } else {
            this.response.status = status; // 状態を更新
        }
        console.log(`Response [${responseId}] status updated to: ${status}`);
    }

    getResponseStatus() {
        return this.response ? this.response.status : null;
    }
}

Responseを返して、と依頼しないと返ってこない

  • ユーザー入力(例:こんにちは)については、itemという要素に詰め込んで、OpenAIサーバーに送る
    const userMessage = {
        type: "conversation.item.create",
        item: {
            type: "message",
            role: "user",
            content: [
                {
                    type: "input_text",
                    text: userInput,
                },
            ],
        },
    };
    ws.send(JSON.stringify(userMessage));
  • で、ここで「こんにちは」と送ったんだから、回答こないの?と待ってもなんの音沙汰もなし
  • Responseを要求する必要がある。response.createというタイプなのでわかりづらいが、「こういう条件で回答してね」という要求になる。今回でいうと、「テキストで返してね(modalities:"text")」の部分が条件になる
    const response = {
        type: "response.create",
        response: {
            modalities: ["text"],
        },
    };
    ws.send(JSON.stringify(response));

Function callのあともResponse依頼

  • Function Callの実行結果をOpenAIに返す際も、itemを送付したあとにresponse.create の送信が必要
    const event = {
        type: "conversation.item.create",
        item: {
            type: "function_call_output",
            call_id: call_id, // 元の call_id をそのまま使用
            output: JSON.stringify(result), // 結果をJSON文字列に変換
        },
    };

    ws.send(JSON.stringify(event));
    ws.send(JSON.stringify({type: 'response.create'}));

Javascriptソースコード

  • Node.jsで実行すること
  • 実行すると、OpenAI Realtime APIとのイベント送受信のログがすべて表示されるので、動作の流れをより具体的に見ることができます
RealtimeAPIとチャットするサンプル
import WebSocket from "ws";
import readline from "readline";
import dotenv from "dotenv"; // dotenvをインポート

dotenv.config(); // .envファイルを読み込む

// WebSocketエンドポイントとAPIキー
const url = "wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01";
const API_KEY = process.env.OPENAI_API_KEY; // .envからAPIキーを取得

if (!API_KEY) {
    console.error("Error: OPENAI_API_KEY is not set in .env file");
    process.exit(1); // APIキーが設定されていない場合は終了
}

// リアルタイム状態を管理するクラス
class RealtimeState {
    constructor() {
        this.response = null; // サーバーからのレスポンス状態を管理
    }

    updateResponseStatus(responseId, status) {
        if (!this.response || this.response.id !== responseId) {
            this.response = { id: responseId, status }; // 新しいレスポンスを登録
        } else {
            this.response.status = status; // 状態を更新
        }
        console.log(`Response [${responseId}] status updated to: ${status}`);
    }

    getResponseStatus() {
        return this.response ? this.response.status : null;
    }
}

// インスタンス作成
const state = new RealtimeState();

// WebSocket接続
const ws = new WebSocket(url, {
    headers: {
        Authorization: "Bearer " + API_KEY,
        "OpenAI-Beta": "realtime=v1",
    },
});

// ユーザー入力用のインターフェースを作成
const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
});

// WebSocket接続時の処理
ws.on("open", function open() {
    console.log("Connected to server.");

    // セッションの更新: 関数の定義を送信
    const sessionUpdate = {
        type: 'session.update',
        session: {
            tools: [
                {
                type: 'function',
                name: 'getCurrentWeather',
                description: '指定された都市の現在の天気を取得します。',
                parameters: {
                    type: 'object',
                    properties: {
                    location: {
                        type: 'string',
                        description: '都市名'
                    }
                    },
                    required: ['location']
                }
                }
            ],
            tool_choice: 'auto'
        }
    };
    ws.send(JSON.stringify(sessionUpdate));

    // セッション成立後、3秒待ってからユーザー入力を促す
    setTimeout(() => {
        console.log("準備ができました。メッセージを入力してください:");
        promptUserInput(); // 入力待機を開始
    }, 3000); // 待機時間3秒
});

// ResponseをCreateする関数
function createResponse(instructions = null) {
    const response = {
        type: "response.create",
        response: {
            modalities: ["text"],
        },
    };

    // 引数としてinstructionsが渡された場合、設定する
    if (instructions) {
        response.response.instructions = instructions;
    }

    ws.send(JSON.stringify(response));
    console.log(
        `Response.create を送信しました: ${
            instructions ? `Instructions: ${instructions}` : "No instructions"
        }`
    );
}


// ユーザー入力を受け取る関数
function promptUserInput() {
    rl.question("> ", (userInput) => {
        if (userInput.trim().toLowerCase() === "exit") {
            console.log("終了します。");
            rl.close();
            ws.close();
            return;
        }

        // 現在の状態を確認
        const status = state.getResponseStatus();
        if (status === "done" || status === null) {
            console.log("Response is done or status is unknown. Sending conversation item and creating a new response.");
            sendUserMessage(userInput); // メッセージ送信
            setTimeout(() => createResponse("Continue assisting the user."), 1000); // 新しいInstructions付きのResponseを1秒待機後に作成
        } else if (status === "in_progress") {
            console.log("Response is in progress. Sending message directly without creating a new response.");
            sendUserMessage(userInput); // 状態が in_progress の場合、直接送信
        } else {
            console.error("Unknown state:", status); // その他の不明な状態(デバッグ用)
        }

        // 再度入力を促す
        promptUserInput();
    });
}

// ユーザーのメッセージを送信する関数
function sendUserMessage(userInput) {
    const userMessage = {
        type: "conversation.item.create",
        item: {
            type: "message",
            role: "user",
            content: [
                {
                    type: "input_text",
                    text: userInput,
                },
            ],
        },
    };
    ws.send(JSON.stringify(userMessage));
    console.log("メッセージを送信しました:", userInput);
}

// メッセージ受信時の処理
ws.on("message", function incoming(message) {
    const parsedMessage = JSON.parse(message.toString());

    // 分岐処理: イベントのtypeによって処理を分ける
    switch (parsedMessage.type) {
        case "response.created":
            const responseId = parsedMessage.response.id;
            state.updateResponseStatus(responseId, "in_progress");
            break;

        case "response.done":
            const responseDoneId = parsedMessage.response.id;
            state.updateResponseStatus(responseDoneId, "done");
            break;

        case "response.function_call_arguments.done":
            handleFunctionCall(parsedMessage);
            break;

        default:
            console.log("Unhandled event type:", parsedMessage.type);
    }

    console.log("受信:", JSON.stringify(parsedMessage, null, 2));
});

// Function Call の処理
function handleFunctionCall(parsedMessage) {
    const { name, arguments: args, call_id } = parsedMessage;

    // 関数名に基づく分岐処理
    let result;

    switch (name) {
        case "getCurrentWeather":
            result = getCurrentWeather(args);
            break;

        default:
            console.log("Unhandled function call:");
            console.log(`Name: ${name}`);
            console.log(`Arguments: ${args}`);
            return; // 未対応の関数は処理しない
    }

    // Tool関数の結果を送信
    if (result) {
        const event = {
            type: "conversation.item.create",
            item: {
                type: "function_call_output",
                call_id: call_id, // 元の call_id をそのまま使用
                output: JSON.stringify(result), // 結果をJSON文字列に変換
            },
        };

        ws.send(JSON.stringify(event));
        ws.send(JSON.stringify({type: 'response.create'}));
    }
}

// Tool関数:getCurrentWeather
function getCurrentWeather(args) {
    try {
        const parsedArgs = JSON.parse(args); // 引数をパース
        const location = parsedArgs.location;

        if (!location) {
            console.error("Error: Missing location argument in getCurrentWeather call.");
            return null; // エラー時は null を返す
        }

        // ダミーの結果を生成
        return {
            message: `${location}はとても良い天気です!`
        };
    } catch (error) {
        console.error("Error processing getCurrentWeather dummy function:", error);
        return null; // エラー時は null を返す
    }
}

最後に

  • Realtime APIは取っつきづらいので、簡単な事例(単純なチャットのやりとり)から自分で実装し、動かしてみることが理解の手助けになった
  • これが理解できると、音声の場合はmodalityにaudioをつけてやるだけなので、いよいよ本格的な会話アプリに進めるだろう
5
1
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
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?