0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DMの入力欄をAI作業と共用しない:会話レーンをTypeScriptで分離し、誤実行をサーバーでも拒否する

0
Last updated at Posted at 2026-10-09

通常の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上の最低条件

  1. 選択中のレーン名を送信ボタン付近に表示する
  2. プレースホルダーだけで区別しない
  3. レーン変更時に未送信テキストを自動転記しない
  4. action_requestでは、通常の「送信」と異なるボタン文言を使う
  5. 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;
};

接続手順は次の通りです。

  1. Tencent RTC側から受け取ったメッセージをIncomingChatMessageへ正規化する
  2. 通常の相手メッセージはdmとしてタイムラインへ表示する
  3. AI相談では、ユーザーが選んだmessageIdだけをAI Adapterへ渡す
  4. AI回答は相手へのチャットメッセージとして自動送信せず、ai_questionレーンへ表示する
  5. 有人確認を選んだら、同じタイムラインにhandoff_requestedを追加する
  6. 承認済み操作だけをアプリの操作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. 承認画面を開いたまま会話を進める

  1. 操作案を作る
  2. 別のDMを受信する
  3. 古い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の公式ドキュメントを使用しました。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?