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;
|
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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user