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?

VueのAI司会がライブ状態を先走らない設計:提案・承認・RTC確定を分離する

0
Last updated at Posted at 2026-09-08

生成AIを使えば、司会コメントやVueの操作画面はすぐに生成できます。しかし、ライブ配信で本当に難しいのは文章生成ではありません。

  • 共同配信がまだ成立していないのに、AIが「接続しました」と告知する
  • 配信者が手動介入した後も、古いAI提案が実行される
  • RTC側の状態とVue画面の表示が食い違う
  • 誤操作が起きたとき、AIと運営のどちらが決定したのか追えない

ここで感じる不安は「AIに仕事を奪われるか」よりも、自分が理解していない自動処理に運営責任だけが残ることに近いはずです。

この記事では、AI司会をライブ操作へ直結させず、次の3段階へ分けます。

  1. AIは進行案を提案する
  2. 人または決定論的なポリシーが承認する
  3. RTCから確定状態を受け取って画面を更新する

Vue 3+TypeScriptで、この境界を再現可能な形にします。

結論:AIの出力を「命令」ではなく、期限付きの提案として保存する

AI司会に直接ルーム操作権を渡さないことが結論です。

RTCの確定状態
    ↓
アプリ用RoomSnapshot
    ↓
AIへ進行案を問い合わせる
    ↓
Proposalとして検証・保存
    ↓
運営者が承認
    ↓
CommandGatewayからライブ機能を呼ぶ
    ↓
RTCの確定通知を受信
    ↓
RoomSnapshotを更新

特に重要なのは、コマンド呼び出しに成功しても、その場でcohostingへ書き換えないことです。共同配信の成立は、相手の応答や接続結果を含む別の事実です。Vue側はRTC統合層から届く確定イベントを待ちます。

AIが得意なのは、公開情報を基にしたコメント案、次の進行候補、定型案内の生成です。一方、配信終了、メンバー退出、共同配信の成立判定、安全上の判断までAIの文章生成能力から導くことはできません。

前提:Tencent RTCのUI統合方式を先に決める

Tencent RTCの公式実装ガイドでは、ライブ配信の統合方法として、構築済みUIを利用する方法と、ヘッドレスなAtomicXCoreを使う方法が案内されています。

TUILiveKitの公式概要には、視聴、チャット、共同配信、視聴者管理などInteractive Liveに必要な機能が記載されています。

今回作るAI司会パネルは、これらの機能を置き換えるものではありません。ライブ機能の外側に置くアプリケーション制御層です。

選択 向いているケース AI司会パネルの置き場所
構築済みUI 標準的なライブ画面を早く組み込みたい UIの外側に運営パネルを追加する
AtomicXCore 独自のVue画面や進行制御が重要 アプリ用Adapterを介して接続する

本稿のコードでは、特定SDKのAPI名を直接書かず、LiveCommandGatewayRoomEventAdapterの境界を作ります。実際の接続部分は、対象プラットフォーム向けの最新公式ドキュメントに沿って実装してください。

今回作る制御ルール

AIへ許可する提案は、次の2種類に限定します。

提案 AIが生成 人の承認 RTC確定待ち
公開案内を投稿する 必須 不要
共同配信を依頼する 必須 必須
配信を終了する 不可 人だけ 必須
メンバーを退出させる 不可 人だけ 必須

「危険そうならAIに確認させる」のではなく、危険度の高い操作をAI用の型から除外します。

手順1:Vue 3+TypeScriptの検証環境を作る

npm create vite@latest ai-live-console -- --template vue-ts
cd ai-live-console
npm install
npm install -D vitest

package.jsonへテストコマンドを追加します。

{
  "scripts": {
    "dev": "vite",
    "test": "vitest run"
  }
}

今回のファイル構成です。

src/
├── live-control.ts
├── useLiveModerator.ts
└── App.vue
tests/
└── live-control.test.ts

手順2:RTC状態とAI提案を別のデータモデルにする

src/live-control.tsを作成します。

export type RoomPhase =
  | 'idle'
  | 'live'
  | 'cohosting'
  | 'ended'

export type RoomSnapshot = {
  roomId: string
  phase: RoomPhase
  revision: number
  updatedAt: string
}

// SDK固有のイベント名ではなく、アプリ内で正規化したイベント。
export type RoomEvent =
  | { type: 'STREAM_STARTED'; revision: number }
  | { type: 'COHOST_CONNECTED'; revision: number }
  | { type: 'COHOST_DISCONNECTED'; revision: number }
  | { type: 'STREAM_ENDED'; revision: number }

