通常のDMを書きたいだけなのに、AIが会話へ入り込む。AIへ相談したつもりが、アプリ操作の依頼として扱われる。一方で、機能ごとに別チャネルを作ると、今度は履歴と担当者が分散します。
問題は「統合UIか分離UIか」という見た目だけではありません。利用者が毎回判断したいのは、次の3点です。
- 相手へメッセージを送るのか
- AIだけに相談するのか
- アプリの状態変更を準備するのか
この記事では、スレッドは一つのまま、入力権限を3つのレーンへ分離する実装を作ります。タブを分けるだけでなく、選択中のレーンと命令の種類が一致しなければサーバーでも拒否します。
結論:履歴を分断せず、入力の意味を分離する
今回の構成は次の通りです。
| レーン | 用途 | AIへ渡す文脈 | 状態変更 |
|---|---|---|---|
dm |
相手との通常会話 | 渡さない | なし |
ai_question |
要約、返信相談、説明 | 明示同意した範囲だけ | なし |
action_request |
通知停止などの操作準備 | 必要最小限 | 人の承認後だけ |
重要なのは、AIに文章からレーンを推測させないことです。
「しばらく通知はいらない」という文は、近況を伝えるDMにも、通知設定の変更依頼にも見えます。LLMが自然な解釈を返せても、利用者がどちらを意図したかは確定できません。そこで、何を入力したかではなく、どの入力欄から送ったかを命令の一部にします。
前提:Tencent RTCとアプリの責任を分ける
Tencent RTCのSocial Messagingソリューションは、1対1チャット、グループディスカッション、コミュニティ、リッチメディアなどのソーシャルメッセージング用途を対象としています。
本記事では、Tencent RTCをメッセージング経路として利用し、その外側にアプリ固有の「会話レーン」を置きます。
[TUIChatなどのチャットUI]
|
| 受信・送信イベント
v
[アプリのChat Adapter]
|
v
[レーン検証API] ----> [AI Adapter]
| |
| +-- 要約・返信案・操作案
v
[アプリの操作Worker]
責任分界は以下のようにします。
- Tencent RTC:チャットやDMの配送と表示
- アプリ:レーン、AI利用同意、操作承認、監査イベント
- AI:相談への回答や操作案の生成
- 人:AIへ渡す範囲と、状態変更を実行するかの決定
AIが高精度に意図分類できる場合でも、同意や実行権限まで委譲したことにはなりません。
まず固定するユーザーワークフロー
画面上部で履歴を分けるのではなく、入力部分だけを分けます。
┌──────────────────────────────┐
│ 田中さんとのDM │
│ │
│ 田中: 明日の集合は10時です │
│ 自分: 了解しました │
│ AI相談: 返信案を作成しました │
│ システム: 有人確認を依頼済み │
│ │
├──────────────────────────────┤
│ [DM] [AIに相談] [操作を準備] │
│ │
│ 選択したレーン専用の入力欄 │
└──────────────────────────────┘
履歴を一つに保つ理由は、有人引き継ぎを新しい窓口へ逃がさないためです。ただし、各イベントにはレーンを表示し、AIの回答を相手の発言に見せないようにします。
UI上の最低条件
- 選択中のレーン名を送信ボタン付近に表示する
- プレースホルダーだけで区別しない
- レーン変更時に未送信テキストを自動転記しない
-
action_requestでは、通常の「送信」と異なるボタン文言を使う - AIへ渡すメッセージ範囲を送信前に表示する
たとえば、操作レーンのボタンは「送信」ではなく「操作案を作る」とします。色だけに依存すると、アクセシビリティ上も誤認しやすくなります。
手順1:検証用プロジェクトを作る
Node.js 20以降を前提にします。
mkdir chat-lane-guard
cd chat-lane-guard
npm init -y
npm install -D typescript tsx @types/node
mkdir src
package.jsonのscriptへ次を追加します。
{
"scripts": {
"check": "tsx src/demo.ts"
}
}
手順2:レーンをUI状態ではなく命令へ含める
src/demo.tsを作成します。
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
type Lane = "dm" | "ai_question" | "action_request";
type ActorRole = "user" | "operator" | "assistant";
type ActionName = "mute_thread" | "create_group_invite";
type Command =
| {
kind: "send_dm";
threadId: string;
text: string;
}
| {
kind: "ask_assistant";
threadId: string;
question: string;
contextMessageIds: string[];
}
| {
kind: "prepare_action";
threadId: string;
action: ActionName;
target: string;
}
| {
kind: "approve_action";
threadId: string;
proposalId: string;
baseRevision: number;
}
| {
kind: "request_handoff";
threadId: string;
reason: string;
};
type ChatEvent = {
id: string;
threadId: string;
revision: number;
lane: Lane;
type:
| "dm_sent"
| "assistant_requested"
| "action_proposed"
| "action_approved"
| "handoff_requested";
actorRole: ActorRole;
body: Record<string, unknown>;
createdAt: string;
};
type RequestContext = {
actorId: string;
actorRole: ActorRole;
aiConsent: boolean;
};
const expectedLane: Record<Command["kind"], Lane> = {
send_dm: "dm",
ask_assistant: "ai_question",
prepare_action: "action_request",
approve_action: "action_request",
request_handoff: "ai_question",
};
class ThreadStore {
private events: ChatEvent[] = [];
get revision(): number {
return this.events.length;
}
all(): readonly ChatEvent[] {
return this.events;
}
submit(
command: Command,
visibleLane: Lane,
context: RequestContext,
): ChatEvent {
if (visibleLane !== expectedLane[command.kind]) {
throw new Error(
`lane_mismatch: ${visibleLane} cannot submit ${command.kind}`,
);
}
// AI出力からクライアント命令を直接実行させない。
if (context.actorRole === "assistant") {
throw new Error("assistant_cannot_submit_client_command");
}
if (
(command.kind === "ask_assistant" ||
command.kind === "prepare_action") &&
!context.aiConsent
) {
throw new Error("ai_consent_required");
}
if (command.kind === "ask_assistant") {
if (command.contextMessageIds.length === 0) {
throw new Error("explicit_context_required");
}
return this.append(command.threadId, "ai_question", context, {
type: "assistant_requested",
body: {
question: command.question,
contextMessageIds: command.contextMessageIds,
},
});
}
if (command.kind === "send_dm") {
return this.append(command.threadId, "dm", context, {
type: "dm_sent",
body: { text: command.text },
});
}
if (command.kind === "prepare_action") {
return this.append(command.threadId, "action_request", context, {
type: "action_proposed",
body: {
proposalId: randomUUID(),
action: command.action,
target: command.target,
status: "awaiting_approval",
},
});
}
if (command.kind === "approve_action") {
if (command.baseRevision !== this.revision) {
throw new Error("stale_thread_revision");
}
const proposal = this.events.find(
(event) =>
event.type === "action_proposed" &&
event.body.proposalId === command.proposalId,
);
if (!proposal) {
throw new Error("proposal_not_found");
}
const alreadyApproved = this.events.some(
(event) =>
event.type === "action_approved" &&
event.body.proposalId === command.proposalId,
);
if (alreadyApproved) {
throw new Error("proposal_already_approved");
}
return this.append(command.threadId, "action_request", context, {
type: "action_approved",
body: {
proposalId: command.proposalId,
approvedBy: context.actorId,
},
});
}
return this.append(command.threadId, "ai_question", context, {
type: "handoff_requested",
body: { reason: command.reason },
});
}
private append(
threadId: string,
lane: Lane,
context: RequestContext,
event: Pick<ChatEvent, "type" | "body">,
): ChatEvent {
const stored: ChatEvent = {
id: randomUUID(),
threadId,
revision: this.revision + 1,
lane,
type: event.type,
actorRole: context.actorRole,
body: event.body,
createdAt: new Date().toISOString(),
};
this.events.push(stored);
return stored;
}
}
const user: RequestContext = {
actorId: "user-1",
actorRole: "user",
aiConsent: true,
};
const store = new ThreadStore();
// 通常DMは通常DMとして保存される。
store.submit(
{ kind: "send_dm", threadId: "thread-1", text: "通知は不要です" },
"dm",
user,
);
assert.equal(store.all()[0].type, "dm_sent");
// 同じ文章でも、DMレーンから操作要求にはできない。
assert.throws(
() =>
store.submit(
{
kind: "prepare_action",
threadId: "thread-1",
action: "mute_thread",
target: "thread-1",
},
"dm",
user,
),
/lane_mismatch/,
);
// 操作レーンでは、まず提案イベントだけを作る。
const proposal = store.submit(
{
kind: "prepare_action",
threadId: "thread-1",
action: "mute_thread",
target: "thread-1",
},
"action_request",
user,
);
const proposalId = String(proposal.body.proposalId);
const revisionAtReview = store.revision;
// 人が現在のrevisionを確認して承認する。
const approval = store.submit(
{
kind: "approve_action",
threadId: "thread-1",
proposalId,
baseRevision: revisionAtReview,
},
"action_request",
user,
);
assert.equal(approval.type, "action_approved");
console.log(JSON.stringify(store.all(), null, 2));
console.log("all checks passed");
実行します。
npm run check
最後にall checks passedと表示されれば、最低限の境界が動いています。
コードの要点:UIを改変されても命令の意味を変えない
この実装では、ブラウザ上の選択状態だけを信用していません。
たとえば拡張機能、古いフロントエンド、実装ミスによって、DM画面からprepare_actionが送られたとします。サーバーは次の対応表で拒否します。
const expectedLane = {
send_dm: "dm",
ask_assistant: "ai_question",
prepare_action: "action_request",
approve_action: "action_request",
request_handoff: "ai_question",
};
これは認可のすべてではありませんが、少なくとも「画面ではDMだったのに、内部では操作命令になった」という不一致を検出できます。
また、action_proposedとaction_approvedを分けています。AIが操作パラメーターを提案する構成にしても、提案イベントをそのまま実行キューへ流してはいけません。操作Workerが受け取るのは、承認済みイベントだけにします。
手順3:チャットへ接続する
実アプリでは、SDK固有のイベントを直接ドメインロジックへ広げず、Adapterで次の内部形式へ変換します。
type IncomingChatMessage = {
messageId: string;
threadId: string;
senderId: string;
text: string;
sentAt: string;
};
接続手順は次の通りです。
- Tencent RTC側から受け取ったメッセージを
IncomingChatMessageへ正規化する - 通常の相手メッセージは
dmとしてタイムラインへ表示する - AI相談では、ユーザーが選んだ
messageIdだけをAI Adapterへ渡す - AI回答は相手へのチャットメッセージとして自動送信せず、
ai_questionレーンへ表示する - 有人確認を選んだら、同じタイムラインに
handoff_requestedを追加する - 承認済み操作だけをアプリの操作Workerへ渡す
送信APIや受信イベントの具体的な呼び出しは、採用するTencent RTCのUIコンポーネントやSDKに合わせてAdapter内へ閉じ込めます。本記事のThreadStoreへSDK固有オブジェクトを直接保存しないことがポイントです。
翻訳はDMレーンの補助機能として扱う
多言語DMでは、TUIChatのオンデマンド翻訳を利用できます。対応するコンテンツ種別、言語、エディション上の条件は公式ドキュメントで確認してください。
翻訳を追加しても、レーンの意味は変更しません。
- 原文は
dmに残す - 翻訳結果を操作命令へ変換しない
- AIへ渡す場合は、原文と翻訳文のどちらを渡すか表示する
- 誤訳の可能性が高い契約、決済、安全上の連絡は人へ引き継ぐ
「通知しないで」のような文が、翻訳によって設定変更命令に見えたとしても、DMレーンから操作を開始させない設計がそのまま効きます。
確認方法:文章ではなく入口を入れ替える
正常なDMだけでなく、同じ文章を異なるレーンから入力して挙動を比較します。
1. DMから操作命令を偽装する
- 表示レーン:
dm - 命令:
prepare_action - 期待結果:
lane_mismatchで拒否
フロントエンドの表示制御だけでは、この試験を通せません。
2. AI同意を取り消してから相談する
const noConsent = { ...user, aiConsent: false };
- 命令:
ask_assistant - 期待結果:
ai_consent_required - DMの送受信:継続可能
AIを停止した結果、通常チャットまで使えなくなる設計は避けます。
3. 文脈を選ばずAIへ質問する
contextMessageIds: []- 期待結果:
explicit_context_required
スレッド全体を暗黙にAIへ渡さず、利用者が選択した範囲を入力契約にします。
4. 承認画面を開いたまま会話を進める
- 操作案を作る
- 別のDMを受信する
- 古い
baseRevisionで承認する
期待結果はstale_thread_revisionです。変更後の状態を確認してから、改めて承認します。
5. AIから直接コマンドを送る
actorRole: "assistant"- 期待結果:
assistant_cannot_submit_client_command
プロンプトに「勝手に操作しないで」と書くだけでなく、実行境界でも拒否します。
検証チェックリスト
- DM画面からAI相談や操作要求を送れない
- AI利用への同意を撤回してもDMは継続できる
- AIへ渡すメッセージIDが送信前に見える
- AI回答と相手の発言を視覚的・意味的に区別できる
- 操作案だけではWorkerが動かない
- 古いrevisionの承認を拒否できる
- 有人引き継ぎが同じスレッドに記録される
- 翻訳後も原文を確認できる
AIが改善できる部分と、改善できない部分
AIが実用的に支援できるのは、選択された文脈の要約、返信案、操作パラメーターの候補作成などです。自由文を構造化された候補へ変換できれば、手入力を減らせます。
一方、次の判断はモデル性能だけでは解決しません。
- 利用者はAIに会話を見せることへ同意したか
- その発言は相手へのDMか、システムへの命令か
- 操作による影響を理解して承認したか
- 問題発生時に誰が対応するか
利用者の不安は、AIが賢いかどうかよりも、いま入力した文章がどこへ行き、何を起こすのか分からないことにあります。分離UIは好みの問題ではなく、その判断を利用者へ戻すための境界です。
注意点とトレードオフ
レーンを増やしすぎない
細かく分けすぎると、送信前の選択コストが上がります。まずはdm、ai_question、action_requestの3つから始め、監査ログを見て追加を判断します。
レーン選択をAIへ自動委任しない
AIによる推薦は可能ですが、自動切り替えは避けます。「操作の可能性があります。操作レーンへ移動しますか」と提案し、利用者が選択する形にします。
未送信テキストをレーン間でコピーしない
DMへ書いた個人的な内容が、AI相談レーンへ自動的に移ると、意図しない情報共有になります。移動する場合は確認画面を挟みます。
revisionだけで完全な競合制御にはならない
サンプルは理解しやすさを優先して、イベント数をrevisionにしています。本番では永続ストア上のトランザクション、冪等キー、承認済み提案の一意制約も必要です。
AI停止時の経路を残す
AI Adapterが失敗しても、DMと有人引き継ぎは利用できるようにします。AI相談を通常会話の必須経路にすると、AI障害がチャット全体の障害になります。
まとめ
一つの画面へ機能を集めることと、一つの入力欄へ意味を集めることは別です。
Social Messagingでは、履歴と担当窓口を一つに保ちながら、次の境界を分けると運用しやすくなります。
- 通常のDM
- 明示的なAI相談
- 承認を必要とする操作準備
そして、分離はタブ表示だけで終わらせず、サーバー側でvisibleLaneとcommand.kindの組み合わせを検証します。これにより、AIの誤分類、UIの不具合、古いクライアントがあっても、通常会話がそのまま操作命令へ変わることを防げます。
関係性の開示: 筆者はTencent RTCと関係があり、本記事の実装上の参照資料としてTencent RTCの公式ドキュメントを使用しました。