WebSocket通信ガイド
WebSocket通信ガイド¶
概要¶
MarionetteのWebSocket APIは、リアルタイムな会話システムを構築するためのプロトコルです。
クライアントとサーバー間でメッセージをやり取りし、AIとの会話を実現します。
基本的な会話フロー¶
Marionetteでの会話は、 接続 と 会話 の2つのフェーズで構成されます。
1. WebSocket接続¶
まず、WebSocket接続を確立します:
接続が完了すると、セッションが作成され、会話を開始する準備が整います。
2. 会話の繰り返し¶
接続後は、以下の3ステップ(a, b, c)を繰り返すことで会話を続けます:
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.triggerのdata形式 | 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接続¶
接続エンドポイント¶
接続方法¶
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 | 操作タイプ(必須)。例: "dialog.add", "event.trigger", "event.response" |
id | クライアント送信時のメッセージID(オプション)。レスポンスのreply_toで参照できる |
reply_to | 返信先メッセージID(オプション)。サーバーからのレスポンスで使用される |
data | メッセージのデータ部分(オプション) |
基本的な会話の実行¶
会話は、以下のサイクルを繰り返すことで実行します:
- dialog.add(オプショナルで state.set)
- event.trigger
- 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(確認)が返ってきます:
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.triggerのdataフィールドは多くの場合、空辞書で構いません。
ただ、ドラマ作成者によっては、会話制御目的で利用することもあるので、確認が必要です。
例えば、初期パラメータを渡す場合:
{
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
音声データの形式や処理方法については、音声チャンクについてを参照してください。
完了通知¶
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.addやstate.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は階層的な辞書構造を持ちます:
状態の取得¶
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の場合は最後のメッセージ
高頻度フィールド¶
- audio (
object): 音声データ(チャンク単位)- 詳細は音声チャンクについてを参照
ドラマによって異なるフィールド(例)¶
- 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.updatedとstate.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.triggerのdataを使用(初期化フラグ、一時的な設定など)
ドラマ作成者に確認してください。
会話履歴の取得¶
// 指定されたステップ範囲の会話エントリを取得
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;
}
}
};