export type ProposalAction =
  | {
      type: 'POST_NOTICE'
      text: string
    }
  | {
      type: 'REQUEST_COHOST'
      targetUserId: string
      reason: string
    }

export type ProposalStatus =
  | 'pending'
  | 'approved'
  | 'rejected'
  | 'stale'
  | 'executed'
  | 'failed'

export type Proposal = {
  proposalId: string
  basedOnRevision: number
  action: ProposalAction
  expiresAt: string
  status: ProposalStatus
  explanation: string
  decidedBy?: string
  error?: string
}

export type ControlMode = 'ai_assisted' | 'human_only'

export interface LiveCommandGateway {
  postNotice(roomId: string, text: string): Promise<void>
  requestCohost(roomId: string, targetUserId: string): Promise<void>
}

revisionは、AIがどの時点のルーム状態を見て提案したかを照合するための番号です。SDKが同名の値を提供するという意味ではありません。RTCのコールバックやバックエンドのスナップショットを、アプリのAdapterが単調増加する番号へ正規化します。

手順3:ルーム状態を確定イベントだけで更新する

同じファイルにReducerを追加します。

export function reduceRoom(
  current: RoomSnapshot,
  event: RoomEvent,
): RoomSnapshot {
  if (event.revision <= current.revision) {
    return current
  }

  let nextPhase: RoomPhase = current.phase

  switch (event.type) {
    case 'STREAM_STARTED':
      if (current.phase !== 'idle') return current
      nextPhase = 'live'
      break

    case 'COHOST_CONNECTED':
      if (current.phase !== 'live') return current
      nextPhase = 'cohosting'
      break

    case 'COHOST_DISCONNECTED':
      if (current.phase !== 'cohosting') return current
      nextPhase = 'live'
      break

    case 'STREAM_ENDED':
      if (current.phase === 'ended') return current
      nextPhase = 'ended'
      break
  }

  return {
    ...current,
    phase: nextPhase,
    revision: event.revision,
    updatedAt: new Date().toISOString(),
  }
}

共同配信リクエストを送った直後には、cohostingへ変更していません。COHOST_CONNECTEDは、RTC統合層で接続成立を確認した後に発行するアプリ内イベントです。

この区別がないと、次のような誤表示が起きます。

AIが共同配信を提案
→ 運営者が承認
→ 招待処理を呼び出す
→ Vueが即座に「共同配信中」と表示
→ 相手は辞退、または接続失敗

コード:期限切れ・状態差分・有人介入を検査する

同じlive-control.tsへ制御クラスを追加します。

export class LiveModeratorControl {
  snapshot: RoomSnapshot
  mode: ControlMode = 'ai_assisted'
  proposals = new Map<string, Proposal>()

  constructor(
    initial: RoomSnapshot,
    private readonly gateway: LiveCommandGateway,
    private readonly now: () => Date = () => new Date(),
  ) {
    this.snapshot = initial
  }

  applyRoomEvent(event: RoomEvent): void {
    const previousRevision = this.snapshot.revision
    this.snapshot = reduceRoom(this.snapshot, event)

    if (this.snapshot.revision === previousRevision) return

    // 状態が変わったら、古い状態に基づく未処理提案を無効化する。
    for (const proposal of this.proposals.values()) {
      if (
        proposal.status === 'pending' &&
        proposal.basedOnRevision !== this.snapshot.revision
      ) {
        proposal.status = 'stale'
      }
    }
  }

  addProposal(proposal: Proposal): void {
    if (this.mode === 'human_only') {
      throw new Error('有人運転中のためAI提案を受け付けません')
    }

    if (this.proposals.has(proposal.proposalId)) {
      return
    }

    this.validateProposal(proposal)
    this.proposals.set(proposal.proposalId, proposal)
  }

  enableHumanOverride(operatorId: string): void {
    this.mode = 'human_only'

    for (const proposal of this.proposals.values()) {
      if (proposal.status === 'pending') {
        proposal.status = 'rejected'
        proposal.decidedBy = operatorId
        proposal.error = '有人オーバーライドにより破棄'
      }
    }
  }

  resumeAiAssistance(): void {
    // 復帰は運営UIの明示操作からだけ呼ぶ。
    this.mode = 'ai_assisted'
  }

  reject(proposalId: string, operatorId: string): void {
    const proposal = this.requirePending(proposalId)
    proposal.status = 'rejected'
    proposal.decidedBy = operatorId
  }

