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:
@@ -273,6 +273,33 @@ export interface BackendEscalation {
|
||||
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 {
|
||||
status: number;
|
||||
detail: unknown;
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
BackendLiveCall,
|
||||
BackendThread,
|
||||
BackendThreadMessage,
|
||||
BackendVoiceAISummary,
|
||||
channelFromBackend,
|
||||
} from '../../lib/api';
|
||||
|
||||
@@ -141,6 +142,8 @@ export const OperatorView: React.FC<{ channelFilter?: ChannelType; title?: strin
|
||||
const [items, setItems] = React.useState<UnifiedInteraction[]>([]);
|
||||
const [selectedInteraction, setSelectedInteraction] = React.useState<UnifiedInteraction | null>(null);
|
||||
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 [isLoading, setIsLoading] = React.useState(true);
|
||||
const [isWorking, setIsWorking] = React.useState(false);
|
||||
@@ -202,6 +205,31 @@ export const OperatorView: React.FC<{ channelFilter?: ChannelType; title?: strin
|
||||
.catch(() => setMessages([]));
|
||||
}, [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 channelItems = channelFilter ? items.filter((item) => item.channel === channelFilter) : items;
|
||||
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">
|
||||
{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">
|
||||
@@ -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<{
|
||||
interaction: UnifiedInteraction;
|
||||
onClaim: () => void;
|
||||
onHangup: () => void;
|
||||
isWorking: boolean;
|
||||
}> = ({ interaction, onClaim, onHangup, isWorking }) => {
|
||||
summary: BackendVoiceAISummary | null;
|
||||
summaryLoading: boolean;
|
||||
}> = ({ interaction, onClaim, onHangup, isWorking, summary, summaryLoading }) => {
|
||||
if (interaction.status === 'PENDING') {
|
||||
return (
|
||||
<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 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="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>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<p className="text-slate-500 italic">
|
||||
<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>
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Транскрипт разговора с ИИ</span>
|
||||
<span className="text-[10px] text-slate-300 ml-auto">
|
||||
Call ID: {interaction.backendId} · {interaction.aiState || 'нет активной AI-сессии'}
|
||||
</span>
|
||||
</div>
|
||||
<VoiceTranscriptPanel summary={summary} loading={summaryLoading} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user