// リアルタイム通話モニタ(manager/live/call_logs と demo/live/call_logs で共用)。
//
// 複数の通話が同時に流れると発話が混戦するため、call_sid ごとにタイムラインを分けて表示する。
// 各通話は card で表され、ヘッダをクリックすると発話タイムラインを開閉できるアコーディオンになる。
//
// Action Cable の購読は importmap の call_monitor_bridge(ES モジュール)が担い、
// window.CallMonitor.subscribe(channel, handlers) 経由で発話を受信する。
// 受信ペイロード: { type: 'utterance', call_sid, to_number, role: 'ai' | 'caller', text, at }
const { useState, useEffect, useRef, useCallback } = React
// 最終発話から LIVE_WINDOW_MS 以内の通話は「通話中」とみなす(通話終了イベントは配信されないため経過時間で判定する)
const LIVE_WINDOW_MS = 45 * 1000
// 同時に保持する通話数の上限(超えたら古い通話から捨てる)
const MAX_CALLS = 50
// 接続状態ごとの表示(ラベルと Bootstrap バッジのクラス)
const STATUS_LABEL = {
connecting: ['接続中…', 'text-bg-secondary'],
connected: ['接続済み', 'text-bg-success'],
disconnected: ['切断されました', 'text-bg-danger'],
rejected: ['購読が拒否されました', 'text-bg-danger'],
}
// ISO8601 文字列を Date にする。パースできなければ現在時刻でフォールバックする
function parseAt(iso) {
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? new Date() : date
}
function formatTime(date) {
return date.toLocaleTimeString('ja-JP')
}
// call_sid はそのままだと長いので末尾6文字に短縮して見出しに使う
function shortSid(sid) {
if (!sid) return '不明'
return sid.length > 6 ? `…${sid.slice(-6)}` : sid
}
// 接続状態バッジ
function ConnectionBadge({ status }) {
const [label, cls] = STATUS_LABEL[status] || STATUS_LABEL.connecting
return {label}
}
// 発話1件の吹き出し。ナナミ(AI)は左、相手(発信者)は右に寄せて区別する
function Message({ utterance }) {
const isAi = utterance.role === 'ai'
const align = isAi ? 'justify-content-start' : 'justify-content-end'
const bubble = isAi ? 'bg-primary-subtle text-primary-emphasis' : 'bg-body-secondary'
const label = isAi ? 'ナナミ' : '相手'
return (
{label}
{formatTime(utterance.at)}
{utterance.text}
)
}
// 通話1件のアコーディオン。ヘッダをクリックすると発話タイムラインを開閉する
function CallItem({ call, open, onToggle, now }) {
const live = now - call.lastAt.getTime() < LIVE_WINDOW_MS
const callerCount = call.utterances.filter((u) => u.role !== 'ai').length
const aiCount = call.utterances.filter((u) => u.role === 'ai').length
return (
{open && (
{call.callSid}
{call.toNumber && (
{call.toNumber}
)}
{formatTime(call.firstAt)} 開始
{call.utterances.map((u, i) => (
))}
)}
)
}
function App({ channel }) {
// 通話は firstAt の新しい順(先頭が最新の通話)で保持する
const [calls, setCalls] = useState([])
const [openSids, setOpenSids] = useState(() => new Set())
const [status, setStatus] = useState('connecting')
const [now, setNow] = useState(() => Date.now())
// 既知の call_sid(新規通話の判定用)と、通話に振る通し番号
const knownSids = useRef(new Set())
const seq = useRef(0)
// 発話1件を該当する通話へ振り分ける。未知の call_sid なら新しい通話を作って自動で開く
const addUtterance = useCallback((data) => {
const at = parseAt(data.at)
const message = { role: data.role, text: data.text, at }
const sid = data.call_sid
if (knownSids.current.has(sid)) {
setCalls((prev) => {
const idx = prev.findIndex((c) => c.callSid === sid)
if (idx < 0) return prev // 上限超過で捨てられた通話への追記は無視する
const next = prev.slice()
next[idx] = { ...prev[idx], utterances: [...prev[idx].utterances, message], lastAt: at }
return next
})
return
}
knownSids.current.add(sid)
seq.current += 1
const call = {
callSid: sid,
seq: seq.current,
toNumber: data.to_number,
utterances: [message],
firstAt: at,
lastAt: at,
}
setCalls((prev) => {
const next = [call, ...prev]
return next.length > MAX_CALLS ? next.slice(0, MAX_CALLS) : next
})
setOpenSids((prev) => {
const next = new Set(prev)
next.add(sid)
return next
})
}, [])
const toggle = useCallback((sid) => {
setOpenSids((prev) => {
const next = new Set(prev)
if (next.has(sid)) next.delete(sid)
else next.add(sid)
return next
})
}, [])
// Action Cable を購読する。call_monitor_bridge が未読込なら ready イベントを待つ
useEffect(() => {
let subscription
const start = () => {
if (!window.CallMonitor) return
subscription = window.CallMonitor.subscribe(channel, {
connected() {
setStatus('connected')
},
disconnected() {
setStatus('disconnected')
},
rejected() {
setStatus('rejected')
},
received(data) {
if (data && data.type === 'utterance') addUtterance(data)
},
})
}
if (window.CallMonitor) start()
else window.addEventListener('call-monitor:ready', start, { once: true })
return () => {
if (subscription) subscription.unsubscribe()
window.removeEventListener('call-monitor:ready', start)
}
}, [channel, addUtterance])
// 「通話中」判定と時刻表示を更新するため定期的に現在時刻を進める
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 5000)
return () => clearInterval(id)
}, [])
return (
{calls.length} 件の通話
{calls.length === 0 ? (
) : (
calls.map((call) => (
toggle(call.callSid)}
/>
))
)}
)
}
const rootEl = document.getElementById('call-monitor-root')
if (rootEl) {
const channel = rootEl.dataset.channel || 'CallMonitorChannel'
ReactDOM.createRoot(rootEl).render()
}