  async approve(
    proposalId: string,
    operatorId: string,
  ): Promise<void> {
    const proposal = this.requirePending(proposalId)

    if (this.mode !== 'ai_assisted') {
      throw new Error('有人運転中はAI提案を実行できません')
    }

    if (proposal.basedOnRevision !== this.snapshot.revision) {
      proposal.status = 'stale'
      throw new Error('提案後にルーム状態が変化しました')
    }

    if (new Date(proposal.expiresAt) <= this.now()) {
      proposal.status = 'stale'
      throw new Error('提案の有効期限が切れています')
    }

    this.validateActionAgainstRoom(proposal.action)

    proposal.status = 'approved'
    proposal.decidedBy = operatorId

    try {
      switch (proposal.action.type) {
        case 'POST_NOTICE':
          await this.gateway.postNotice(
            this.snapshot.roomId,
            proposal.action.text,
          )
          break

        case 'REQUEST_COHOST':
          await this.gateway.requestCohost(
            this.snapshot.roomId,
            proposal.action.targetUserId,
          )
          break
      }

      proposal.status = 'executed'
    } catch (error) {
      proposal.status = 'failed'
      proposal.error =
        error instanceof Error ? error.message : 'unknown error'
      throw error
    }
  }

  private requirePending(proposalId: string): Proposal {
    const proposal = this.proposals.get(proposalId)

    if (!proposal) throw new Error('提案が見つかりません')
    if (proposal.status !== 'pending') {
      throw new Error(`処理済みの提案です: ${proposal.status}`)
    }

    return proposal
  }

  private validateProposal(proposal: Proposal): void {
    if (!proposal.proposalId.trim()) {
      throw new Error('proposalIdは必須です')
    }

    if (proposal.basedOnRevision !== this.snapshot.revision) {
      throw new Error('古いルーム状態に基づく提案です')
    }

    if (proposal.status !== 'pending') {
      throw new Error('新規提案のstatusはpendingである必要があります')
    }

    this.validateActionAgainstRoom(proposal.action)
  }

  private validateActionAgainstRoom(action: ProposalAction): void {
    if (this.snapshot.phase === 'idle' || this.snapshot.phase === 'ended') {
      throw new Error('配信中ではありません')
    }

    if (action.type === 'POST_NOTICE') {
      const length = action.text.trim().length
      if (length === 0 || length > 120) {
        throw new Error('案内文は1〜120文字にしてください')
      }
    }

    if (action.type === 'REQUEST_COHOST') {
      if (this.snapshot.phase !== 'live') {
        throw new Error('現在の状態では共同配信を依頼できません')
      }
      if (!action.targetUserId.trim()) {
        throw new Error('targetUserIdは必須です')
      }
    }
  }
}

ポイントはenableHumanOverride()です。単なる「AIボタンの非表示」ではなく、保留中の提案を破棄し、新しい提案の受付と承認済みでない操作を止めます。

手順4:AIには現在状態と許可された出力形式だけを渡す

実際のLLM呼び出しは、APIキーやモデレーション処理をブラウザへ置かないよう、サーバー側で実行します。

Vueから送る入力例です。

type ModeratorRequest = {
  room: {
    roomId: string
    phase: RoomPhase
    revision: number
  }
  publicContext: {
    scheduledGuestId?: string
    topic: string
  }
  allowedActions: Array<'POST_NOTICE' | 'REQUEST_COHOST'>
}

AIの返却値は、文章として実行せずProposalへ変換します。

{
  "proposalId": "proposal-20260908-001",
  "basedOnRevision": 12,
  "action": {
    "type": "REQUEST_COHOST",
    "targetUserId": "guest-42",
    "reason": "予定時刻になったため"
  },
  "expiresAt": "2026-09-08T12:00:30.000Z",
  "status": "pending",
  "explanation": "予定ゲストとの共同配信開始を提案します"
}

LLMには次の判断をさせません。

  • 現在、本当に共同配信中か
  • 対象ユーザーに操作権限があるか
  • 配信を強制終了すべきか
  • メンバーを退出させるべきか

AI出力は、TypeScriptの型だけでは防御できません。ネットワークから受け取ったJSONは、実運用ではJSON Schemaやスキーマ検証ライブラリを使ってランタイム検証してください。

手順5:Vueから承認とオーバーライドを操作する

src/useLiveModerator.tsを作成します。

import { computed, reactive } from 'vue'
import {
  LiveModeratorControl,
  type LiveCommandGateway,
  type Proposal,
  type RoomEvent,
} from './live-control'

const gateway: LiveCommandGateway = {
  async postNotice(roomId, text) {
    console.log('Adapterへ案内投稿を依頼', { roomId, text })
    // ここでTencent RTC統合層へ接続する。
  },

  async requestCohost(roomId, targetUserId) {
    console.log('Adapterへ共同配信依頼', {
      roomId,
      targetUserId,
    })
    // 呼び出し後もRoomPhaseは変更しない。
  },
}

