109 lines
3.9 KiB
JavaScript
109 lines
3.9 KiB
JavaScript
import { useEffect, useRef } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import './Conversation.css'
|
|
|
|
/**
|
|
* The call, turn by turn.
|
|
*
|
|
* The voice agent hands the workflow a full transcript when a call ends, and
|
|
* until now Engage read it, wrote three sentences of summary, and the words
|
|
* themselves were dropped. So a customer said "I think it's 12,000 rupees" and
|
|
* "lower premium", and the file held one AI's paraphrase of that.
|
|
*
|
|
* It matters more here than it would elsewhere: `consent_artefact =
|
|
* verbal_call` is recorded from what the agent HEARD, and it is the artefact
|
|
* justifying every later contact. A colleague reviewing that months later
|
|
* should see the customer's own words.
|
|
*
|
|
* Rendered like the WhatsApp thread on purpose — agent right, caller left —
|
|
* because it is the same kind of thing and should not need learning twice.
|
|
*/
|
|
function turnsFrom(text) {
|
|
if (!text || typeof text !== 'string') return []
|
|
const out = []
|
|
// "Agent: ..." / "Caller: ..." with a turn continuing until the next label.
|
|
// Split on the labels rather than on newlines: a single turn wraps, and
|
|
// splitting per line would shatter one sentence into four bubbles.
|
|
const re = /^(Agent|Caller|Customer|User|Assistant):\s*/i
|
|
for (const raw of text.split('\n')) {
|
|
const line = raw.replace(/\s+$/, '')
|
|
if (!line.trim()) continue
|
|
const m = line.match(re)
|
|
if (m) {
|
|
const who = m[1].toLowerCase()
|
|
out.push({
|
|
side: (who === 'agent' || who === 'assistant') ? 'us' : 'them',
|
|
text: line.slice(m[0].length).trim(),
|
|
})
|
|
} else if (out.length) {
|
|
out[out.length - 1].text += (out[out.length - 1].text ? '\n' : '') + line.trim()
|
|
}
|
|
}
|
|
return out.filter((t) => t.text)
|
|
}
|
|
|
|
export default function CallTranscript({ text, name, onClose }) {
|
|
const ref = useRef(null)
|
|
const restore = useRef(null)
|
|
const turns = turnsFrom(text)
|
|
|
|
useEffect(() => {
|
|
restore.current = document.activeElement
|
|
ref.current?.focus()
|
|
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
|
document.addEventListener('keydown', onKey)
|
|
const prev = document.body.style.overflow
|
|
document.body.style.overflow = 'hidden'
|
|
return () => {
|
|
document.removeEventListener('keydown', onKey)
|
|
document.body.style.overflow = prev
|
|
if (restore.current instanceof HTMLElement) restore.current.focus()
|
|
}
|
|
}, [onClose])
|
|
|
|
return createPortal(
|
|
<div className="prose__scrim" onClick={onClose} role="presentation">
|
|
<div
|
|
className="prose__dlg conv"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Call transcript"
|
|
tabIndex={-1}
|
|
ref={ref}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="prose__head">
|
|
<h3>The call with {name || 'the customer'}</h3>
|
|
<button type="button" onClick={onClose} aria-label="Close">
|
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
|
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
|
|
strokeWidth="1.6" strokeLinecap="round" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="conv__body">
|
|
{turns.length ? turns.map((t, i) => (
|
|
<div key={i} className={'bub bub--' + t.side}>
|
|
<p>{t.text}</p>
|
|
<span>{t.side === 'us' ? 'Meera' : (name || 'the customer')}</span>
|
|
</div>
|
|
)) : (
|
|
/* A transcript that will not parse is still evidence, so it is
|
|
shown raw rather than replaced with "nothing to display". */
|
|
<pre className="conv__raw">{text}</pre>
|
|
)}
|
|
</div>
|
|
|
|
<p className="conv__note">
|
|
Recorded by the voice agent as the call happened. This is what was said —
|
|
the call notes in the trail are Engage's reading of it, which is a
|
|
different thing.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
,
|
|
document.body,
|
|
)
|
|
}
|