fix: show AI call transcript in operator voice workspace

OperatorView never fetched asterisk-bridge's GET
/asterisk/live-calls/{call_id}/ai-summary endpoint for voice calls —
the useEffect that loads conversation content only covered telegram
and whatsapp threads, so the voice panel showed just a Call ID and AI
state string, no transcript of what was actually said, even though
the backend already had the full data (transcript_segments,
customer_request_text, ai_outcome_text).

Add BackendVoiceAISummary types, poll the ai-summary endpoint while a
voice interaction is selected, and render the transcript + AI
request/outcome summary in VoiceWorkspace.
This commit is contained in:
arys
2026-08-30 12:48:42 +05:00
parent 03417c4f45
commit db1793e149
2 changed files with 130 additions and 12 deletions
+27
View File
@@ -273,6 +273,33 @@ export interface BackendEscalation {
completed_at?: string | null; completed_at?: string | null;
} }
export interface BackendVoiceAISummaryTranscriptSegment {
speaker: 'caller' | 'assistant';
text: string;
sequence_no: number;
source_type: string;
created_at: string;
interrupted?: boolean;
}
export interface BackendVoiceAISummary {
call_id: string;
session_id?: string | null;
voice_session_id?: string | null;
status_label: string;
status_tone: 'answered' | 'handoff';
customer_name_status?: string | null;
customer_name_value?: string | null;
customer_name_source?: string | null;
voice_start_language?: string | null;
customer_request_text: string;
ai_outcome_text: string;
handoff_reason: string;
recommended_next_step: string;
generated_at: string;
transcript_segments: BackendVoiceAISummaryTranscriptSegment[];
}
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
detail: unknown; detail: unknown;
+103 -12
View File
@@ -33,6 +33,7 @@ import {
BackendLiveCall, BackendLiveCall,
BackendThread, BackendThread,
BackendThreadMessage, BackendThreadMessage,
BackendVoiceAISummary,
channelFromBackend, channelFromBackend,
} from '../../lib/api'; } from '../../lib/api';
@@ -141,6 +142,8 @@ export const OperatorView: React.FC<{ channelFilter?: ChannelType; title?: strin
const [items, setItems] = React.useState<UnifiedInteraction[]>([]); const [items, setItems] = React.useState<UnifiedInteraction[]>([]);
const [selectedInteraction, setSelectedInteraction] = React.useState<UnifiedInteraction | null>(null); const [selectedInteraction, setSelectedInteraction] = React.useState<UnifiedInteraction | null>(null);
const [messages, setMessages] = React.useState<BackendThreadMessage[]>([]); const [messages, setMessages] = React.useState<BackendThreadMessage[]>([]);
const [voiceSummary, setVoiceSummary] = React.useState<BackendVoiceAISummary | null>(null);
const [voiceSummaryLoading, setVoiceSummaryLoading] = React.useState(false);
const [replyText, setReplyText] = React.useState(''); const [replyText, setReplyText] = React.useState('');
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(true);
const [isWorking, setIsWorking] = React.useState(false); const [isWorking, setIsWorking] = React.useState(false);
@@ -202,6 +205,31 @@ export const OperatorView: React.FC<{ channelFilter?: ChannelType; title?: strin
.catch(() => setMessages([])); .catch(() => setMessages([]));
}, [selectedInteraction]); }, [selectedInteraction]);
const loadVoiceSummary = React.useCallback(async (callId: string) => {
setVoiceSummaryLoading(true);
try {
const summary = await apiRequest<BackendVoiceAISummary | null>(
'asterisk-bridge',
`asterisk/live-calls/${encodeURIComponent(callId)}/ai-summary`,
);
setVoiceSummary(summary);
} catch {
setVoiceSummary(null);
} finally {
setVoiceSummaryLoading(false);
}
}, []);
React.useEffect(() => {
if (!selectedInteraction || selectedInteraction.source !== 'voice') {
setVoiceSummary(null);
return;
}
loadVoiceSummary(selectedInteraction.backendId);
const timer = window.setInterval(() => loadVoiceSummary(selectedInteraction.backendId), 5000);
return () => window.clearInterval(timer);
}, [selectedInteraction, loadVoiceSummary]);
const visibleItems = React.useMemo(() => { const visibleItems = React.useMemo(() => {
const channelItems = channelFilter ? items.filter((item) => item.channel === channelFilter) : items; const channelItems = channelFilter ? items.filter((item) => item.channel === channelFilter) : items;
if (activeTab === 'mine') { if (activeTab === 'mine') {
@@ -449,7 +477,14 @@ export const OperatorView: React.FC<{ channelFilter?: ChannelType; title?: strin
<div className="flex-1 overflow-y-auto bg-slate-50/50 flex flex-col relative pb-32 md:pb-0"> <div className="flex-1 overflow-y-auto bg-slate-50/50 flex flex-col relative pb-32 md:pb-0">
{selectedInteraction.channel === ChannelType.VOICE ? ( {selectedInteraction.channel === ChannelType.VOICE ? (
<VoiceWorkspace interaction={selectedInteraction} onClaim={() => handleTakeInWork(selectedInteraction)} onHangup={handleFinish} isWorking={isWorking} /> <VoiceWorkspace
interaction={selectedInteraction}
onClaim={() => handleTakeInWork(selectedInteraction)}
onHangup={handleFinish}
isWorking={isWorking}
summary={voiceSummary}
summaryLoading={voiceSummaryLoading}
/>
) : ( ) : (
<> <>
<div className="flex-1 p-6 space-y-6"> <div className="flex-1 p-6 space-y-6">
@@ -607,12 +642,72 @@ const MessageBubble: React.FC<{ message: BackendThreadMessage }> = ({ message })
); );
}; };
const voiceSpeakerLabel: Record<'caller' | 'assistant', string> = {
caller: 'Клиент',
assistant: 'ИИ-оператор',
};
const VoiceTranscriptPanel: React.FC<{
summary: BackendVoiceAISummary | null;
loading: boolean;
}> = ({ summary, loading }) => {
if (!summary) {
return (
<div className="text-xs text-slate-400 italic py-4 text-center">
{loading ? 'Загрузка транскрипта…' : 'Транскрипт пока недоступен — AI ещё не обработал этот звонок.'}
</div>
);
}
return (
<div className="space-y-4">
{(summary.customer_request_text || summary.ai_outcome_text) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{summary.customer_request_text && (
<div className="p-3 bg-slate-50 rounded-lg border border-slate-100">
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1">Запрос клиента</div>
<div className="text-xs text-slate-700">{summary.customer_request_text}</div>
</div>
)}
{summary.ai_outcome_text && (
<div className="p-3 bg-indigo-50 rounded-lg border border-indigo-100">
<div className="text-[9px] font-bold text-indigo-400 uppercase tracking-widest mb-1">Ответ ИИ</div>
<div className="text-xs text-indigo-900">{summary.ai_outcome_text}</div>
</div>
)}
</div>
)}
<div className="space-y-2">
{summary.transcript_segments.map((segment) => (
<div
key={`${segment.sequence_no}-${segment.created_at}`}
className={cn('flex flex-col text-xs max-w-[85%]', segment.speaker === 'caller' ? 'items-start' : 'items-end ml-auto')}
>
<span className="text-[9px] font-bold uppercase tracking-widest text-slate-400 mb-0.5">
{voiceSpeakerLabel[segment.speaker]}
</span>
<span
className={cn(
'px-3 py-1.5 rounded-xl',
segment.speaker === 'caller' ? 'bg-slate-100 text-slate-800' : 'bg-indigo-600 text-white',
)}
>
{segment.text}
</span>
</div>
))}
</div>
</div>
);
};
const VoiceWorkspace: React.FC<{ const VoiceWorkspace: React.FC<{
interaction: UnifiedInteraction; interaction: UnifiedInteraction;
onClaim: () => void; onClaim: () => void;
onHangup: () => void; onHangup: () => void;
isWorking: boolean; isWorking: boolean;
}> = ({ interaction, onClaim, onHangup, isWorking }) => { summary: BackendVoiceAISummary | null;
summaryLoading: boolean;
}> = ({ interaction, onClaim, onHangup, isWorking, summary, summaryLoading }) => {
if (interaction.status === 'PENDING') { if (interaction.status === 'PENDING') {
return ( return (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center animate-in fade-in duration-500"> <div className="flex-1 flex flex-col items-center justify-center p-12 text-center animate-in fade-in duration-500">
@@ -665,19 +760,15 @@ const VoiceWorkspace: React.FC<{
</div> </div>
</div> </div>
<div className="h-48 bg-white border-t border-slate-100 p-4 overflow-y-auto"> <div className="h-72 bg-white border-t border-slate-100 p-4 overflow-y-auto">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></div> <div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">AI состояние звонка</span> <span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Транскрипт разговора с ИИ</span>
</div> <span className="text-[10px] text-slate-300 ml-auto">
<div className="space-y-2 text-xs"> Call ID: {interaction.backendId} · {interaction.aiState || 'нет активной AI-сессии'}
<p className="text-slate-500 italic"> </span>
<span className="font-bold text-slate-900 not-italic">Call ID:</span> {interaction.backendId}
</p>
<p className="text-slate-500 italic">
<span className="font-bold text-indigo-600 not-italic">AI:</span> {interaction.aiState || 'нет активной AI-сессии'}
</p>
</div> </div>
<VoiceTranscriptPanel summary={summary} loading={summaryLoading} />
</div> </div>
</div> </div>
); );