export function useLiveModerator() {
  const state = reactive(
    new LiveModeratorControl(
      {
        roomId: 'demo-room',
        phase: 'idle',
        revision: 0,
        updatedAt: new Date().toISOString(),
      },
      gateway,
    ),
  )

  const proposals = computed(() =>
    Array.from(state.proposals.values()),
  )

  function onRoomEvent(event: RoomEvent) {
    state.applyRoomEvent(event)
  }

  function receiveProposal(proposal: Proposal) {
    state.addProposal(proposal)
  }

  return {
    state,
    proposals,
    onRoomEvent,
    receiveProposal,
  }
}

App.vueでは、AI提案の内容、参照revision、有効期限を承認前に見せます。

<script setup lang="ts">
import { useLiveModerator } from './useLiveModerator'

const {
  state,
  proposals,
  onRoomEvent,
  receiveProposal,
} = useLiveModerator()

function startDemoLive() {
  onRoomEvent({ type: 'STREAM_STARTED', revision: 1 })
}

function addDemoProposal() {
  receiveProposal({
    proposalId: crypto.randomUUID(),
    basedOnRevision: state.snapshot.revision,
    action: {
      type: 'REQUEST_COHOST',
      targetUserId: 'guest-42',
      reason: '予定ゲストの出演時刻になったため',
    },
    expiresAt: new Date(Date.now() + 30_000).toISOString(),
    status: 'pending',
    explanation: '共同配信リクエストを提案します',
  })
}
</script>

<template>
  <main>
    <h1>ライブ運営コンソール</h1>

    <dl>
      <dt>ルーム状態</dt>
      <dd>{{ state.snapshot.phase }}</dd>
      <dt>revision</dt>
      <dd>{{ state.snapshot.revision }}</dd>
      <dt>制御モード</dt>
      <dd>{{ state.mode }}</dd>
    </dl>

    <button @click="startDemoLive">配信開始イベントを再現</button>
    <button
      :disabled="state.mode === 'human_only'"
      @click="addDemoProposal"
    >
      AI提案を再現
    </button>
    <button @click="state.enableHumanOverride('operator-1')">
      有人運転へ切り替える
    </button>
    <button @click="state.resumeAiAssistance()">
      AI支援を再開する
    </button>

    <article
      v-for="proposal in proposals"
      :key="proposal.proposalId"
    >
      <h2>{{ proposal.action.type }}</h2>
      <p>{{ proposal.explanation }}</p>
      <p>参照revision: {{ proposal.basedOnRevision }}</p>
      <p>期限: {{ proposal.expiresAt }}</p>
      <p>状態: {{ proposal.status }}</p>

      <button
        :disabled="proposal.status !== 'pending'"
        @click="state.approve(proposal.proposalId, 'operator-1')"
      >
        承認
      </button>
      <button
        :disabled="proposal.status !== 'pending'"
        @click="state.reject(proposal.proposalId, 'operator-1')"
      >
        却下
      </button>
    </article>
  </main>
</template>

本番では、STREAM_STARTEDなどのボタンを置くのではなく、Tencent RTC統合層で受けた状態をアプリイベントへ変換してonRoomEvent()へ渡します。

確認方法:AIの文章ではなく、制御の失敗条件をテストする

tests/live-control.test.tsを作成します。

import { describe, expect, it, vi } from 'vitest'
import {
  LiveModeratorControl,
  type LiveCommandGateway,
  type Proposal,
} from '../src/live-control'

function makeProposal(revision = 1): Proposal {
  return {
    proposalId: 'p-1',
    basedOnRevision: revision,
    action: {
      type: 'REQUEST_COHOST',
      targetUserId: 'guest-42',
      reason: '出演予定時刻',
    },
    expiresAt: '2026-09-08T12:01:00.000Z',
    status: 'pending',
    explanation: '共同配信を提案',
  }
}

function setup() {
  const gateway: LiveCommandGateway = {
    postNotice: vi.fn().mockResolvedValue(undefined),
    requestCohost: vi.fn().mockResolvedValue(undefined),
  }

  const control = new LiveModeratorControl(
    {
      roomId: 'room-1',
      phase: 'live',
      revision: 1,
      updatedAt: '2026-09-08T12:00:00.000Z',
    },
    gateway,
    () => new Date('2026-09-08T12:00:10.000Z'),
  )

  return { control, gateway }
}

