Compare commits
No commits in common. "e442347b6e92663e0af4a0957f604eba0cc2ae1a" and "94e5e9520ba830ef01bdb414d11fd4e47c8076f9" have entirely different histories.
e442347b6e
...
94e5e9520b
33
src/api.ts
33
src/api.ts
@ -171,36 +171,3 @@ export async function fetchCallMessages(
|
|||||||
if (Array.isArray(res)) return res
|
if (Array.isArray(res)) return res
|
||||||
return res?.messages ?? []
|
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,8 +39,6 @@ export const FIELDS = {
|
|||||||
CALL_DURATION: 'call_duration_seconds',
|
CALL_DURATION: 'call_duration_seconds',
|
||||||
CALL_SUMMARY: 'call_summary',
|
CALL_SUMMARY: 'call_summary',
|
||||||
CONTACT_NOTES: 'contact_notes',
|
CONTACT_NOTES: 'contact_notes',
|
||||||
/** Dual-channel WAV of the call, written when it ends. */
|
|
||||||
CALL_RECORDING: 'call_recording_url',
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -169,7 +169,6 @@ function DetailDrawer({ row, onClose }: { row: LeadRow; onClose: () => void }) {
|
|||||||
const summary = str(row[FIELDS.CALL_SUMMARY])
|
const summary = str(row[FIELDS.CALL_SUMMARY])
|
||||||
const notes = str(row[FIELDS.CONTACT_NOTES])
|
const notes = str(row[FIELDS.CONTACT_NOTES])
|
||||||
const transferred = str(row[FIELDS.CALL_END_REASON]) === END_REASON_HANDOVER
|
const transferred = str(row[FIELDS.CALL_END_REASON]) === END_REASON_HANDOVER
|
||||||
const recording = str(row[FIELDS.CALL_RECORDING])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-20 flex justify-end bg-slate-900/20" onClick={onClose}>
|
<div className="fixed inset-0 z-20 flex justify-end bg-slate-900/20" onClick={onClose}>
|
||||||
@ -206,25 +205,6 @@ function DetailDrawer({ row, onClose }: { row: LeadRow; onClose: () => void }) {
|
|||||||
</div>
|
</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="Call summary" body={summary} empty="No summary yet — it is written when the call ends." />
|
||||||
<Section title="Notes" body={notes} />
|
<Section title="Notes" body={notes} />
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Clock, Headphones, Inbox, PhoneForwarded, RefreshCw, Sparkles, User } from 'lucide-react'
|
import { Headphones, Inbox, PhoneForwarded, RefreshCw, Sparkles, User } from 'lucide-react'
|
||||||
import { useQuery, useZino } from '../zino-sdk'
|
import { useQuery, useZino } from '../zino-sdk'
|
||||||
import { fetchCallFacts, fetchCallMessages, fetchMyCalls, type CopilotSession } from '../api'
|
import { fetchCallMessages, fetchMyCalls, type CopilotSession } from '../api'
|
||||||
import { LIVE_CALL_PREFIX } from '../config'
|
import { LIVE_CALL_PREFIX } from '../config'
|
||||||
|
|
||||||
const POLL_MS = 3000
|
const POLL_MS = 3000
|
||||||
@ -73,12 +73,7 @@ export default function MyCalls() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{activeId && (
|
{activeId && <Thread sessionId={activeId} />}
|
||||||
<div className="space-y-4">
|
|
||||||
<Recording sessionId={activeId} />
|
|
||||||
<Thread sessionId={activeId} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -119,62 +114,6 @@ 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 }) {
|
function Thread({ sessionId }: { sessionId: number }) {
|
||||||
const { client } = useZino()
|
const { client } = useZino()
|
||||||
const { data: messages = [], isLoading, error } = useQuery({
|
const { data: messages = [], isLoading, error } = useQuery({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user