// リアルタイム通話モニタ(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() }