Compare commits
2 Commits
94e5e9520b
...
e442347b6e
| Author | SHA1 | Date | |
|---|---|---|---|
| e442347b6e | |||
| 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
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,8 @@ export const FIELDS = {
|
||||
CALL_DURATION: 'call_duration_seconds',
|
||||
CALL_SUMMARY: 'call_summary',
|
||||
CONTACT_NOTES: 'contact_notes',
|
||||
/** Dual-channel WAV of the call, written when it ends. */
|
||||
CALL_RECORDING: 'call_recording_url',
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
@ -169,6 +169,7 @@ function DetailDrawer({ row, onClose }: { row: LeadRow; onClose: () => void }) {
|
||||
const summary = str(row[FIELDS.CALL_SUMMARY])
|
||||
const notes = str(row[FIELDS.CONTACT_NOTES])
|
||||
const transferred = str(row[FIELDS.CALL_END_REASON]) === END_REASON_HANDOVER
|
||||
const recording = str(row[FIELDS.CALL_RECORDING])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 flex justify-end bg-slate-900/20" onClick={onClose}>
|
||||
@ -205,6 +206,25 @@ function DetailDrawer({ row, onClose }: { row: LeadRow; onClose: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The recording sits ABOVE the summary on purpose: the summary is a
|
||||
claim about the conversation, and this is the conversation. On a demo
|
||||
about whether the AI captured things fairly, the evidence should not
|
||||
be below the fold. */}
|
||||
<div className="mt-5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Recording</h3>
|
||||
{recording ? (
|
||||
// preload="none": a WAV of a whole call, and the drawer opens on
|
||||
// every row click. Fetch it when someone actually presses play.
|
||||
<audio controls preload="none" src={recording} className="mt-2 h-9 w-full" />
|
||||
) : (
|
||||
<p className="mt-1.5 text-sm text-slate-400">
|
||||
{transferred || str(row[FIELDS.CALL_END_REASON])
|
||||
? 'No recording for this call.'
|
||||
: 'Available once the call ends.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Section title="Call summary" body={summary} empty="No summary yet — it is written when the call ends." />
|
||||
<Section title="Notes" body={notes} />
|
||||
|
||||
|
||||
@ -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