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

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

# シーケンス図

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

```mermaid
sequenceDiagram
    participant User as ユーザー
    participant Client as Node.jsクライアント
    participant OpenAI as OpenAI Realtime API

    User->>Client: アプリ実行
    Client->>OpenAI: WebSocket接続要求
    OpenAI-->>Client: session.created<br>（セッション作成）
    Client->>OpenAI: session.update<br>（ツール登録: getCurrentWeather）
    OpenAI-->>Client: session.updated<br>（ツール登録成功）

    Note over Client: ユーザー入力を待機

    Note over Client: Function Call を呼ぶチャットの流れ
    User->>Client: メッセージ入力<br>("東京の天気を知りたいんだけど")
    Client->>OpenAI: conversation.item.create<br>（ユーザー入力送信）
    OpenAI-->>Client: conversation.item.created<br>（ユーザー入力送信完了）
    Client->>OpenAI: response.create<br>（レスポンス生成リクエスト）
    OpenAI-->>Client: response.created<br>（レスポンス進行中）
    OpenAI-->>Client: response.output_item.added<br>（関数呼び出し: getCurrentWeather）

    loop 関数引数の送信
        OpenAI-->>Client: response.function_call_arguments.delta<br>("{\"location\":\"")
        OpenAI-->>Client: response.function_call_arguments.delta<br>("東京\"}")
    end

    OpenAI-->>Client: response.function_call_arguments.done<br>（関数引数確定: {"location":"東京"}）
    Client->>Client: getCurrentWeather 実行<br>（ローカルで処理）
    Client->>OpenAI: conversation.item.create<br>（関数結果送信: "東京：良い天気"）
    Client->>OpenAI: response.create<br>（次のレスポンス生成リクエスト）

    loop Deltaレスポンス送信
        OpenAI-->>Client: response.text.delta<br>("東京, は, とても, 良い, 天気, です！")<br>※小分け(stream的)に到着
    end

    OpenAI-->>Client: response.text.done<br>（完全なレスポンス通知：東京はとても良い天気です！）
    OpenAI-->>Client: response.output_item.done<br>（レスポンス生成完了）
    Client->>User: レスポンス表示<br>("東京はとても良い天気です！")
```

</details>

# 動作のポイント（ハマりポイント）
## セッションの概念
* まずOpenAI公式の絵が、素直にとてもわかりやすい
* Conversationには、ユーザーの発言、OpenAIの回答に加え、Function_call（Functionを呼べ）や、Function_callの回答（今回の例でいうとgetCurrentWeatherのレスポンス）が入る
* Responseというのがわかりづらいが、「まさに今答えている回答」といえばわかりやすいか。いったん答えきったら「Done」になる


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/10317/037692fa-9d8e-8025-b939-adc214b41e3b.png)

* Responseは、Active（in progress）なものは1個しか存在できず、Activeなものがある状態で「Response返して」とリクエストを送ると「Conversation already has an active response」というエラーが返るので、ここのステータス管理は、クライアント側でしてあげるのが良さそうだ

```javascript
// リアルタイム状態を管理するクラス
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サーバーに送る

```javascript
    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")」の部分が条件になる

```javascript
    const response = {
        type: "response.create",
        response: {
            modalities: ["text"],
        },
    };
    ws.send(JSON.stringify(response));
```

## Function callのあともResponse依頼
* Function Callの実行結果をOpenAIに返す際も、itemを送付したあとにresponse.create の送信が必要

```javascript
    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とのイベント送受信のログがすべて表示されるので、動作の流れをより具体的に見ることができます

<details><summary>RealtimeAPIとチャットするサンプル</summary>

```javascript
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 を返す
    }
}
```

</details>

# 最後に
* Realtime APIは取っつきづらいので、簡単な事例（単純なチャットのやりとり）から自分で実装し、動かしてみることが理解の手助けになった
* これが理解できると、音声の場合はmodalityにaudioをつけてやるだけなので、いよいよ本格的な会話アプリに進めるだろう