feat: play the call recording on My calls
Same treatment as the BA operator console: the audio sits with the conversation it belongs to, so "did the AI capture what the customer actually said" is answerable by pressing play rather than by trusting the summary above it. Reads the new /api/agent-sessions/:id/call, which is scoped to the human who took the call — the recording lives on the voice session, owned by the telephony leg, and is unreachable through the ordinary session API. preload="none" so opening the screen doesn't pull a WAV of a whole phone call before anyone asked to hear one. A 404 is a normal answer and renders nothing: recordings are written at call END, so a live call shows its duration and end reason with a note instead of a dead player. tsc -b and vite build clean.
This commit is contained in:
parent
94e5e9520b
commit
0612626491
33
src/api.ts
33
src/api.ts
@ -171,3 +171,36 @@ export async function fetchCallMessages(
|
||||
if (Array.isArray(res)) return res
|
||||
return res?.messages ?? []
|
||||
}
|
||||
|
||||
export interface CallFacts {
|
||||
voice_session_id: number
|
||||
recording_url: string
|
||||
end_reason: string
|
||||
duration_seconds: number
|
||||
answered: boolean
|
||||
started_at?: string
|
||||
ended_at?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The call behind a transferred session — recording included.
|
||||
*
|
||||
* The audio lives on the VOICE session, which is owned by the telephony leg and
|
||||
* not by any person, so it is unreachable through the ordinary session API. This
|
||||
* endpoint bridges that: owning the co-pilot session is the statement "you were
|
||||
* the colleague on this call", and it returns only that call's own facts.
|
||||
*
|
||||
* 404 is normal — a session that isn't a handover has no call behind it, and a
|
||||
* recording is written at call END, so a live call has facts but no audio yet.
|
||||
*/
|
||||
export async function fetchCallFacts(
|
||||
client: ZinoClient,
|
||||
sessionId: number,
|
||||
): Promise<CallFacts | null> {
|
||||
try {
|
||||
return await client.request<CallFacts>('GET', `/api/agent-sessions/${sessionId}/call`)
|
||||
} catch (err: any) {
|
||||
if (err?.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Headphones, Inbox, PhoneForwarded, RefreshCw, Sparkles, User } from 'lucide-react'
|
||||
import { Clock, Headphones, Inbox, PhoneForwarded, RefreshCw, Sparkles, User } from 'lucide-react'
|
||||
import { useQuery, useZino } from '../zino-sdk'
|
||||
import { fetchCallMessages, fetchMyCalls, type CopilotSession } from '../api'
|
||||
import { fetchCallFacts, fetchCallMessages, fetchMyCalls, type CopilotSession } from '../api'
|
||||
import { LIVE_CALL_PREFIX } from '../config'
|
||||
|
||||
const POLL_MS = 3000
|
||||
@ -73,7 +73,12 @@ export default function MyCalls() {
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{activeId && <Thread sessionId={activeId} />}
|
||||
{activeId && (
|
||||
<div className="space-y-4">
|
||||
<Recording sessionId={activeId} />
|
||||
<Thread sessionId={activeId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -114,6 +119,62 @@ function CallRow({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The recording, and the call's own facts.
|
||||
*
|
||||
* This is the evidence the transcript above is a summary OF. On a demo whose
|
||||
* pitch is that the AI captured the conversation faithfully, "did it?" should be
|
||||
* answerable by pressing play rather than by trusting the text.
|
||||
*/
|
||||
function Recording({ sessionId }: { sessionId: number }) {
|
||||
const { client } = useZino()
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['call-facts', sessionId],
|
||||
queryFn: () => fetchCallFacts(client, sessionId),
|
||||
// Written at call end, so it arrives late on a live call. Keep asking, but
|
||||
// stop once there is audio.
|
||||
refetchInterval: (q: any) => (q?.state?.data?.recording_url ? false : POLL_MS * 3),
|
||||
})
|
||||
|
||||
if (isLoading || !data) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Call recording
|
||||
</h3>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Clock size={12} />
|
||||
<span className="tabular-nums">{dur(data.duration_seconds)}</span>
|
||||
</span>
|
||||
{data.end_reason && (
|
||||
<span className="font-mono text-[11px] text-slate-400">
|
||||
{data.end_reason.replace(/-/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.recording_url ? (
|
||||
// preload="none": the audio is a WAV of a whole phone call. Fetching it
|
||||
// for every call in the list, before anyone asks to hear one, would pull
|
||||
// megabytes nobody wanted.
|
||||
<audio controls preload="none" src={data.recording_url} className="mt-3 h-9 w-full" />
|
||||
) : (
|
||||
<p className="mt-2 text-xs italic text-slate-400">
|
||||
No recording yet — it is written when the call ends.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function dur(s?: number): string {
|
||||
const n = Number(s ?? 0)
|
||||
if (!n) return '—'
|
||||
return n >= 60 ? `${Math.floor(n / 60)}m ${n % 60}s` : `${n}s`
|
||||
}
|
||||
|
||||
function Thread({ sessionId }: { sessionId: number }) {
|
||||
const { client } = useZino()
|
||||
const { data: messages = [], isLoading, error } = useQuery({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user