mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-10 08:05:22 +02:00
feat: add rtf in logs (#119)
* feat: add rtf in logs * chore: unify the call logs and real time events
This commit is contained in:
parent
a172db8022
commit
cac25879bf
19 changed files with 861 additions and 206 deletions
|
|
@ -9,7 +9,7 @@ import {
|
|||
ApiKeyErrorDialog,
|
||||
AudioControls,
|
||||
ConnectionStatus,
|
||||
RealtimeFeedbackPanel,
|
||||
RealtimeFeedback,
|
||||
WorkflowConfigErrorDialog
|
||||
} from "./components";
|
||||
import { useWebSocketRTC } from "./hooks";
|
||||
|
|
@ -142,9 +142,9 @@ const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVar
|
|||
|
||||
{/* Show transcript panel */}
|
||||
<div className="w-1/3 h-full shrink-0">
|
||||
<RealtimeFeedbackPanel
|
||||
<RealtimeFeedback
|
||||
mode="live"
|
||||
messages={feedbackMessages}
|
||||
isVisible={true}
|
||||
isCallActive={connectionActive}
|
||||
isCallCompleted={isCompleted}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
'use client';
|
||||
|
||||
import { FeedbackMessage } from '../hooks/useWebSocketRTC';
|
||||
import { processLiveMessages, processTranscriptEvents, TranscriptEvent } from '../utils/processTranscriptEvents';
|
||||
import { UnifiedTranscript } from './UnifiedTranscript';
|
||||
|
||||
// Historical log event format from the backend
|
||||
interface RealtimeFeedbackEvent {
|
||||
type: string;
|
||||
payload: {
|
||||
text?: string;
|
||||
final?: boolean;
|
||||
user_id?: string;
|
||||
timestamp?: string;
|
||||
function_name?: string;
|
||||
tool_call_id?: string;
|
||||
result?: string;
|
||||
node_name?: string;
|
||||
previous_node?: string;
|
||||
ttfb_seconds?: number;
|
||||
processor?: string;
|
||||
model?: string;
|
||||
};
|
||||
timestamp: string;
|
||||
turn: number;
|
||||
}
|
||||
|
||||
export interface WorkflowRunLogs {
|
||||
realtime_feedback_events?: RealtimeFeedbackEvent[];
|
||||
}
|
||||
|
||||
// Props for live mode (WebSocket messages)
|
||||
interface LiveModeProps {
|
||||
mode: 'live';
|
||||
messages: FeedbackMessage[];
|
||||
isCallActive: boolean;
|
||||
isCallCompleted: boolean;
|
||||
}
|
||||
|
||||
// Props for historical mode (API logs)
|
||||
interface HistoricalModeProps {
|
||||
mode: 'historical';
|
||||
logs: WorkflowRunLogs | null;
|
||||
}
|
||||
|
||||
type RealtimeFeedbackProps = LiveModeProps | HistoricalModeProps;
|
||||
|
||||
/**
|
||||
* Convert backend log events to unified TranscriptEvent format
|
||||
*/
|
||||
function convertLogEventsToTranscriptEvents(events: RealtimeFeedbackEvent[]): TranscriptEvent[] {
|
||||
return events.map(event => {
|
||||
let type: TranscriptEvent['type'];
|
||||
let status: TranscriptEvent['status'];
|
||||
|
||||
switch (event.type) {
|
||||
case 'rtf-user-transcription':
|
||||
type = 'user-transcription';
|
||||
break;
|
||||
case 'rtf-bot-text':
|
||||
type = 'bot-text';
|
||||
break;
|
||||
case 'rtf-function-call-start':
|
||||
type = 'function-call';
|
||||
status = 'running';
|
||||
break;
|
||||
case 'rtf-function-call-end':
|
||||
type = 'function-call';
|
||||
status = 'completed';
|
||||
break;
|
||||
case 'rtf-node-transition':
|
||||
type = 'node-transition';
|
||||
break;
|
||||
case 'rtf-ttfb-metric':
|
||||
type = 'ttfb-metric';
|
||||
break;
|
||||
default:
|
||||
type = 'bot-text';
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
text: event.payload.text || event.payload.result || event.payload.function_name || event.payload.node_name || '',
|
||||
final: event.payload.final,
|
||||
timestamp: event.timestamp,
|
||||
turn: event.turn,
|
||||
functionName: event.payload.function_name,
|
||||
status,
|
||||
nodeName: event.payload.node_name,
|
||||
previousNode: event.payload.previous_node,
|
||||
ttfbSeconds: event.payload.ttfb_seconds,
|
||||
processor: event.payload.processor,
|
||||
model: event.payload.model,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert live WebSocket messages to unified TranscriptEvent format
|
||||
*/
|
||||
function convertLiveMessagesToTranscriptEvents(messages: FeedbackMessage[]): TranscriptEvent[] {
|
||||
return messages.map(msg => ({
|
||||
type: msg.type,
|
||||
text: msg.text,
|
||||
final: msg.final,
|
||||
timestamp: msg.timestamp,
|
||||
functionName: msg.functionName,
|
||||
status: msg.status,
|
||||
nodeName: msg.nodeName,
|
||||
previousNode: msg.previousNode,
|
||||
ttfbSeconds: msg.ttfbSeconds,
|
||||
processor: msg.processor,
|
||||
model: msg.model,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Single unified component that handles both live WebSocket messages
|
||||
* and historical logs from the API.
|
||||
*/
|
||||
export const RealtimeFeedback = (props: RealtimeFeedbackProps) => {
|
||||
if (props.mode === 'historical') {
|
||||
// Historical mode - process logs from API
|
||||
const rawEvents = props.logs?.realtime_feedback_events;
|
||||
const messages = rawEvents
|
||||
? processTranscriptEvents(convertLogEventsToTranscriptEvents(rawEvents))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<UnifiedTranscript
|
||||
messages={messages}
|
||||
status="ended"
|
||||
title="Call Transcript"
|
||||
emptyState={{
|
||||
title: "No conversation recorded",
|
||||
subtitle: "Real-time feedback events were not captured for this call"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Live mode - process WebSocket messages (optimized - messages already accumulated)
|
||||
const { messages, isCallActive, isCallCompleted } = props;
|
||||
const status = isCallActive ? 'live' : isCallCompleted ? 'ended' : 'ready';
|
||||
const processedMessages = processLiveMessages(convertLiveMessagesToTranscriptEvents(messages));
|
||||
|
||||
return (
|
||||
<UnifiedTranscript
|
||||
messages={processedMessages}
|
||||
status={status}
|
||||
title="Live Transcript"
|
||||
autoScroll={true}
|
||||
emptyState={{
|
||||
title: "No messages yet",
|
||||
subtitle: isCallActive
|
||||
? "Start speaking to see the transcript"
|
||||
: "Start the call to begin the conversation"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2, MessageSquare, Mic, MicOff, Wrench } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { FeedbackMessage } from "../hooks/useWebSocketRTC";
|
||||
|
||||
interface RealtimeFeedbackPanelProps {
|
||||
messages: FeedbackMessage[];
|
||||
isVisible: boolean;
|
||||
isCallActive: boolean;
|
||||
isCallCompleted: boolean;
|
||||
}
|
||||
|
||||
const MessageItem = ({ msg }: { msg: FeedbackMessage }) => {
|
||||
// Function call message - centered
|
||||
if (msg.type === 'function-call') {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="px-3 py-1.5 rounded-full text-xs bg-amber-500/10 border border-amber-500/20 inline-flex items-center gap-2">
|
||||
{msg.status === 'running' ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-amber-500" />
|
||||
) : (
|
||||
<Wrench className="h-3 w-3 text-amber-500" />
|
||||
)}
|
||||
<span className="font-mono text-amber-700 dark:text-amber-400">
|
||||
{msg.functionName}()
|
||||
</span>
|
||||
{msg.status === 'completed' && (
|
||||
<span className="text-muted-foreground">✓</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isUser = msg.type === 'user-transcription';
|
||||
|
||||
// User messages on right, bot messages on left
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex",
|
||||
isUser ? "justify-end" : "justify-start"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] px-3 py-2 rounded-2xl text-sm",
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground rounded-br-md"
|
||||
: "bg-muted rounded-bl-md",
|
||||
!msg.final && "opacity-70"
|
||||
)}
|
||||
>
|
||||
<div className="whitespace-pre-wrap leading-relaxed">{msg.text}</div>
|
||||
{!msg.final && (
|
||||
<div className={cn(
|
||||
"text-[10px] mt-1 italic",
|
||||
isUser ? "text-primary-foreground/70" : "text-muted-foreground"
|
||||
)}>
|
||||
speaking...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const RealtimeFeedbackPanel = ({
|
||||
messages,
|
||||
isVisible,
|
||||
isCallActive,
|
||||
isCallCompleted
|
||||
}: RealtimeFeedbackPanelProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-background border-l border-border">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-medium text-sm whitespace-nowrap">Live Transcript</span>
|
||||
<div className={cn(
|
||||
"flex items-center gap-1 text-xs px-2 py-0.5 rounded-full shrink-0",
|
||||
isCallActive
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: isCallCompleted
|
||||
? "bg-muted text-muted-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{isCallActive ? (
|
||||
<>
|
||||
<Mic className="h-3 w-3" />
|
||||
<span>Live</span>
|
||||
</>
|
||||
) : isCallCompleted ? (
|
||||
<>
|
||||
<MicOff className="h-3 w-3" />
|
||||
<span>Ended</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MicOff className="h-3 w-3" />
|
||||
<span>Ready</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
|
||||
<MessageSquare className="h-10 w-10 mb-4 opacity-30" />
|
||||
<p className="font-medium">No messages yet</p>
|
||||
<p className="text-xs mt-1 text-center px-4">
|
||||
{isCallActive
|
||||
? "Start speaking to see the transcript"
|
||||
: "Start the call to begin the conversation"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-4">
|
||||
{messages.map((msg) => (
|
||||
<MessageItem key={msg.id} msg={msg} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with message count */}
|
||||
{messages.length > 0 && (
|
||||
<div className="px-4 py-2 border-t border-border text-xs text-muted-foreground shrink-0">
|
||||
{messages.filter(m => m.type !== 'function-call').length} messages
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { ProcessedMessage } from "../utils/processTranscriptEvents";
|
||||
import { TranscriptContainer } from "./shared/TranscriptContainer";
|
||||
import { TranscriptEmptyState } from "./shared/TranscriptEmptyState";
|
||||
import { TranscriptMessage, TranscriptMessageData } from "./shared/TranscriptMessage";
|
||||
|
||||
interface UnifiedTranscriptProps {
|
||||
messages: ProcessedMessage[];
|
||||
status: 'ready' | 'live' | 'ended';
|
||||
title?: string;
|
||||
autoScroll?: boolean;
|
||||
emptyState?: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const UnifiedTranscript = ({
|
||||
messages,
|
||||
status,
|
||||
title,
|
||||
autoScroll = false,
|
||||
emptyState
|
||||
}: UnifiedTranscriptProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive (for live mode)
|
||||
useEffect(() => {
|
||||
if (autoScroll && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, autoScroll]);
|
||||
|
||||
// Calculate message count (exclude system messages like function calls, node transitions, TTFB)
|
||||
const messageCount = messages.filter(
|
||||
m => m.type === 'user-transcription' || m.type === 'bot-text'
|
||||
).length;
|
||||
|
||||
// Convert ProcessedMessage to TranscriptMessageData
|
||||
const transcriptMessages: TranscriptMessageData[] = messages.map(msg => ({
|
||||
id: msg.id,
|
||||
type: msg.type,
|
||||
text: msg.text,
|
||||
final: msg.final,
|
||||
functionName: msg.functionName,
|
||||
status: msg.status,
|
||||
nodeName: msg.nodeName,
|
||||
ttfbSeconds: msg.ttfbSeconds,
|
||||
}));
|
||||
|
||||
// Default empty state
|
||||
const defaultEmptyState = {
|
||||
title: status === 'live' ? "No messages yet" : "No conversation recorded",
|
||||
subtitle: status === 'live'
|
||||
? "Start speaking to see the transcript"
|
||||
: "Real-time feedback events were not captured"
|
||||
};
|
||||
|
||||
const emptyStateToShow = emptyState || defaultEmptyState;
|
||||
|
||||
return (
|
||||
<TranscriptContainer
|
||||
title={title || (status === 'live' ? 'Live Transcript' : 'Call Transcript')}
|
||||
status={status}
|
||||
messageCount={messageCount > 0 ? messageCount : undefined}
|
||||
>
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{messages.length === 0 ? (
|
||||
<TranscriptEmptyState
|
||||
title={emptyStateToShow.title}
|
||||
subtitle={emptyStateToShow.subtitle}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3 p-4">
|
||||
{transcriptMessages.map((msg, index) => {
|
||||
// Skip standalone TTFB metrics (they're rendered inline with bot text)
|
||||
if (msg.type === 'ttfb-metric') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<TranscriptMessage
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
nextMessage={transcriptMessages[index + 1]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TranscriptContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -2,5 +2,5 @@ export * from './ApiKeyErrorDialog';
|
|||
export * from './AudioControls';
|
||||
export * from './ConnectionStatus';
|
||||
export * from './ContextDisplay';
|
||||
export * from './RealtimeFeedbackPanel';
|
||||
export * from './WorkflowConfigErrorDialog'
|
||||
export * from './RealtimeFeedback';
|
||||
export * from './WorkflowConfigErrorDialog';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
'use client';
|
||||
|
||||
import { MessageSquare, Mic, MicOff } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type CallStatus = 'ready' | 'live' | 'ended';
|
||||
|
||||
interface TranscriptContainerProps {
|
||||
title: string;
|
||||
status: CallStatus;
|
||||
children: ReactNode;
|
||||
messageCount?: number;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
ready: {
|
||||
icon: MicOff,
|
||||
label: 'Ready',
|
||||
className: 'bg-muted text-muted-foreground',
|
||||
},
|
||||
live: {
|
||||
icon: Mic,
|
||||
label: 'Live',
|
||||
className: 'bg-green-500/10 text-green-600 dark:text-green-400',
|
||||
},
|
||||
ended: {
|
||||
icon: MicOff,
|
||||
label: 'Ended',
|
||||
className: 'bg-muted text-muted-foreground',
|
||||
},
|
||||
};
|
||||
|
||||
export function TranscriptContainer({
|
||||
title,
|
||||
status,
|
||||
children,
|
||||
messageCount
|
||||
}: TranscriptContainerProps) {
|
||||
const statusConfig = STATUS_CONFIG[status];
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-background border-l border-border">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-medium text-sm whitespace-nowrap">{title}</span>
|
||||
<div className={cn(
|
||||
"flex items-center gap-1 text-xs px-2 py-0.5 rounded-full shrink-0",
|
||||
statusConfig.className
|
||||
)}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
<span>{statusConfig.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{children}
|
||||
|
||||
{/* Footer with message count */}
|
||||
{messageCount !== undefined && messageCount > 0 && (
|
||||
<div className="px-4 py-2 border-t border-border text-xs text-muted-foreground shrink-0">
|
||||
{messageCount} messages
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
'use client';
|
||||
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
interface TranscriptEmptyStateProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function TranscriptEmptyState({ title, subtitle }: TranscriptEmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
|
||||
<MessageSquare className="h-10 w-10 mb-4 opacity-30" />
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="text-xs mt-1 text-center px-4">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
'use client';
|
||||
|
||||
import { Brain, GitBranch, Wrench } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TranscriptMessageData {
|
||||
id: string;
|
||||
type: 'user-transcription' | 'bot-text' | 'function-call' | 'node-transition' | 'ttfb-metric';
|
||||
text: string;
|
||||
final?: boolean;
|
||||
functionName?: string;
|
||||
nodeName?: string;
|
||||
ttfbSeconds?: number;
|
||||
}
|
||||
|
||||
interface TranscriptMessageProps {
|
||||
message: TranscriptMessageData;
|
||||
nextMessage?: TranscriptMessageData;
|
||||
}
|
||||
|
||||
export function TranscriptMessage({ message, nextMessage }: TranscriptMessageProps) {
|
||||
// Node transition - show as section divider
|
||||
if (message.type === 'node-transition') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<div className="flex-1 h-px bg-border"></div>
|
||||
<div className="px-2 py-1 rounded-md text-xs bg-blue-500/10 border border-blue-500/20 inline-flex items-center gap-1.5">
|
||||
<GitBranch className="h-3 w-3 text-blue-500" />
|
||||
<span className="font-medium text-blue-700 dark:text-blue-400">
|
||||
{message.nodeName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-border"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TTFB metric - don't render standalone, it'll be shown with bot messages and function calls
|
||||
if (message.type === 'ttfb-metric') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Function call message - centered with TTFB if present
|
||||
if (message.type === 'function-call') {
|
||||
const ttfbMetric = nextMessage?.type === 'ttfb-metric' ? nextMessage : null;
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{/* Show TTFB metric above function call */}
|
||||
{ttfbMetric && ttfbMetric.ttfbSeconds !== undefined && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Brain className="h-3 w-3" />
|
||||
<span className="font-medium">Reasoning Delay:</span>
|
||||
<span>{(ttfbMetric.ttfbSeconds * 1000).toFixed(0)}ms</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-1.5 rounded-full text-xs bg-amber-500/10 border border-amber-500/20 inline-flex items-center gap-2">
|
||||
<Wrench className="h-3 w-3 text-amber-500" />
|
||||
<span className="font-mono text-amber-700 dark:text-amber-400">
|
||||
{message.functionName}()
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isUser = message.type === 'user-transcription';
|
||||
const isBot = message.type === 'bot-text';
|
||||
|
||||
// Check if next message is a TTFB metric (for bot messages)
|
||||
const ttfbMetric = isBot && nextMessage?.type === 'ttfb-metric' ? nextMessage : null;
|
||||
|
||||
// User messages on right, bot messages on left
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex",
|
||||
isUser ? "justify-end" : "justify-start"
|
||||
)}>
|
||||
<div className="flex flex-col gap-1 max-w-[85%]">
|
||||
{/* Show TTFB metric above bot messages */}
|
||||
{ttfbMetric && ttfbMetric.ttfbSeconds !== undefined && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground px-1">
|
||||
<Brain className="h-3 w-3" />
|
||||
<span className="font-medium">Reasoning Delay:</span>
|
||||
<span>{(ttfbMetric.ttfbSeconds * 1000).toFixed(0)}ms</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-2xl text-sm",
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground rounded-br-md"
|
||||
: "bg-muted rounded-bl-md",
|
||||
!message.final && "opacity-70"
|
||||
)}
|
||||
>
|
||||
<div className="whitespace-pre-wrap leading-relaxed">{message.text}</div>
|
||||
{!message.final && (
|
||||
<div className={cn(
|
||||
"text-[10px] mt-1 italic",
|
||||
isUser ? "text-primary-foreground/70" : "text-muted-foreground"
|
||||
)}>
|
||||
speaking...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,12 +17,19 @@ interface UseWebSocketRTCProps {
|
|||
|
||||
export interface FeedbackMessage {
|
||||
id: string;
|
||||
type: 'user-transcription' | 'bot-text' | 'function-call';
|
||||
type: 'user-transcription' | 'bot-text' | 'function-call' | 'node-transition' | 'ttfb-metric';
|
||||
text: string;
|
||||
final?: boolean;
|
||||
timestamp: string;
|
||||
functionName?: string;
|
||||
status?: 'running' | 'completed';
|
||||
// Node transition fields
|
||||
nodeName?: string;
|
||||
previousNode?: string;
|
||||
// TTFB metric fields
|
||||
ttfbSeconds?: number;
|
||||
processor?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: UseWebSocketRTCProps) => {
|
||||
|
|
@ -285,35 +292,26 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
|
|||
case 'rtf-user-transcription': {
|
||||
const transcription = message.payload;
|
||||
setFeedbackMessages(prev => {
|
||||
// Mark last bot message as final (user started speaking)
|
||||
const withBotFinalized = prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.type === 'bot-text' && !m.final
|
||||
? { ...m, final: true }
|
||||
: m
|
||||
// Step 1: Finalize the last bot message (user started speaking)
|
||||
const messagesWithBotFinalized = prev.map((msg, idx) => {
|
||||
const isLastMessage = idx === prev.length - 1;
|
||||
const isUnfinalizedBotMessage = msg.type === 'bot-text' && !msg.final;
|
||||
return isLastMessage && isUnfinalizedBotMessage
|
||||
? { ...msg, final: true }
|
||||
: msg;
|
||||
});
|
||||
|
||||
// Step 2: Remove any previous interim transcription
|
||||
const messagesWithoutInterim = messagesWithBotFinalized.filter(
|
||||
msg => !(msg.type === 'user-transcription' && !msg.final)
|
||||
);
|
||||
|
||||
// For interim transcriptions, replace the last interim
|
||||
if (!transcription.final) {
|
||||
const withoutLastInterim = withBotFinalized.filter(
|
||||
m => !(m.type === 'user-transcription' && !m.final)
|
||||
);
|
||||
return [...withoutLastInterim, {
|
||||
id: `user-${Date.now()}`,
|
||||
type: 'user-transcription',
|
||||
text: transcription.text,
|
||||
final: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
}];
|
||||
}
|
||||
// For final transcriptions, replace interim with final
|
||||
const withoutInterim = withBotFinalized.filter(
|
||||
m => !(m.type === 'user-transcription' && !m.final)
|
||||
);
|
||||
return [...withoutInterim, {
|
||||
// Step 3: Add new transcription (interim or final)
|
||||
return [...messagesWithoutInterim, {
|
||||
id: `user-${Date.now()}`,
|
||||
type: 'user-transcription',
|
||||
text: transcription.text,
|
||||
final: true,
|
||||
final: transcription.final,
|
||||
timestamp: new Date().toISOString(),
|
||||
}];
|
||||
});
|
||||
|
|
@ -381,6 +379,33 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
|
|||
break;
|
||||
}
|
||||
|
||||
case 'rtf-node-transition': {
|
||||
const { node_name, previous_node } = message.payload;
|
||||
setFeedbackMessages(prev => [...prev, {
|
||||
id: `node-${Date.now()}`,
|
||||
type: 'node-transition',
|
||||
text: node_name,
|
||||
nodeName: node_name,
|
||||
previousNode: previous_node,
|
||||
timestamp: new Date().toISOString(),
|
||||
}]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'rtf-ttfb-metric': {
|
||||
const { ttfb_seconds, processor, model } = message.payload;
|
||||
setFeedbackMessages(prev => [...prev, {
|
||||
id: `ttfb-${Date.now()}`,
|
||||
type: 'ttfb-metric',
|
||||
text: `${(ttfb_seconds * 1000).toFixed(0)}ms`,
|
||||
ttfbSeconds: ttfb_seconds,
|
||||
processor,
|
||||
model,
|
||||
timestamp: new Date().toISOString(),
|
||||
}]);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn('Unknown message type:', message.type);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useParams } from 'next/navigation';
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import BrowserCall from '@/app/workflow/[workflowId]/run/[runId]/BrowserCall';
|
||||
import { RealtimeFeedback, WorkflowRunLogs } from '@/app/workflow/[workflowId]/run/[runId]/components/RealtimeFeedback';
|
||||
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
|
||||
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from '@/client/sdk.gen';
|
||||
import { MediaPreviewButtons, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
|
||||
|
|
@ -23,6 +24,7 @@ interface WorkflowRunResponse {
|
|||
recording_url: string | null;
|
||||
initial_context: Record<string, string | number | boolean | object> | null;
|
||||
gathered_context: Record<string, string | number | boolean | object> | null;
|
||||
logs: WorkflowRunLogs | null;
|
||||
}
|
||||
|
||||
function ContextDisplay({ title, context }: { title: string; context: Record<string, string | number | boolean | object> | null }) {
|
||||
|
|
@ -116,6 +118,7 @@ export default function WorkflowRunPage() {
|
|||
recording_url: response.data?.recording_url ?? null,
|
||||
initial_context: response.data?.initial_context as Record<string, string> | null ?? null,
|
||||
gathered_context: response.data?.gathered_context as Record<string, string> | null ?? null,
|
||||
logs: response.data?.logs as WorkflowRunLogs | null ?? null,
|
||||
});
|
||||
};
|
||||
fetchWorkflowRun();
|
||||
|
|
@ -147,8 +150,10 @@ export default function WorkflowRunPage() {
|
|||
}
|
||||
else if (workflowRun?.is_completed) {
|
||||
returnValue = (
|
||||
<div className="h-full flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-4xl space-y-6">
|
||||
<div className="flex h-full w-full">
|
||||
{/* Main content - 2/3 width */}
|
||||
<div className="w-2/3 h-full flex items-center justify-center overflow-y-auto">
|
||||
<div className="w-full max-w-4xl space-y-6 p-6">
|
||||
<Card className="border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -235,17 +240,23 @@ export default function WorkflowRunPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<ContextDisplay
|
||||
title="Initial Context"
|
||||
context={workflowRun?.initial_context}
|
||||
/>
|
||||
<ContextDisplay
|
||||
title="Gathered Context"
|
||||
context={workflowRun?.gathered_context}
|
||||
/>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<ContextDisplay
|
||||
title="Initial Context"
|
||||
context={workflowRun?.initial_context}
|
||||
/>
|
||||
<ContextDisplay
|
||||
title="Gathered Context"
|
||||
context={workflowRun?.gathered_context}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript panel - 1/3 width */}
|
||||
<div className="w-1/3 h-full shrink-0">
|
||||
<RealtimeFeedback mode="historical" logs={workflowRun?.logs} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* Utility to process realtime feedback events into a unified transcript format.
|
||||
* Used by both live WebSocket messages and post-call logs.
|
||||
*/
|
||||
|
||||
export interface TranscriptEvent {
|
||||
type: 'user-transcription' | 'bot-text' | 'function-call' | 'node-transition' | 'ttfb-metric';
|
||||
text: string;
|
||||
final?: boolean;
|
||||
timestamp: string;
|
||||
turn?: number;
|
||||
functionName?: string;
|
||||
status?: 'running' | 'completed';
|
||||
nodeName?: string;
|
||||
previousNode?: string;
|
||||
ttfbSeconds?: number;
|
||||
processor?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface ProcessedMessage {
|
||||
id: string;
|
||||
type: TranscriptEvent['type'];
|
||||
text: string;
|
||||
final?: boolean;
|
||||
timestamp: string;
|
||||
functionName?: string;
|
||||
status?: 'running' | 'completed';
|
||||
nodeName?: string;
|
||||
ttfbSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process transcript events (both live and historical).
|
||||
* Combines consecutive bot-text by turn and associates TTFB metrics.
|
||||
*/
|
||||
export function processTranscriptEvents(events: TranscriptEvent[]): ProcessedMessage[] {
|
||||
// Filter out interim transcriptions and function-call-start events
|
||||
const filteredEvents = events.filter(event => {
|
||||
if (event.type === 'user-transcription' && !event.final) return false;
|
||||
if (event.type === 'function-call' && event.status === 'running') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const processed: ProcessedMessage[] = [];
|
||||
let currentBotText: { event: TranscriptEvent; text: string } | null = null;
|
||||
let pendingTtfb: TranscriptEvent | null = null;
|
||||
|
||||
const flushBotText = () => {
|
||||
if (!currentBotText) return;
|
||||
|
||||
processed.push(convertToProcessedMessage(currentBotText.event, currentBotText.text));
|
||||
|
||||
// Add the pending TTFB metric if it exists
|
||||
if (pendingTtfb) {
|
||||
processed.push(convertToProcessedMessage(pendingTtfb));
|
||||
pendingTtfb = null;
|
||||
}
|
||||
|
||||
currentBotText = null;
|
||||
};
|
||||
|
||||
for (const event of filteredEvents) {
|
||||
if (event.type === 'ttfb-metric') {
|
||||
// Store TTFB to associate with the next bot-text or function-call
|
||||
pendingTtfb = event;
|
||||
} else if (event.type === 'bot-text') {
|
||||
// Combine consecutive bot-text from the same turn
|
||||
if (currentBotText && currentBotText.event.turn === event.turn) {
|
||||
currentBotText.text = currentBotText.text + ' ' + event.text;
|
||||
} else {
|
||||
flushBotText();
|
||||
currentBotText = { event, text: event.text };
|
||||
}
|
||||
} else {
|
||||
// Handle other events (user-transcription, function-call, node-transition)
|
||||
flushBotText();
|
||||
processed.push(convertToProcessedMessage(event));
|
||||
|
||||
// Add pending TTFB after function calls
|
||||
if (event.type === 'function-call' && pendingTtfb) {
|
||||
processed.push(convertToProcessedMessage(pendingTtfb));
|
||||
pendingTtfb = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining bot text
|
||||
flushBotText();
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process live messages - optimized version.
|
||||
*
|
||||
* Optimizations rely on useWebSocketRTC.tsx already handling:
|
||||
* - Bot text accumulation (consecutive chunks combined with spaces)
|
||||
* - Interim transcription filtering (only final transcriptions kept)
|
||||
* - Function call status (start events filtered, only completed kept)
|
||||
*
|
||||
* This function only needs to:
|
||||
* - Associate TTFB metrics with the preceding bot-text or function-call
|
||||
* - Convert to ProcessedMessage format
|
||||
*/
|
||||
export function processLiveMessages(messages: TranscriptEvent[]): ProcessedMessage[] {
|
||||
const processed: ProcessedMessage[] = [];
|
||||
let pendingTtfb: TranscriptEvent | null = null;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'ttfb-metric') {
|
||||
// Store TTFB to associate with next message
|
||||
pendingTtfb = msg;
|
||||
} else {
|
||||
// Add the message
|
||||
processed.push(convertToProcessedMessage(msg));
|
||||
|
||||
// Add pending TTFB after final bot-text or completed function calls
|
||||
if ((msg.type === 'bot-text' && msg.final) ||
|
||||
(msg.type === 'function-call' && msg.status === 'completed')) {
|
||||
if (pendingTtfb) {
|
||||
processed.push(convertToProcessedMessage(pendingTtfb));
|
||||
pendingTtfb = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
// Alias for backward compatibility
|
||||
export const processHistoricalEvents = processTranscriptEvents;
|
||||
|
||||
function convertToProcessedMessage(event: TranscriptEvent, overrideText?: string): ProcessedMessage {
|
||||
return {
|
||||
id: `${event.type}-${event.timestamp}`,
|
||||
type: event.type,
|
||||
text: overrideText ?? event.text,
|
||||
final: event.final ?? true,
|
||||
timestamp: event.timestamp,
|
||||
functionName: event.functionName,
|
||||
status: event.status,
|
||||
nodeName: event.nodeName,
|
||||
ttfbSeconds: event.ttfbSeconds,
|
||||
};
|
||||
}
|
||||
|
|
@ -964,6 +964,9 @@ export type WorkflowRunResponseSchema = {
|
|||
[key: string]: unknown;
|
||||
} | null;
|
||||
call_type: CallType;
|
||||
logs?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type WorkflowRunUsageResponse = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue