コンテンツにスキップ

WebSocket通信ガイド

WebSocket通信ガイド

概要

MarionetteのWebSocket APIは、リアルタイムな会話システムを構築するためのプロトコルです。
クライアントとサーバー間でメッセージをやり取りし、AIとの会話を実現します。

基本的な会話フロー

Marionetteでの会話は、 接続会話 の2つのフェーズで構成されます。

1. WebSocket接続

まず、WebSocket接続を確立します:

WebSocket接続(/ws?drama_id=yyy)
   → セッションが自動作成される
   → session.created メッセージを受信

接続が完了すると、セッションが作成され、会話を開始する準備が整います。

2. 会話の繰り返し

接続後は、以下の3ステップ(a, b, c)を繰り返すことで会話を続けます:

a. dialog.add(オプショナルで state.set)
b. event.trigger
c. event.response を受信
(必要に応じて、aに戻る)

dialogとstateの概念

Marionetteでは、dialog (会話履歴)と state (状態)が、クライアントとサーバー間で共有される共通データベースとして存在します。

graph LR
    Client[クライアント]
    Server[サーバー]
    Dialog[(dialog<br/>会話履歴)]
    State[(state<br/>状態)]

    Client -->|dialog.add| Dialog
    Client -->|state.set| State
    Server -->|dialog.add| Dialog
    Server -->|state.set| State
    Dialog -->|dialog.updated| Client
    Dialog -->|dialog.updated| Server
    State -->|state.updated| Client
    State -->|state.updated| Server

dialog(会話履歴):

  • ユーザーとAIの会話の記録を保存
  • クライアントはdialog.addでユーザーの発話を追加
  • サーバーはdialog.addでAIの応答を追加
  • 変更はdialog.updatedで全クライアントに通知される

state(状態):

  • アプリケーションの状態を保存(ユーザー情報、進行状況など)
  • クライアントはstate.setで状態を更新
  • サーバーもstate.setで状態を更新
  • 変更はstate.updatedで全クライアントに通知される

:

// クライアント側: ユーザーの発話を追加
ws.send(JSON.stringify({
  op: "dialog.add",
  data: { role: "user", text: "こんにちは" }
}));

// クライアント側: ユーザー情報を状態に保存
ws.send(JSON.stringify({
  op: "state.set",
  data: { changes: [{ path: "user.name", value: "ゆういち" }] }
}));

// サーバー側から: 会話履歴の更新通知を受信
// { op: "dialog.updated", data: { role: "assistant", text: "こんにちは!" } }

// サーバー側から: 状態の更新通知を受信
// { op: "state.updated", data: { diff: { "scenario.phase": "greeting" } } }

この共通データベースにより、複数のクライアントが同じセッションに接続している場合でも、会話履歴と状態が同期されます。

ドラマ作成者から入手するべき情報

クライアント実装前に、ドラマ作成者から以下の情報を入手してください:

項目 説明
drama_id WebSocket接続に使用するドラマID "a1b2c3d4"
APIKey WebSocket接続時の認証に使用するAPIキー "sk-xxxxxxxxxxxx"
event.triggerdata形式 event.trigger送信時にdataフィールドに含めるべきフィールドとその形式 { reason: "manual_click", user_age: 8 }
event.responseの形式 event.responseで返ってくる可能性のあるフィールドとその形式 { text: string, choices: string[], is_last: boolean }
statesとしてクライアント側で設定・監視するべき項目 state.setで設定すべき項目と、state.updatedで監視すべき項目 user.name (文字列型), user.age (数値型), scenario.phase (文字列型)

Note

これらの情報はドラマごとに異なります。必ずドラマ作成者に確認してください。

WebSocket接続

接続エンドポイント

wss://marionette.spiral-ai-app.com/ws?drama_id=<drama_id>

接続方法

const ws = new WebSocket(
  `wss://marionette.spiral-ai-app.com/ws?drama_id=${dramaId}`,
  [],
  { headers: { 'X-API-Key': apiKey } }
);

ws.onopen = () => {
  console.log('接続成功');
};

ws.onmessage = (event) => {
  const message = JSON.parse(event.data);

  // セッション作成完了メッセージを受信
  if (message.op === 'session.created') {
    console.log('セッション作成完了:', message.data.session_id);
    // session_idを保存(必要に応じて)
    const sessionId = message.data.session_id;
  }

  handleMessage(message);
};

session.created メッセージ

WebSocket接続時に、セッション作成完了を示す以下のメッセージが送信されます:

{
  "op": "session.created",
  "data": {
    "session_id": "abc123xyz...",
    "user_id": "user-xxx",
    "drama_id": "your-drama-id",
    "expires_at": "2025-12-06T08:00:00Z"
  }
}

メッセージ構造

すべてのWebSocketメッセージは以下の構造を持ちます:

{
  "op": "操作名",
  "id": "メッセージID",
  "reply_to": "返信先メッセージID",
  "data": { ... }
}
フィールド 説明
op 操作タイプ(必須)。例: "dialog.add", "event.trigger", "event.response"
id クライアント送信時のメッセージID(オプション)。レスポンスのreply_toで参照できる
reply_to 返信先メッセージID(オプション)。サーバーからのレスポンスで使用される
data メッセージのデータ部分(オプション)

基本的な会話の実行

会話は、以下のサイクルを繰り返すことで実行します:

  1. dialog.add(オプショナルで state.set
  2. event.trigger
  3. event.response を受信

ステップ1: dialog.add(オプショナルで state.set)

dialog.add: ユーザーの発話を追加

dialog.addを送信して、会話履歴にユーザーの発話を追加します。

const dialogMessage = {
  op: "dialog.add",
  id: "dlg-001",
  data: {
    role: "user",
    text: "こんにちは",
    trigger_event: false  // まだ会話生成を開始しない
  }
};
ws.send(JSON.stringify(dialogMessage));

サーバーからACK(確認)が返ってきます:

{
  "op": "ack",
  "reply_to": "dlg-001",
  "data": {
    "step": 1422
  }
}
state.set: 状態を更新(オプショナル)

必要に応じて、state.setで状態を更新できます。詳細はState(状態)の操作を参照してください。

const stateMessage = {
  op: "state.set",
  id: "set-001",
  data: {
    changes: [
      { path: "user.name", value: "ゆういち" },
      { path: "user.age", value: 8 }
    ],
    trigger_event: false
  }
};
ws.send(JSON.stringify(stateMessage));

ステップ2: event.trigger

event.triggerを送信して、AI応答の生成を開始します。

const triggerMessage = {
  op: "event.trigger",
  id: "gen-001",
  data: {}
};
ws.send(JSON.stringify(triggerMessage));

!!! note event.triggerのdataフィールド
event.triggerdataフィールドは多くの場合、空辞書で構いません。
ただ、ドラマ作成者によっては、会話制御目的で利用することもあるので、確認が必要です。
例えば、初期パラメータを渡す場合:

{
  op: "event.trigger",
  id: "gen-001",
  data: {
    reason: "init",
    user_age: 8,
    scenario_phase: "greeting"
  }
}

どのようなフィールドが必要かは、ドラマ作成者に確認してください。

Note

event.triggerはACKを返しません。

ステップ3: event.response を受信

event.responseメッセージが複数回送信されます。通常、これらのメッセージは順不同で送信されます。
この送信内容はドラマごとの設計事項のため、どのような送信が期待されるかは、ドラマ作成者に確認してください。

以下に、推奨パターンを示します。

テキスト応答
{
  "op": "event.response",
  "reply_to": "gen-001",
  "data": {
    "text": "こんにちは!元気ですか?",
    "is_last": false
  }
}
音声チャンク
{
  "op": "event.response",
  "reply_to": "gen-001",
  "data": {
    "audio": {
      "audio": "base64_encoded_data...",
      "chunk_id": 0,
      "synthesis_id": "uuid-here",
      "is_last": false,
      "order": 1
    },
    "is_last": false
  }
}

Note

音声データの形式や処理方法については、音声チャンクについてを参照してください。

完了通知
{
  "op": "event.response",
  "reply_to": "gen-001",
  "data": {
    "is_last": true
  }
}

is_last: trueが含まれるメッセージが、会話生成の完了を示します。
これはドラマ作成者の設計事項ですが、これがない場合にはevent.responseの終わりが分からないため、正しく動作することができません。ドラマ作成者に連絡をし、追加を依頼してください。

Note

推奨事項: is_last: trueを含むメッセージは、最後に送信されることが推奨されています。
もしis_last: trueが最後以外の位置で送信される場合、クライアント側で正しく動作しない可能性があります。
そのような場合は、ドラマ作成者に連絡してください。

is_last: trueを受信したら、次の会話のために再度ステップ1(dialog.add、オプショナルで state.set)から始めることができます。

完全な会話の実装例

完全な会話の実装例を展開

以下は、基本的な会話フローの完全な実装例です:

class MarionetteChat {
  constructor(dramaId, apiKey) {
    this.dramaId = dramaId;
    this.apiKey = apiKey;
    this.sessionId = null;
    this.ws = null;
    this.messageIdCounter = 0;
  }

  async connect() {
    return new Promise((resolve, reject) => {
      this.ws = new WebSocket(
        `wss://marionette.spiral-ai-app.com/ws?drama_id=${this.dramaId}`,
        [],
        {
          headers: {
            'X-API-Key': this.apiKey
          }
        }
      );

      this.ws.onopen = () => {
        console.log('WebSocket接続成功');
      };

      this.ws.onmessage = (event) => {
        const message = JSON.parse(event.data);
        this.handleMessage(message, resolve);
      };

      this.ws.onerror = (error) => {
        console.error('WebSocketエラー:', error);
        reject(error);
      };
    });
  }

  handleMessage(message, resolveConnect) {
    // セッション作成完了
    if (message.op === "session.created") {
      this.sessionId = message.data.session_id;
      console.log(`セッション作成完了: ${this.sessionId}`);
      if (resolveConnect) resolveConnect();
      return;
    }

    if (message.op === "ack") {
      console.log(`ACK受信 (reply_to: ${message.reply_to})`);
    } else if (message.op === "event.response") {
      this.handleEventResponse(message);
    } else if (message.op === "dialog.updated") {
      console.log('会話履歴更新:', message.data);
    } else if (message.op === "state.updated") {
      console.log('状態更新:', message.data);
    }
  }

  handleEventResponse(message) {
    const data = message.data;

    if (data.text) {
      console.log('テキスト:', data.text);
      // UIにテキストを表示
    }

    if (data.audio) {
      console.log('音声チャンク:', data.audio.chunk_id);
      // 音声を再生
    }

    if (data.is_last) {
      console.log('会話生成完了');
    }
  }

  sendUserMessage(text) {
    const id = `dlg-${++this.messageIdCounter}`;
    const message = {
      op: "dialog.add",
      id: id,
      data: {
        role: "user",
        text: text,
        trigger_event: false
      }
    };
    this.ws.send(JSON.stringify(message));
  }

  triggerGeneration() {
    const id = `gen-${++this.messageIdCounter}`;
    const message = {
      op: "event.trigger",
      id: id,
      data: {
        reason: "manual_click"
      }
    };
    this.ws.send(JSON.stringify(message));
  }
}

// 使用例
async function main() {
  // WebSocket接続(セッションは自動作成される)
  const chat = new MarionetteChat('simple_chat', 'your-api-key-here');
  await chat.connect();

  console.log(`セッションID: ${chat.sessionId}`);

  // ユーザーの発話を追加
  chat.sendUserMessage("こんにちは");

  // 会話生成を開始
  chat.triggerGeneration();
}

main();

trigger_eventオプション

dialog.addstate.setには、trigger_eventオプションがあります。
これをtrueにすると、ACK送信後に自動的にevent.triggerが実行されます。

// trigger_event: trueを使用
const message = {
  op: "dialog.add",
  id: "dlg-001",
  data: {
    role: "user",
    text: "こんにちは",
    trigger_event: true  // 自動的にevent.triggerを実行
  }
};
ws.send(JSON.stringify(message));

// この場合、event.responseのreply_toは "dlg-001" になります

これにより、以下の2つのコードは同等になります:

パターン1: trigger_event: false(手動)

// 1. dialog.addを送信
ws.send(JSON.stringify({
  op: "dialog.add",
  id: "dlg-001",
  data: { role: "user", text: "こんにちは", trigger_event: false }
}));

// 2. ACKを待ってから、event.triggerを送信
ws.send(JSON.stringify({
  op: "event.trigger",
  id: "gen-001",
  data: { reason: "manual_click" }
}));

パターン2: trigger_event: true(自動)

// dialog.addを送信するだけで、自動的にevent.triggerが実行される
ws.send(JSON.stringify({
  op: "dialog.add",
  id: "dlg-001",
  data: { role: "user", text: "こんにちは", trigger_event: true }
}));

State(状態)の操作

Stateは、アプリケーションの状態を管理するためのシステムです。
ユーザー情報、会話の進行状況などを保存できます。

状態の構造

Stateは階層的な辞書構造を持ちます:

{
  "user": {
    "name": "ゆういち",
    "age": 8
  },
  "scenario": {
    "phase": "greeting"
  }
}

状態の取得

const message = {
  op: "state.get",
  id: "get-001",
  data: {
    fields: ["user.name", "user.age"]  // 特定のフィールドのみ取得
  }
};
ws.send(JSON.stringify(message));

// レスポンス
// {
//   "op": "state.result",
//   "reply_to": "get-001",
//   "data": {
//     "state": {
//       "user": {
//         "name": "ゆういち",
//         "age": 8
//       }
//     }
//   }
// }

状態の更新

const message = {
  op: "state.set",
  id: "set-001",
  data: {
    changes: [
      { path: "user.name", value: "ゆういち" },
      { path: "user.age", value: 8 }
    ],
    trigger_event: false  // 更新後に自動的にevent.triggerを実行するか
  }
};
ws.send(JSON.stringify(message));

// ACKレスポンス
// {
//   "op": "ack",
//   "reply_to": "set-001",
//   "data": {
//     "step": 1420
//   }
// }

ブロードキャスト通知

Marionetteでは、状態や会話履歴の変更が自動的に接続中のクライアントに通知されます。

dialog.updated

会話履歴に新しいエントリが追加されると、dialog.updatedが送信されます。

{
  "op": "dialog.updated",
  "reply_to": "dlg-001",
  "data": {
    "source": "client",
    "role": "user",
    "text": "こんにちは",
    "meta": {},
    "step": 1422
  }
}
  • source: 更新元("client"または"server"
  • role: 発話者の役割
  • text: 発話内容
  • meta: メタデータ
  • step: ステップ番号

state.updated

状態が更新されると、state.updatedが送信されます。

{
  "op": "state.updated",
  "reply_to": "set-001",
  "data": {
    "source": "client",
    "diff": {
      "user.name": "Alice",
      "user.age": 30
    },
    "step": 1420
  }
}
  • source: 更新元("client"または"server"
  • diff: 変更内容(辞書形式: {"path": "value"}
  • step: ステップ番号

Note

diffフィールドは辞書形式です。配列ではありません。

event.responseで返ってくる可能性のあるフィールド

event.responseには、以下のようなフィールドが含まれる可能性があります:

確定フィールド

  • is_last (boolean): 処理完了フラグ。trueの場合は最後のメッセージ

高頻度フィールド

ドラマによって異なるフィールド(例)

  • text (string): 生成されたテキスト
  • motion (string): キャラクターの動作
  • talk_to (string): 発話相手
  • facial (string): 表情
  • choices (array): ユーザーの選択肢(例: 4択)
  • next_action (object): 次のアクション情報

Note

event.responseのフィールドは、ドラマ作成者の設計によって異なります。

選択肢の例

{
  "op": "event.response",
  "reply_to": "gen-001",
  "data": {
    "text": "どこの学校に通っていますか?",
    "choices": ["幼稚園", "小学校", "中学校", "内緒"],
    "is_last": false
  }
}

クライアント実装チェックリスト

実装時に、以下の項目を確認してください:

  • WebSocket接続時にdrama_idクエリパラメータを指定している
  • WebSocket接続時にX-API-Keyヘッダーを設定している
  • session.createdメッセージを受信してsession_idを取得している
  • dialog.addで会話履歴にユーザーの発話を追加している
  • event.triggerで会話生成を開始している
  • event.responseを複数回受信できるように実装している
  • is_last: trueで会話生成の完了を検知している
  • 音声チャンクを正しく処理している(音声チャンクについて参照)
  • dialog.updatedstate.updatedのブロードキャストを処理している

トラブルシューティング

WebSocket接続が失敗する

症状: WebSocket接続時にエラーが発生する

原因と対処法:

Close Code Reason 対処法
1008 "session_id or drama_id required" URLにdrama_idパラメータを追加
1008 "Authentication required for session creation" 認証ヘッダーを設定
1008 "Invalid API key" 正しいAPI Keyを使用
1008 "Failed to create session" drama_idが正しいか確認

確認事項:

  • URLにdrama_idパラメータを指定しているか?
  • API Keyが正しいか?
  • ヘッダーにX-API-KeyまたはAuthorization: Bearerを設定しているか?
  • wss://(HTTPSの場合)を使用しているか?

event.responseが返ってこない

症状: event.triggerを送信しても、event.responseが返ってこない

原因: event.triggerの前に、会話履歴や状態を準備していない可能性があります

確認事項:

  • event.triggerの前にdialog.addを送信しているか?
  • ドラマの設計上、初期状態の設定が必要な場合、state.setを送信しているか?

音声が再生されない

症状: 音声データは受信できているが、再生されない

対処法: 音声チャンクについてのトラブルシューティングを参照してください

技術的詳細

ステップ管理

MarionetteのWebSocket通信では、すべての状態変更と会話履歴追加に「ステップ番号」が付与されます。

  • ステップ番号は0から始まり、変更ごとに1ずつ増加します
  • ACKレスポンスには、現在のステップ番号が含まれます
  • ブロードキャスト通知(*.updated)にも、ステップ番号が含まれます

メッセージIDの管理

クライアントは、送信するメッセージに一意のidを付与することを推奨します:

let messageIdCounter = 0;

function sendMessage(op, data) {
  const id = `msg-${++messageIdCounter}`;
  const message = { op, id, data };
  ws.send(JSON.stringify(message));
  return id;
}

これにより、レスポンスメッセージのreply_toフィールドで、どのリクエストに対する応答かを識別できます。

event.responseのストリーミング

event.triggerを送信すると、event.response複数回 送信されます。
通常、これらのメッセージは順不同で送信されます。

event.response #1 (text)
event.response #2 (audio chunk 1)
event.response #3 (audio chunk 2)
event.response #4 (audio chunk 3, is_last: true)
event.response #5 (is_last: true)

クライアントは、is_last: trueが含まれるメッセージを受信するまで、すべてのevent.responseを処理する必要があります。

Note

推奨事項: is_last: trueを含むメッセージは、最後に送信されるよう、ドラマ作成者に依頼されています。
これは、クライアント側で正しくevent.responseの終了を認識するために大事な仕様です。
ただ、ドラマ作成者の作り方次第ではそうでない実装も発生しうるため、もしis_last: trueが最後以外の位置で送信されているケースを見かけたら、ドラマ作成者に連絡し修正を依頼してください。

並列処理の制限

1つ目のevent.triggerが完了していない段階で、次のevent.triggerを送った場合の現在の挙動は、「実行中の処理を強制停止して、次のevent.triggerの処理を新規スタート」です。

この強制停止により、それ以降の会話生成に不具合が発生する可能性があるため、基本的には同一セッションで複数のevent.triggerを並列実行は禁止となります。

1つのevent.triggerが完了(is_last: trueを受信)してから、次のevent.triggerを送信してください。

シーケンス図

基本的な会話フロー

sequenceDiagram
    participant Client
    participant Server

    Note over Client,Server: WebSocket接続(セッション自動作成)
    Client->>Server: WebSocket接続 (/ws?drama_id=xxx)
    Server->>Client: session.created (session_id, user_id, drama_id)

    Note over Client,Server: 会話開始
    Client->>Server: dialog.add (id: "dlg-001", text: "こんにちは")
    Server->>Client: ack (reply_to: "dlg-001")
    Server->>Client: dialog.updated

    Client->>Server: event.trigger (id: "gen-001")
    Server->>Client: event.response (text: "こんにちは!元気ですか?")
    Server->>Client: event.response (audio chunk 1)
    Server->>Client: event.response (audio chunk 2)
    Server->>Client: event.response (is_last: true)
    Server->>Client: dialog.updated (role: "assistant")

trigger_event: trueを使用した場合

sequenceDiagram
    participant Client
    participant Server

    Client->>Server: WebSocket接続 (/ws?drama_id=xxx)
    Server->>Client: session.created

    Client->>Server: dialog.add (id: "dlg-001", trigger_event: true)
    Server->>Client: ack (reply_to: "dlg-001")
    Server->>Client: dialog.updated
    Note over Server: 自動的にevent.triggerを実行
    Server->>Client: event.response (reply_to: "dlg-001")
    Server->>Client: event.response (is_last: true)

よくあるユースケース

初期パラメータを設定してから会話を開始

初期パラメータを渡す方法は、 2つのパターン があります。どちらを使用するかは、ドラマ作成者の設計によります。

パターン1: state.setで状態を更新してから会話を開始
// 1. 初期状態を設定
await sendMessage("state.set", {
  changes: [
    { path: "user.age", value: 8 },
    { path: "scenario.phase", value: "greeting" }
  ],
  trigger_event: false
});

// 2. 会話を開始
sendMessage("event.trigger", { reason: "init" });

このパターンでは、状態がデータベースに永続化され、すべてのクライアントに同期されます。

パターン2: event.triggerのdataフィールドでパラメータを渡す
// event.triggerと同時にパラメータを渡す
sendMessage("event.trigger", {
  reason: "init",
  user_age: 8,
  scenario_phase: "greeting"
});

このパターンでは、パラメータは一時的にドラマの処理に渡されますが、状態として永続化されるかどうかはドラマの実装次第です。

Note

  • 永続的な状態: state.setを使用(ユーザー情報、進行状況など)
  • 一時的なパラメータ: event.triggerdataを使用(初期化フラグ、一時的な設定など)

ドラマ作成者に確認してください。

会話履歴の取得

// 指定されたステップ範囲の会話エントリを取得
sendMessage("dialog.diff", {
  from: 0,      // 開始ステップ
  to: 1422      // 終了ステップ
});

// レスポンス: dialog.result
// {
//   "op": "dialog.result",
//   "data": {
//     "entries": [
//       { "role": "user", "text": "...", "timestamp": "..." },
//       { "role": "assistant", "text": "...", "timestamp": "..." }
//     ]
//   }
// }

エラーハンドリング

エラーメッセージの形式

{
  "op": "error",
  "reply_to": "<元メッセージID>",
  "data": {
    "code": "INVALID_DATA",
    "message": "text is empty for dialog.add",
    "meta": {
      "field": "text"
    }
  }
}

よくあるエラーコード

エラーコード 説明 対処法
INVALID_DATA データが無効 リクエストのデータ形式を確認
SESSION_NOT_FOUND セッションが見つからない 再接続を試みる
INVALID_INPUT 入力が無効 必須フィールドを確認
UNKNOWN_OPERATION 未知の操作 操作タイプ(op)を確認
INTERNAL_ERROR 内部エラー サーバーログを確認

エラーハンドリングの実装例

ws.onmessage = (event) => {
  const message = JSON.parse(event.data);

  if (message.op === "error") {
    console.error("エラー:", message.data.code, message.data.message);

    // エラーに応じた処理
    switch (message.data.code) {
      case "INVALID_DATA":
        // データ形式を修正して再送信
        break;
      case "SESSION_NOT_FOUND":
        // 再接続を試みる
        break;
      default:
        // その他のエラー処理
        break;
    }
  }
};