describe('LiveModeratorControl', () => {
  it('共同配信を依頼しても、確定イベント前はliveのまま', async () => {
    const { control, gateway } = setup()
    control.addProposal(makeProposal())

    await control.approve('p-1', 'operator-1')

    expect(gateway.requestCohost).toHaveBeenCalledOnce()
    expect(control.snapshot.phase).toBe('live')

    control.applyRoomEvent({
      type: 'COHOST_CONNECTED',
      revision: 2,
    })

    expect(control.snapshot.phase).toBe('cohosting')
  })

  it('ルーム状態が変わると古い提案を実行しない', () => {
    const { control } = setup()
    control.addProposal(makeProposal())

    control.applyRoomEvent({
      type: 'STREAM_ENDED',
      revision: 2,
    })

    expect(control.proposals.get('p-1')?.status).toBe('stale')
  })

  it('有人オーバーライドで保留中の提案を破棄する', () => {
    const { control } = setup()
    control.addProposal(makeProposal())

    control.enableHumanOverride('operator-1')

    expect(control.mode).toBe('human_only')
    expect(control.proposals.get('p-1')?.status).toBe('rejected')
  })

  it('期限切れ提案を実行しない', async () => {
    const { control, gateway } = setup()
    const proposal = makeProposal()
    proposal.expiresAt = '2026-09-08T12:00:05.000Z'
    control.addProposal(proposal)

    await expect(
      control.approve('p-1', 'operator-1'),
    ).rejects.toThrow('有効期限')

    expect(gateway.requestCohost).not.toHaveBeenCalled()
    expect(proposal.status).toBe('stale')
  })
})

実行します。

npm test

さらに実機統合では、最低限次を確認します。

  • 共同配信依頼を送っただけでは「共同配信中」と表示されない
  • 相手が辞退した場合、AIが成立済みとして案内しない
  • 提案表示中に配信が終了したら承認できない
  • 有人運転へ切り替えた瞬間、保留中提案が破棄される
  • AI APIがタイムアウトしてもライブ視聴と手動運営を継続できる
  • 同じproposalIdが再送されても提案が増えない
  • 再接続後はローカル状態だけで判断せず、最新スナップショットへ同期する
  • 誰が承認、却下、有人切替を行ったか記録される

注意点

1. AI司会をライブ継続の必須依存にしない

AI APIが停止しても、配信、視聴、手動の共同配信操作を継続できる構成にします。AI司会は進行を補助する機能であり、RTCメディア経路の前提条件にしません。

2. チャット全文を無条件にAIへ送らない

視聴者コメントを進行案に利用する場合は、利用目的を表示し、必要な範囲へ絞り、個人情報や不適切な入力を扱うモデレーション境界を設けます。公開チャットであることと、外部AI処理へ送ってよいことは同義ではありません。

3. 「承認済み」と「成立済み」を分ける

executedは、アプリがGatewayへ処理を依頼できたことを表します。共同配信が成立したことは、RTC側の確定状態を受けたcohostingで表します。監査ログでも両者を同じ成功として保存しないようにします。

4. 自動承認を追加するなら、操作ごとに判断する

将来、短い定型案内だけを自動承認することは考えられます。ただし、共同配信、メンバー管理、配信終了まで一括で自動化する理由にはなりません。

判断基準は「AIの精度が高いか」ではなく、次の4点です。

  • 誤った場合に取り消せるか
  • 影響範囲が限定されているか
  • RTCの確定状態で検証できるか
  • 人が即座に停止できるか

5. コード生成後に人が残すべき理解

生成AIは、Vueコンポーネントや型定義の下書きを速く作れます。しかし、次の設計責任はプロンプトへ委譲できません。

  • 何をRTCの事実として扱うか
  • AIへどの操作を渡さないか
  • 古い提案をいつ無効にするか
  • 有人介入後にどう復帰するか
  • どのログを運営判断の証跡として残すか

AI時代にコードを学ぶ意味は、すべてを手書きすることではなく、生成された実装が越えてはいけない境界を、型・状態・テストとして固定できることにあります。

まとめ

AI司会を安定したライブ機能にするには、自然な文章を生成できるだけでは足りません。

  • AIは期限付きのProposalだけを作る
  • 危険な操作はAI用の型から除外する
  • 承認時にルームrevisionと期限を再確認する
  • コマンド実行とRTC上の成立を分離する
  • 有人オーバーライドで保留提案を破棄する
  • Vue画面はRTC統合層の確定状態を表示する

この構成なら、AIの提案品質を改善しながらも、ライブ運営の決定権と停止手段を人に残せます。


関係性の開示: 筆者は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?