refactor: refactor TesterPanel into smaller components

This commit is contained in:
Abhishek Kumar 2026-05-21 13:48:40 +05:30
parent 5d9ae9da6d
commit f1fdc41949
38 changed files with 2062 additions and 1492 deletions

View file

@ -64,13 +64,17 @@ def build_function_call_start_event(
*,
function_name: str | None,
tool_call_id: str | None,
arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"function_name": function_name,
"tool_call_id": tool_call_id,
}
if arguments is not None:
payload["arguments"] = arguments
return {
"type": RealtimeFeedbackType.FUNCTION_CALL_START.value,
"payload": {
"function_name": function_name,
"tool_call_id": tool_call_id,
},
"payload": payload,
}

View file

@ -276,6 +276,7 @@ class RealtimeFeedbackObserver(BaseObserver):
build_function_call_start_event(
function_name=frame.function_name,
tool_call_id=frame.tool_call_id,
arguments=dict(frame.arguments or {}),
)
)
# Handle function call result

View file

@ -80,6 +80,7 @@ def build_text_chat_realtime_feedback_events(
build_function_call_start_event(
function_name=payload.get("function_name"),
tool_call_id=payload.get("tool_call_id"),
arguments=payload.get("arguments"),
),
timestamp=timestamp,
turn=turn_index,

View file

@ -1,28 +1,22 @@
"use client";
import { AlertCircle, Loader2, MessageSquareText, Mic, Pencil, Phone, RefreshCw, RotateCcw, Sparkles, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { Loader2, MessageSquareText, Mic, Phone, RefreshCw, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
appendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPost,
createTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPost,
createWorkflowRunApiV1WorkflowWorkflowIdRunsPost,
rewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPost,
} from "@/client/sdk.gen";
import type { WorkflowRunTextSessionResponse } from "@/client/types.gen";
import { Badge } from "@/components/ui/badge";
import { createWorkflowRunApiV1WorkflowWorkflowIdRunsPost } from "@/client/sdk.gen";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { WORKFLOW_RUN_MODES } from "@/constants/workflowRunModes";
import { useAuth } from "@/lib/auth";
import { cn, getRandomId } from "@/lib/utils";
import { ApiKeyErrorDialog, ConnectionStatus, RealtimeFeedback, WorkflowConfigErrorDialog } from "../run/[runId]/components";
import { useWebSocketRTC } from "../run/[runId]/hooks";
import { AiSimulatorPlaceholder } from "./workflow-tester/AiSimulatorPlaceholder";
import { EmbeddedVoiceTester } from "./workflow-tester/EmbeddedVoiceTester";
import { ManualTextChatPanel } from "./workflow-tester/ManualTextChatPanel";
import { ChatModeToggle, DisabledNotice, EmptyState } from "./workflow-tester/shared";
import { extractSdkErrorMessage, getErrorMessage } from "./workflow-tester/utils";
interface WorkflowTesterPanelProps {
workflowId: number;
@ -33,775 +27,6 @@ interface WorkflowTesterPanelProps {
onClose?: () => void;
}
interface TextChatMessage {
text: string;
created_at: string;
}
interface TextChatTurn {
id: string;
status: string;
created_at: string;
user_message: TextChatMessage | null;
assistant_message: TextChatMessage | null;
events: Array<Record<string, unknown>>;
usage: Record<string, unknown>;
}
interface TextChatSessionData {
version: number;
status: string;
cursor_turn_id: string | null;
turns: TextChatTurn[];
discarded_future: Array<Record<string, unknown>>;
simulator: {
enabled: boolean;
config: Record<string, unknown>;
};
}
interface TextChatCheckpoint {
version: number;
anchor_turn_id: string | null;
current_node_id: string | null;
messages: Array<Record<string, unknown>>;
gathered_context: Record<string, unknown>;
tool_state: Record<string, unknown>;
}
type TextChatSession = Omit<WorkflowRunTextSessionResponse, "session_data" | "checkpoint"> & {
session_data: TextChatSessionData;
checkpoint: TextChatCheckpoint;
};
interface TextChatToolEvent {
kind: "start" | "result";
functionName: string;
resultText?: string;
}
interface TurnActionState {
turnId: string;
type: "rewind" | "edit";
}
const EMPTY_TEXT_CHAT_TURNS: TextChatTurn[] = [];
function toTextChatSession(response: WorkflowRunTextSessionResponse): TextChatSession {
return {
...response,
session_data: response.session_data as unknown as TextChatSessionData,
checkpoint: response.checkpoint as unknown as TextChatCheckpoint,
};
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return "Something went wrong";
}
function extractSdkErrorMessage(error: unknown, fallback: string) {
if (!error) return fallback;
if (typeof error === "string") return error;
if (typeof error === "object") {
const detail = (error as { detail?: unknown }).detail;
if (typeof detail === "string") return detail;
if (detail && typeof detail === "object" && typeof (detail as { message?: unknown }).message === "string") {
return (detail as { message: string }).message;
}
}
return fallback;
}
function DisabledNotice({ reason }: { reason: string }) {
return (
<div className="rounded-lg border border-amber-200/80 bg-amber-50/80 px-3 py-2.5 text-sm text-amber-900 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200">
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-0.5">
<p className="font-medium">Testing is paused</p>
<p className="text-amber-800/90 dark:text-amber-300">{reason}</p>
</div>
</div>
</div>
);
}
function EmptyState({
icon,
title,
description,
action,
}: {
icon: ReactNode;
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-1 flex-col justify-center rounded-xl border border-border/70 bg-background px-5 py-6 text-left">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
{icon}
</div>
<div className="mt-4 space-y-1.5">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
</div>
{action ? <div className="mt-5">{action}</div> : null}
</div>
);
}
function MessageBubble({
role,
text,
state,
}: {
role: "user" | "agent";
text: ReactNode;
state?: "default" | "muted";
}) {
const isUser = role === "user";
const isMuted = state === "muted";
return (
<div className={cn("flex", isUser ? "justify-end" : "justify-start")}>
<div
className={cn(
"max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3.5 py-2 text-sm leading-6",
isUser
? "rounded-br-md bg-primary text-primary-foreground"
: isMuted
? "rounded-bl-md border border-dashed border-border bg-background text-muted-foreground"
: "rounded-bl-md bg-muted text-foreground",
)}
>
{text}
</div>
</div>
);
}
function TypingBubble() {
return (
<div className="flex justify-start">
<div className="rounded-2xl rounded-bl-md bg-muted px-3.5 py-3">
<div className="flex items-center gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60" />
</div>
</div>
</div>
);
}
function stringifyToolResult(result: unknown) {
if (result == null) return "No result";
if (typeof result === "string") return result;
try {
return JSON.stringify(result);
} catch {
return String(result);
}
}
function extractToolEvents(events: Array<Record<string, unknown>>): TextChatToolEvent[] {
return events.reduce<TextChatToolEvent[]>((acc, event) => {
const eventType = event.type;
const payload = event.payload;
if (!payload || typeof payload !== "object") {
return acc;
}
const typedPayload = payload as Record<string, unknown>;
const functionName = typeof typedPayload.function_name === "string"
? typedPayload.function_name
: "tool";
if (eventType === "tool_call_started") {
acc.push({ kind: "start", functionName });
return acc;
}
if (eventType === "tool_call_result") {
acc.push({
kind: "result",
functionName,
resultText: stringifyToolResult(typedPayload.result),
});
return acc;
}
return acc;
}, []);
}
function getReplayCursorTurnId(turns: TextChatTurn[], turnId: string): string | null {
const turnIndex = turns.findIndex((turn) => turn.id === turnId);
if (turnIndex < 0) {
throw new Error("Turn not found");
}
return turns[turnIndex - 1]?.id ?? null;
}
function ToolEventBubble({ event }: { event: TextChatToolEvent }) {
return (
<div className="flex justify-start">
<div className="max-w-[85%] rounded-2xl rounded-bl-md border border-border/70 bg-background px-3.5 py-2 text-sm leading-6 text-foreground">
<div className="flex items-center gap-2">
<Badge variant="outline" className="h-5 px-1.5 text-[10px] uppercase tracking-[0.14em]">
{event.kind === "start" ? "Tool" : "Result"}
</Badge>
<span className="font-mono text-xs text-muted-foreground">
{event.kind === "start"
? `${event.functionName}()`
: `${event.functionName} -> ${event.resultText ?? "No result"}`}
</span>
</div>
</div>
</div>
);
}
function EmbeddedVoiceTester({
workflowId,
workflowRunId,
initialContextVariables,
accessToken,
onReset,
}: {
workflowId: number;
workflowRunId: number;
initialContextVariables?: Record<string, string>;
accessToken: string;
onReset: () => void;
}) {
const router = useRouter();
const {
audioRef,
connectionActive,
permissionError,
isCompleted,
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
apiKeyErrorCode,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
connectionStatus,
start,
stop,
isStarting,
feedbackMessages,
} = useWebSocketRTC({
workflowId,
workflowRunId,
accessToken,
initialContextVariables,
});
const autoStartedRef = useRef(false);
useEffect(() => {
if (autoStartedRef.current) {
return;
}
autoStartedRef.current = true;
void start();
}, [start]);
const endButtonLabel = connectionActive
? "End Call"
: isCompleted
? "Start Another Test"
: connectionStatus === "failed"
? "Retry Call"
: "Starting Test...";
const handleFooterAction = async () => {
if (connectionActive) {
stop();
return;
}
if (isCompleted) {
onReset();
return;
}
if (connectionStatus === "failed") {
await start();
}
};
return (
<>
<div className="min-h-0 flex flex-1 flex-col overflow-hidden rounded-xl border border-border/70 bg-background">
<div className="min-h-0 flex-1 overflow-hidden bg-muted/15">
<RealtimeFeedback
mode="live"
messages={feedbackMessages}
isCallActive={connectionActive}
isCallCompleted={isCompleted}
/>
</div>
<div className="border-t border-border/70 bg-background px-4 py-3">
<div className="flex flex-col gap-3">
<ConnectionStatus connectionStatus={connectionStatus} />
{permissionError ? (
<p className="text-center text-sm text-destructive">{permissionError}</p>
) : null}
<Button
onClick={handleFooterAction}
disabled={isStarting && connectionStatus !== "failed"}
variant={connectionActive ? "destructive" : "default"}
className="w-full"
>
{isStarting && connectionStatus !== "failed" ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting Test...
</>
) : connectionActive ? (
<>
<Phone className="h-4 w-4" />
{endButtonLabel}
</>
) : connectionStatus === "failed" ? (
<>
<RefreshCw className="h-4 w-4" />
{endButtonLabel}
</>
) : isCompleted ? (
<>
<RefreshCw className="h-4 w-4" />
{endButtonLabel}
</>
) : (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{endButtonLabel}
</>
)}
</Button>
</div>
</div>
<audio ref={audioRef} autoPlay playsInline className="hidden" />
</div>
<ApiKeyErrorDialog
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
error={apiKeyError}
errorCode={apiKeyErrorCode}
onNavigateToCredits={() => router.push("/api-keys")}
onNavigateToModelConfig={() => router.push("/model-configurations")}
/>
<WorkflowConfigErrorDialog
open={workflowConfigModalOpen}
onOpenChange={setWorkflowConfigModalOpen}
error={workflowConfigError}
onNavigateToWorkflow={() => router.push(`/workflow/${workflowId}`)}
/>
</>
);
}
function ManualTextChat({
workflowId,
ready,
initialContextVariables,
disabled,
disabledReason,
onActiveChange,
}: {
workflowId: number;
ready: boolean;
initialContextVariables?: Record<string, string>;
disabled: boolean;
disabledReason: string | null;
onActiveChange?: (active: boolean) => void;
}) {
const [session, setSession] = useState<TextChatSession | null>(null);
const [started, setStarted] = useState(false);
const [draft, setDraft] = useState("");
const [creatingSession, setCreatingSession] = useState(false);
const [sendingMessage, setSendingMessage] = useState(false);
const [editingTurnId, setEditingTurnId] = useState<string | null>(null);
const [activeTurnAction, setActiveTurnAction] = useState<TurnActionState | null>(null);
const scrollEndRef = useRef<HTMLDivElement | null>(null);
const turns = session?.session_data.turns ?? EMPTY_TEXT_CHAT_TURNS;
const editingTurn = editingTurnId
? turns.find((turn) => turn.id === editingTurnId) ?? null
: null;
const composerId = `workflow-tester-compose-${workflowId}`;
const createSession = useCallback(async () => {
if (disabled) return;
setCreatingSession(true);
try {
const response = await createTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPost({
path: { workflow_id: workflowId },
body: {
initial_context: initialContextVariables ?? {},
annotations: {
tester: {
source: "workflow_editor",
modality: "text",
ui_mode: "manual_text",
},
},
},
});
if (response.error || !response.data) {
throw new Error(extractSdkErrorMessage(response.error, "Failed to create chat session"));
}
setSession(toTextChatSession(response.data));
setDraft("");
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setCreatingSession(false);
}
}, [disabled, initialContextVariables, workflowId]);
useEffect(() => {
if (!started || creatingSession || session || !ready || disabled) {
return;
}
void createSession();
}, [createSession, creatingSession, disabled, ready, session, started]);
useEffect(() => {
onActiveChange?.(started);
}, [onActiveChange, started]);
const submitMessage = useCallback(async (
messageText: string,
replayOptions?: TurnActionState,
) => {
const trimmedText = messageText.trim();
if (!session || !trimmedText || disabled) return;
setSendingMessage(true);
if (replayOptions) {
setActiveTurnAction(replayOptions);
}
try {
let activeSession = session;
if (replayOptions) {
const rewindResponse = await rewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPost({
path: { workflow_id: workflowId, run_id: activeSession.workflow_run_id },
body: {
cursor_turn_id: getReplayCursorTurnId(
activeSession.session_data.turns,
replayOptions.turnId,
),
expected_revision: activeSession.revision,
},
});
if (rewindResponse.error || !rewindResponse.data) {
throw new Error(extractSdkErrorMessage(rewindResponse.error, "Failed to rewind session"));
}
activeSession = toTextChatSession(rewindResponse.data);
setSession(activeSession);
}
const response = await appendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPost({
path: { workflow_id: workflowId, run_id: activeSession.workflow_run_id },
body: {
text: trimmedText,
expected_revision: activeSession.revision,
},
});
if (response.error || !response.data) {
throw new Error(extractSdkErrorMessage(response.error, "Failed to send message"));
}
setSession(toTextChatSession(response.data));
setDraft("");
setEditingTurnId(null);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setSendingMessage(false);
setActiveTurnAction(null);
}
}, [disabled, session, workflowId]);
const rewindTurn = useCallback(async (turn: TextChatTurn) => {
if (!turn.user_message) return;
await submitMessage(turn.user_message.text, { turnId: turn.id, type: "rewind" });
}, [submitMessage]);
const startEditingTurn = useCallback((turn: TextChatTurn) => {
if (!turn.user_message) return;
const nextText = turn.user_message.text;
setEditingTurnId(turn.id);
setDraft(nextText);
requestAnimationFrame(() => {
const textarea = document.getElementById(composerId) as HTMLTextAreaElement | null;
textarea?.focus();
textarea?.setSelectionRange(nextText.length, nextText.length);
});
}, [composerId]);
const cancelEditingTurn = useCallback(() => {
setEditingTurnId(null);
setDraft("");
}, []);
const submitComposer = useCallback(async () => {
if (editingTurnId) {
await submitMessage(draft, { turnId: editingTurnId, type: "edit" });
return;
}
await submitMessage(draft);
}, [draft, editingTurnId, submitMessage]);
useEffect(() => {
if (!editingTurnId) {
return;
}
if (!turns.some((turn) => turn.id === editingTurnId)) {
setEditingTurnId(null);
setDraft("");
}
}, [editingTurnId, turns]);
useEffect(() => {
scrollEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [session?.revision, sendingMessage, turns.length]);
const inputDisabled = disabled || !session;
if (!started && !session) {
return (
<div className="flex h-full min-h-0 flex-col gap-3">
{disabledReason ? <DisabledNotice reason={disabledReason} /> : null}
<EmptyState
icon={<MessageSquareText className="h-7 w-7" />}
title="Chat with this agent"
description="Test the agent over a text conversation. Send messages and see how it responds, with tool calls and rewind support."
action={
<Button onClick={() => setStarted(true)} disabled={disabled || !ready}>
<MessageSquareText className="h-4 w-4" />
Start Test
</Button>
}
/>
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
{disabledReason ? (
<div className="pb-3">
<DisabledNotice reason={disabledReason} />
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto">
{creatingSession && !session ? (
<div className="space-y-3 py-1">
<Skeleton className="ml-auto h-9 w-2/3 rounded-2xl" />
<Skeleton className="h-12 w-3/4 rounded-2xl" />
</div>
) : turns.length === 0 ? (
<div className="flex h-full items-center justify-center px-4 py-10 text-center">
<p className="text-sm text-muted-foreground">
{disabled
? (disabledReason ?? "Testing is paused.")
: "Send a message to start the conversation."}
</p>
</div>
) : (
<div className="space-y-3 py-1">
{turns.map((turn) => {
const toolEvents = extractToolEvents(turn.events);
const rewindingThisTurn = activeTurnAction?.turnId === turn.id && activeTurnAction.type === "rewind";
const rerunningEditedTurn = activeTurnAction?.turnId === turn.id && activeTurnAction.type === "edit";
return (
<div key={turn.id} className="group space-y-1.5">
{turn.user_message ? (
<div className="space-y-1">
<MessageBubble role="user" text={turn.user_message.text} />
<div className="flex h-5 items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<button
type="button"
onClick={() => void rewindTurn(turn)}
disabled={disabled || sendingMessage}
aria-label="Rerun this turn"
title="Rerun this turn"
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
>
{rewindingThisTurn ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onClick={() => startEditingTurn(turn)}
disabled={disabled || sendingMessage}
aria-label="Edit and rerun this turn"
title="Edit and rerun this turn"
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50",
editingTurnId === turn.id && "bg-muted text-foreground",
)}
>
{rerunningEditedTurn ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Pencil className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
) : null}
{toolEvents.map((event, index) => (
<ToolEventBubble
key={`${turn.id}-${event.kind}-${event.functionName}-${index}`}
event={event}
/>
))}
{turn.assistant_message ? (
<MessageBubble role="agent" text={turn.assistant_message.text} />
) : turn.status === "failed" ? (
<MessageBubble role="agent" state="muted" text="Agent turn failed" />
) : null}
</div>
);
})}
{sendingMessage ? <TypingBubble /> : null}
<div ref={scrollEndRef} />
</div>
)}
</div>
<div className="pt-3">
{editingTurn ? (
<div className="mb-2 flex items-center justify-between gap-2 rounded-lg border border-border/70 bg-muted/35 px-3 py-2 text-xs text-muted-foreground">
<span>Edit the selected user message, then press Enter to rerun from that point.</span>
<button
type="button"
onClick={cancelEditingTurn}
className="inline-flex items-center gap-1 rounded text-foreground hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<X className="h-3.5 w-3.5" />
Cancel
</button>
</div>
) : null}
<div className="relative">
<Textarea
id={composerId}
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={ready ? (editingTurn ? "Edit and rerun this message…" : "Send a message…") : "Preparing chat…"}
rows={1}
className="min-h-11! resize-none pr-20 text-sm leading-6"
disabled={inputDisabled}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (sendingMessage) return;
void submitComposer();
}
}}
/>
<Button
type="button"
size="sm"
onClick={() => void submitComposer()}
disabled={inputDisabled || sendingMessage || !draft.trim()}
className="absolute bottom-1.5 right-1.5 h-8 px-4"
>
{sendingMessage ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{editingTurn ? "Rerunning" : "Sending"}
</>
) : (
editingTurn ? "Rerun" : "Send"
)}
</Button>
</div>
</div>
</div>
);
}
function AiSimulatorPlaceholder({
disabledReason,
}: {
disabledReason: string | null;
}) {
const [simulatorPrompt, setSimulatorPrompt] = useState(
"Act like a skeptical prospect. Push on pricing, ask about integrations, and end the chat if the assistant becomes repetitive."
);
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{disabledReason ? <DisabledNotice reason={disabledReason} /> : null}
<p className="text-sm text-muted-foreground">
Drive multi-turn, agent-vs-agent tests with a persona prompt.
</p>
<Textarea
value={simulatorPrompt}
onChange={(event) => setSimulatorPrompt(event.target.value)}
placeholder="Describe the simulated user…"
className="min-h-32 resize-none text-sm leading-6"
/>
<Button size="sm" disabled className="self-start">
<Sparkles className="h-4 w-4" />
Coming soon
</Button>
</div>
);
}
function ChatModeToggle({
value,
onChange,
}: {
value: "manual" | "simulated";
onChange: (next: "manual" | "simulated") => void;
}) {
const options: Array<{ id: "manual" | "simulated"; label: string }> = [
{ id: "manual", label: "Manual" },
{ id: "simulated", label: "Simulated" },
];
return (
<div className="inline-flex items-center gap-0.5 rounded-md border border-border/70 bg-muted/40 p-0.5">
{options.map((option) => {
const active = option.id === value;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={cn(
"rounded-[5px] px-2.5 py-1 text-xs font-medium transition",
active
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
);
})}
</div>
);
}
export function WorkflowTesterPanel({
workflowId,
initialContextVariables,
@ -930,7 +155,9 @@ export function WorkflowTesterPanel({
<Skeleton className="h-80 rounded-xl" />
</div>
) : !accessToken ? (
<DisabledNotice reason={authUnavailableReason ?? "Authentication is required before browser tests can start."} />
<DisabledNotice
reason={authUnavailableReason ?? "Authentication is required before browser tests can start."}
/>
) : voiceRunId ? (
<EmbeddedVoiceTester
workflowId={workflowId}
@ -945,7 +172,7 @@ export function WorkflowTesterPanel({
<EmptyState
icon={<Phone className="h-7 w-7" />}
title="Call this agent in the browser"
description="Test the Agent over a Voice Call. Some tools which work over telephony, like Transfer Calls are not yet supported."
description="Test the agent over a voice call. Some telephony-only tools, like call transfer, are not yet supported here."
action={
<Button onClick={createVoiceRun} disabled={creatingVoiceRun || testerBlocked}>
{creatingVoiceRun ? (
@ -975,7 +202,7 @@ export function WorkflowTesterPanel({
<Button
variant="ghost"
size="sm"
onClick={() => setChatSessionKey((k) => k + 1)}
onClick={() => setChatSessionKey((value) => value + 1)}
disabled={testerBlocked}
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
@ -986,7 +213,7 @@ export function WorkflowTesterPanel({
</div>
{chatMode === "manual" ? (
<ManualTextChat
<ManualTextChatPanel
key={chatSessionKey}
workflowId={workflowId}
ready={tokenReady && !!accessToken}

View file

@ -0,0 +1,38 @@
"use client";
import { Sparkles } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { DisabledNotice } from "./shared";
export function AiSimulatorPlaceholder({
disabledReason,
}: {
disabledReason: string | null;
}) {
const [simulatorPrompt, setSimulatorPrompt] = useState(
"Act like a skeptical prospect. Push on pricing, ask about integrations, and end the chat if the assistant becomes repetitive.",
);
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{disabledReason ? <DisabledNotice reason={disabledReason} /> : null}
<p className="text-sm text-muted-foreground">
Drive multi-turn, agent-vs-agent tests with a persona prompt.
</p>
<Textarea
value={simulatorPrompt}
onChange={(event) => setSimulatorPrompt(event.target.value)}
placeholder="Describe the simulated user..."
className="min-h-32 resize-none text-sm leading-6"
/>
<Button size="sm" disabled className="self-start">
<Sparkles className="h-4 w-4" />
Coming soon
</Button>
</div>
);
}

View file

@ -0,0 +1,82 @@
"use client";
import { Loader2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
interface ChatComposerProps {
composerId: string;
draft: string;
ready: boolean;
editing: boolean;
sendingMessage: boolean;
inputDisabled: boolean;
onDraftChange: (value: string) => void;
onCancelEditing: () => void;
onSubmit: () => Promise<void> | void;
}
export function ChatComposer({
composerId,
draft,
ready,
editing,
sendingMessage,
inputDisabled,
onDraftChange,
onCancelEditing,
onSubmit,
}: ChatComposerProps) {
return (
<div className="pt-3">
{editing ? (
<div className="mb-2 flex items-center justify-between gap-2 rounded-lg border border-border/70 bg-muted/35 px-3 py-2 text-xs text-muted-foreground">
<span>Edit the selected user message, then press Enter to rerun from that point.</span>
<button
type="button"
onClick={onCancelEditing}
className="inline-flex items-center gap-1 rounded text-foreground hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<X className="h-3.5 w-3.5" />
Cancel
</button>
</div>
) : null}
<div className="relative">
<Textarea
id={composerId}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
placeholder={ready ? (editing ? "Edit and rerun this message..." : "Send a message...") : "Preparing chat..."}
rows={1}
className="min-h-11! resize-none pr-20 text-sm leading-6"
disabled={inputDisabled}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (sendingMessage) return;
void onSubmit();
}
}}
/>
<Button
type="button"
size="sm"
onClick={() => void onSubmit()}
disabled={inputDisabled || sendingMessage || !draft.trim()}
className="absolute bottom-1.5 right-1.5 h-8 px-4"
>
{sendingMessage ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{editing ? "Rerunning" : "Sending"}
</>
) : (
editing ? "Rerun" : "Send"
)}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,158 @@
"use client";
import { Loader2, Phone, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { RealtimeFeedback } from "@/components/workflow/conversation";
import { ApiKeyErrorDialog, ConnectionStatus, WorkflowConfigErrorDialog } from "../../run/[runId]/components";
import { useWebSocketRTC } from "../../run/[runId]/hooks";
interface EmbeddedVoiceTesterProps {
workflowId: number;
workflowRunId: number;
initialContextVariables?: Record<string, string>;
accessToken: string;
onReset: () => void;
}
export function EmbeddedVoiceTester({
workflowId,
workflowRunId,
initialContextVariables,
accessToken,
onReset,
}: EmbeddedVoiceTesterProps) {
const router = useRouter();
const {
audioRef,
connectionActive,
permissionError,
isCompleted,
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
apiKeyErrorCode,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
connectionStatus,
start,
stop,
isStarting,
feedbackMessages,
} = useWebSocketRTC({
workflowId,
workflowRunId,
accessToken,
initialContextVariables,
});
const autoStartedRef = useRef(false);
useEffect(() => {
if (autoStartedRef.current) {
return;
}
autoStartedRef.current = true;
void start();
}, [start]);
const endButtonLabel = connectionActive
? "End Call"
: isCompleted
? "Start Another Test"
: connectionStatus === "failed"
? "Retry Call"
: "Starting Test...";
const handleFooterAction = async () => {
if (connectionActive) {
stop();
return;
}
if (isCompleted) {
onReset();
return;
}
if (connectionStatus === "failed") {
await start();
}
};
return (
<>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border/70 bg-background">
<div className="min-h-0 flex-1 overflow-hidden bg-muted/15">
<RealtimeFeedback
mode="live"
messages={feedbackMessages}
isCallActive={connectionActive}
isCallCompleted={isCompleted}
/>
</div>
<div className="border-t border-border/70 bg-background px-4 py-3">
<div className="flex flex-col gap-3">
<ConnectionStatus connectionStatus={connectionStatus} />
{permissionError ? (
<p className="text-center text-sm text-destructive">{permissionError}</p>
) : null}
<Button
onClick={handleFooterAction}
disabled={isStarting && connectionStatus !== "failed"}
variant={connectionActive ? "destructive" : "default"}
className="w-full"
>
{isStarting && connectionStatus !== "failed" ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting Test...
</>
) : connectionActive ? (
<>
<Phone className="h-4 w-4" />
{endButtonLabel}
</>
) : connectionStatus === "failed" ? (
<>
<RefreshCw className="h-4 w-4" />
{endButtonLabel}
</>
) : isCompleted ? (
<>
<RefreshCw className="h-4 w-4" />
{endButtonLabel}
</>
) : (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{endButtonLabel}
</>
)}
</Button>
</div>
</div>
<audio ref={audioRef} autoPlay playsInline className="hidden" />
</div>
<ApiKeyErrorDialog
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
error={apiKeyError}
errorCode={apiKeyErrorCode}
onNavigateToCredits={() => router.push("/api-keys")}
onNavigateToModelConfig={() => router.push("/model-configurations")}
/>
<WorkflowConfigErrorDialog
open={workflowConfigModalOpen}
onOpenChange={setWorkflowConfigModalOpen}
error={workflowConfigError}
onNavigateToWorkflow={() => router.push(`/workflow/${workflowId}`)}
/>
</>
);
}

View file

@ -0,0 +1,141 @@
"use client";
import { Skeleton } from "@/components/ui/skeleton";
import type { ConversationItem } from "@/components/workflow/conversation";
import { ConversationTimeline } from "@/components/workflow/conversation";
import { ChatComposer } from "./ChatComposer";
import { DisabledNotice, ManualChatEmptyState, TypingIndicator } from "./shared";
import { TurnMessageActions } from "./TurnMessageActions";
import { useTextChatSession } from "./useTextChatSession";
interface ManualTextChatPanelProps {
workflowId: number;
ready: boolean;
initialContextVariables?: Record<string, string>;
disabled: boolean;
disabledReason: string | null;
onActiveChange?: (active: boolean) => void;
}
export function ManualTextChatPanel({
workflowId,
ready,
initialContextVariables,
disabled,
disabledReason,
onActiveChange,
}: ManualTextChatPanelProps) {
const {
session,
started,
draft,
turns,
editingTurn,
editingTurnId,
creatingSession,
sendingMessage,
activeTurnAction,
composerId,
inputDisabled,
conversationItems,
setDraft,
startSession,
rewindTurn,
startEditingTurn,
cancelEditingTurn,
submitComposer,
} = useTextChatSession({
workflowId,
ready,
initialContextVariables,
disabled,
onActiveChange,
});
if (!started && !session) {
return (
<div className="flex h-full min-h-0 flex-col gap-3">
{disabledReason ? <DisabledNotice reason={disabledReason} /> : null}
<ManualChatEmptyState disabled={disabled} ready={ready} onStart={startSession} />
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
{disabledReason ? (
<div className="pb-3">
<DisabledNotice reason={disabledReason} />
</div>
) : null}
<div className="min-h-0 flex-1">
{creatingSession && !session ? (
<div className="space-y-3 py-1">
<Skeleton className="ml-auto h-9 w-2/3 rounded-2xl" />
<Skeleton className="h-12 w-3/4 rounded-2xl" />
</div>
) : turns.length === 0 ? (
<div className="flex h-full items-center justify-center px-4 py-10 text-center">
<p className="text-sm text-muted-foreground">
{disabled
? (disabledReason ?? "Testing is paused.")
: "Send a message to start the conversation."}
</p>
</div>
) : (
<ConversationTimeline
items={conversationItems}
autoScroll={true}
scrollBehavior="smooth"
emptyState={{
title: "No conversation recorded",
subtitle: "Send a message to start the conversation.",
}}
pendingIndicator={sendingMessage ? <TypingIndicator /> : null}
className="py-1"
renderItemActions={(item: ConversationItem) => {
if (item.kind !== "message" || item.role !== "user" || !item.turnId) {
return null;
}
const turn = turns.find((candidate) => candidate.id === item.turnId);
if (!turn?.user_message) {
return null;
}
const rewindingThisTurn =
activeTurnAction?.turnId === turn.id && activeTurnAction.type === "rewind";
const rerunningEditedTurn =
activeTurnAction?.turnId === turn.id && activeTurnAction.type === "edit";
return (
<TurnMessageActions
disabled={disabled || sendingMessage}
editing={editingTurnId === turn.id}
rewinding={rewindingThisTurn}
rerunningEdit={rerunningEditedTurn}
onRewind={() => void rewindTurn(turn)}
onEdit={() => startEditingTurn(turn)}
/>
);
}}
/>
)}
</div>
<ChatComposer
composerId={composerId}
draft={draft}
ready={ready}
editing={!!editingTurn}
sendingMessage={sendingMessage}
inputDisabled={inputDisabled}
onDraftChange={setDraft}
onCancelEditing={cancelEditingTurn}
onSubmit={submitComposer}
/>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { Loader2, Pencil, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
interface TurnMessageActionsProps {
disabled: boolean;
editing: boolean;
rewinding: boolean;
rerunningEdit: boolean;
onRewind: () => void;
onEdit: () => void;
}
export function TurnMessageActions({
disabled,
editing,
rewinding,
rerunningEdit,
onRewind,
onEdit,
}: TurnMessageActionsProps) {
return (
<>
<button
type="button"
onClick={onRewind}
disabled={disabled}
aria-label="Rerun this turn"
title="Rerun this turn"
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
>
{rewinding ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onClick={onEdit}
disabled={disabled}
aria-label="Edit and rerun this turn"
title="Edit and rerun this turn"
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50",
editing && "bg-muted text-foreground",
)}
>
{rerunningEdit ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Pencil className="h-3.5 w-3.5" />
)}
</button>
</>
);
}

View file

@ -0,0 +1,120 @@
"use client";
import { AlertCircle, MessageSquareText } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function DisabledNotice({ reason }: { reason: string }) {
return (
<div className="rounded-lg border border-amber-200/80 bg-amber-50/80 px-3 py-2.5 text-sm text-amber-900 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200">
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-0.5">
<p className="font-medium">Testing is paused</p>
<p className="text-amber-800/90 dark:text-amber-300">{reason}</p>
</div>
</div>
</div>
);
}
export function EmptyState({
icon,
title,
description,
action,
}: {
icon: ReactNode;
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-1 flex-col justify-center rounded-xl border border-border/70 bg-background px-5 py-6 text-left">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
{icon}
</div>
<div className="mt-4 space-y-1.5">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
</div>
{action ? <div className="mt-5">{action}</div> : null}
</div>
);
}
export function ChatModeToggle({
value,
onChange,
}: {
value: "manual" | "simulated";
onChange: (next: "manual" | "simulated") => void;
}) {
const options: Array<{ id: "manual" | "simulated"; label: string }> = [
{ id: "manual", label: "Manual" },
{ id: "simulated", label: "Simulated" },
];
return (
<div className="inline-flex items-center gap-0.5 rounded-md border border-border/70 bg-muted/40 p-0.5">
{options.map((option) => {
const active = option.id === value;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={cn(
"rounded-[5px] px-2.5 py-1 text-xs font-medium transition",
active
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
);
})}
</div>
);
}
export function TypingIndicator() {
return (
<div className="flex justify-start">
<div className="rounded-2xl rounded-bl-md bg-muted px-3.5 py-3">
<div className="flex items-center gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60" />
</div>
</div>
</div>
);
}
export function ManualChatEmptyState({
disabled,
ready,
onStart,
}: {
disabled: boolean;
ready: boolean;
onStart: () => void;
}) {
return (
<EmptyState
icon={<MessageSquareText className="h-7 w-7" />}
title="Chat with this agent"
description="Test the agent over a text conversation. Send messages and see how it responds, with tool calls, transitions, and rewind support."
action={
<Button onClick={onStart} disabled={disabled || !ready}>
<MessageSquareText className="h-4 w-4" />
Start Test
</Button>
}
/>
);
}

View file

@ -0,0 +1,57 @@
import type { WorkflowRunTextSessionResponse } from "@/client/types.gen";
export interface TextChatMessage {
text: string;
created_at: string;
}
export interface TextChatTurn {
id: string;
status: string;
created_at: string;
user_message: TextChatMessage | null;
assistant_message: TextChatMessage | null;
events: Array<Record<string, unknown>>;
usage: Record<string, unknown>;
}
export interface TextChatSessionData {
version: number;
status: string;
cursor_turn_id: string | null;
turns: TextChatTurn[];
discarded_future: Array<Record<string, unknown>>;
simulator: {
enabled: boolean;
config: Record<string, unknown>;
};
}
export interface TextChatCheckpoint {
version: number;
anchor_turn_id: string | null;
current_node_id: string | null;
messages: Array<Record<string, unknown>>;
gathered_context: Record<string, unknown>;
tool_state: Record<string, unknown>;
}
export type TextChatSession = Omit<WorkflowRunTextSessionResponse, "session_data" | "checkpoint"> & {
session_data: TextChatSessionData;
checkpoint: TextChatCheckpoint;
};
export interface TurnActionState {
turnId: string;
type: "rewind" | "edit";
}
export const EMPTY_TEXT_CHAT_TURNS: TextChatTurn[] = [];
export function toTextChatSession(response: WorkflowRunTextSessionResponse): TextChatSession {
return {
...response,
session_data: response.session_data as unknown as TextChatSessionData,
checkpoint: response.checkpoint as unknown as TextChatCheckpoint,
};
}

View file

@ -0,0 +1,208 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
appendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPost,
createTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPost,
rewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPost,
} from "@/client/sdk.gen";
import { conversationItemsFromTextChatTurns } from "@/components/workflow/conversation/adapters/fromTextChatTurns";
import {
EMPTY_TEXT_CHAT_TURNS,
type TextChatSession,
type TextChatTurn,
toTextChatSession,
type TurnActionState,
} from "./types";
import { extractSdkErrorMessage, getErrorMessage, getReplayCursorTurnId } from "./utils";
interface UseTextChatSessionProps {
workflowId: number;
ready: boolean;
initialContextVariables?: Record<string, string>;
disabled: boolean;
onActiveChange?: (active: boolean) => void;
}
export function useTextChatSession({
workflowId,
ready,
initialContextVariables,
disabled,
onActiveChange,
}: UseTextChatSessionProps) {
const [session, setSession] = useState<TextChatSession | null>(null);
const [started, setStarted] = useState(false);
const [draft, setDraft] = useState("");
const [creatingSession, setCreatingSession] = useState(false);
const [sendingMessage, setSendingMessage] = useState(false);
const [editingTurnId, setEditingTurnId] = useState<string | null>(null);
const [activeTurnAction, setActiveTurnAction] = useState<TurnActionState | null>(null);
const turns = session?.session_data.turns ?? EMPTY_TEXT_CHAT_TURNS;
const editingTurn = editingTurnId
? turns.find((turn) => turn.id === editingTurnId) ?? null
: null;
const composerId = `workflow-tester-compose-${workflowId}`;
const conversationItems = conversationItemsFromTextChatTurns(turns);
const createSession = useCallback(async () => {
if (disabled) return;
setCreatingSession(true);
try {
const response = await createTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPost({
path: { workflow_id: workflowId },
body: {
initial_context: initialContextVariables ?? {},
annotations: {
tester: {
source: "workflow_editor",
modality: "text",
ui_mode: "manual_text",
},
},
},
});
if (response.error || !response.data) {
throw new Error(extractSdkErrorMessage(response.error, "Failed to create chat session"));
}
setSession(toTextChatSession(response.data));
setDraft("");
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setCreatingSession(false);
}
}, [disabled, initialContextVariables, workflowId]);
useEffect(() => {
if (!started || creatingSession || session || !ready || disabled) {
return;
}
void createSession();
}, [createSession, creatingSession, disabled, ready, session, started]);
useEffect(() => {
onActiveChange?.(started);
}, [onActiveChange, started]);
useEffect(() => {
if (!editingTurnId) {
return;
}
if (!turns.some((turn) => turn.id === editingTurnId)) {
setEditingTurnId(null);
setDraft("");
}
}, [editingTurnId, turns]);
const submitMessage = useCallback(async (messageText: string, replayOptions?: TurnActionState) => {
const trimmedText = messageText.trim();
if (!session || !trimmedText || disabled) return;
setSendingMessage(true);
if (replayOptions) {
setActiveTurnAction(replayOptions);
}
try {
let activeSession = session;
if (replayOptions) {
const rewindResponse = await rewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPost({
path: { workflow_id: workflowId, run_id: activeSession.workflow_run_id },
body: {
cursor_turn_id: getReplayCursorTurnId(activeSession.session_data.turns, replayOptions.turnId),
expected_revision: activeSession.revision,
},
});
if (rewindResponse.error || !rewindResponse.data) {
throw new Error(extractSdkErrorMessage(rewindResponse.error, "Failed to rewind session"));
}
activeSession = toTextChatSession(rewindResponse.data);
setSession(activeSession);
}
const response = await appendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPost({
path: { workflow_id: workflowId, run_id: activeSession.workflow_run_id },
body: {
text: trimmedText,
expected_revision: activeSession.revision,
},
});
if (response.error || !response.data) {
throw new Error(extractSdkErrorMessage(response.error, "Failed to send message"));
}
setSession(toTextChatSession(response.data));
setDraft("");
setEditingTurnId(null);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setSendingMessage(false);
setActiveTurnAction(null);
}
}, [disabled, session, workflowId]);
const rewindTurn = useCallback(async (turn: TextChatTurn) => {
if (!turn.user_message) return;
await submitMessage(turn.user_message.text, { turnId: turn.id, type: "rewind" });
}, [submitMessage]);
const startEditingTurn = useCallback((turn: TextChatTurn) => {
if (!turn.user_message) return;
const nextText = turn.user_message.text;
setEditingTurnId(turn.id);
setDraft(nextText);
requestAnimationFrame(() => {
const textarea = document.getElementById(composerId) as HTMLTextAreaElement | null;
textarea?.focus();
textarea?.setSelectionRange(nextText.length, nextText.length);
});
}, [composerId]);
const cancelEditingTurn = useCallback(() => {
setEditingTurnId(null);
setDraft("");
}, []);
const submitComposer = useCallback(async () => {
if (editingTurnId) {
await submitMessage(draft, { turnId: editingTurnId, type: "edit" });
return;
}
await submitMessage(draft);
}, [draft, editingTurnId, submitMessage]);
return {
session,
started,
draft,
turns,
editingTurn,
editingTurnId,
creatingSession,
sendingMessage,
activeTurnAction,
composerId,
inputDisabled: disabled || !session,
conversationItems,
setDraft,
startSession: () => setStarted(true),
rewindTurn,
startEditingTurn,
cancelEditingTurn,
submitComposer,
};
}

View file

@ -0,0 +1,29 @@
export function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return "Something went wrong";
}
export function extractSdkErrorMessage(error: unknown, fallback: string) {
if (!error) return fallback;
if (typeof error === "string") return error;
if (typeof error === "object") {
const detail = (error as { detail?: unknown }).detail;
if (typeof detail === "string") return detail;
if (
detail &&
typeof detail === "object" &&
typeof (detail as { message?: unknown }).message === "string"
) {
return (detail as { message: string }).message;
}
}
return fallback;
}
export function getReplayCursorTurnId(turns: Array<{ id: string }>, turnId: string) {
const turnIndex = turns.findIndex((turn) => turn.id === turnId);
if (turnIndex < 0) {
throw new Error("Turn not found");
}
return turns[turnIndex - 1]?.id ?? null;
}

View file

@ -4,16 +4,15 @@ import { useEffect, useState } from "react";
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from "@/client/sdk.gen";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ConversationRailFrame, RealtimeFeedback } from "@/components/workflow/conversation";
import { useAuth } from "@/lib/auth";
import {
ApiKeyErrorDialog,
AudioControls,
ConnectionStatus,
RealtimeFeedback,
WorkflowConfigErrorDialog
} from "./components";
import { TranscriptRailFrame } from "./components/shared/TranscriptRailFrame";
import { useWebSocketRTC } from "./hooks";
const RUN_SHELL_HEIGHT_CLASS = "h-[calc(100svh-49px)] min-h-[calc(100svh-49px)] max-h-[calc(100svh-49px)]";
@ -154,14 +153,14 @@ const BrowserCall = ({ workflowId, workflowRunId, initialContextVariables }: {
</div>
<div className="h-full min-h-0 w-[420px] shrink-0 border-l border-border bg-background p-5">
<TranscriptRailFrame className="h-full">
<ConversationRailFrame className="h-full">
<RealtimeFeedback
mode="live"
messages={feedbackMessages}
isCallActive={connectionActive}
isCallCompleted={isCompleted}
/>
</TranscriptRailFrame>
</ConversationRailFrame>
</div>
</div>

View file

@ -1,174 +0,0 @@
'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;
allow_interrupt?: boolean;
ttfb_seconds?: number;
processor?: string;
model?: string;
error?: string;
fatal?: boolean;
};
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;
case 'rtf-pipeline-error':
type = 'pipeline-error';
break;
case 'rtf-interrupt-warning':
type = 'interrupt-warning';
break;
default:
type = 'bot-text';
}
return {
type,
text: event.payload.text || event.payload.error || 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,
allowInterrupt: event.payload.allow_interrupt,
ttfbSeconds: event.payload.ttfb_seconds,
processor: event.payload.processor,
model: event.payload.model,
fatal: event.payload.fatal,
};
});
}
/**
* 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,
allowInterrupt: msg.allowInterrupt,
ttfbSeconds: msg.ttfbSeconds,
processor: msg.processor,
model: msg.model,
fatal: msg.fatal,
}));
}
/**
* 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"
}}
/>
);
};

View file

@ -1,98 +0,0 @@
"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,
allowInterrupt: msg.allowInterrupt,
ttfbSeconds: msg.ttfbSeconds,
fatal: msg.fatal,
}));
// 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}-${index}`}
message={msg}
nextMessage={transcriptMessages[index + 1]}
/>
);
})}
</div>
)}
</div>
</TranscriptContainer>
);
};

View file

@ -2,5 +2,4 @@ export * from './ApiKeyErrorDialog';
export * from './AudioControls';
export * from './ConnectionStatus';
export * from './ContextDisplay';
export * from './RealtimeFeedback';
export * from './WorkflowConfigErrorDialog';

View file

@ -1,20 +0,0 @@
'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>
);
}

View file

@ -1,154 +0,0 @@
'use client';
import { AlertTriangle, Brain, ExternalLink, GitBranch, MicOff, 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' | 'pipeline-error' | 'interrupt-warning';
text: string;
final?: boolean;
functionName?: string;
nodeName?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
fatal?: boolean;
}
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="inline-flex items-center gap-1.5 rounded-full border border-blue-500/20 bg-blue-500/10 px-3 py-1 text-xs">
<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>
);
}
// Interrupt warning - show as an amber alert (one-time)
if (message.type === 'interrupt-warning') {
return (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<MicOff className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-amber-700 dark:text-amber-400">
Interruption Disabled
</div>
<div className="text-sm text-amber-600 dark:text-amber-300 mt-0.5">
{message.text}
</div>
<a
href="https://docs.dograh.com/configurations/interruption"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 hover:underline mt-1"
>
Learn more <ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
);
}
// Pipeline error - show as a red alert
if (message.type === 'pipeline-error') {
return (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20">
<AlertTriangle className="h-4 w-4 text-red-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-red-700 dark:text-red-400">
{message.fatal ? 'Fatal Pipeline Error' : 'Pipeline Error'}
</div>
<div className="text-sm text-red-600 dark:text-red-300 mt-0.5 break-words">
{message.text}
</div>
</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="inline-flex items-center gap-2 rounded-full border border-amber-500/20 bg-amber-500/10 px-3 py-1.5 text-xs">
<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 max-w-[85%] flex-col gap-1">
{/* 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(
"rounded-2xl px-4 py-3 text-sm shadow-sm",
isUser
? "bg-primary text-primary-foreground rounded-br-md"
: "bg-muted rounded-bl-md border border-slate-200/80",
!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>
);
}

View file

@ -1,40 +0,0 @@
'use client';
import { type ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface TranscriptRailFrameProps {
children: ReactNode;
className?: string;
header?: ReactNode;
footer?: ReactNode;
}
export function TranscriptRailFrame({
children,
className,
header,
footer,
}: TranscriptRailFrameProps) {
return (
<div className={cn(
'min-h-0 flex h-full flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm',
className,
)}>
{header ? (
<div className="shrink-0 border-b border-border px-4 py-3">
{header}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-hidden">
{children}
</div>
{footer ? (
<div className="shrink-0 border-t border-border px-4 py-3">
{footer}
</div>
) : null}
</div>
);
}

View file

@ -4,6 +4,7 @@ import { client } from "@/client/client.gen";
import { getTurnCredentialsApiV1TurnCredentialsGet, validateUserConfigurationsApiV1UserConfigurationsUserValidateGet, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from "@/client/sdk.gen";
import { TurnCredentialsResponse } from "@/client/types.gen";
import { WorkflowValidationError } from "@/components/flow/types";
import type { RealtimeFeedbackMessage as FeedbackMessage } from "@/components/workflow/conversation";
import { useAppConfig } from "@/context/AppConfigContext";
import logger from '@/lib/logger';
@ -17,26 +18,6 @@ interface UseWebSocketRTCProps {
initialContextVariables?: Record<string, string> | null;
}
export interface FeedbackMessage {
id: string;
type: 'user-transcription' | 'bot-text' | 'function-call' | 'node-transition' | 'ttfb-metric' | 'pipeline-error' | 'interrupt-warning';
text: string;
final?: boolean;
timestamp: string;
functionName?: string;
status?: 'running' | 'completed';
// Node transition fields
nodeName?: string;
previousNode?: string;
allowInterrupt?: boolean;
// TTFB metric fields
ttfbSeconds?: number;
processor?: string;
model?: string;
// Pipeline error fields
fatal?: boolean;
}
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: UseWebSocketRTCProps) => {
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected' | 'failed'>('idle');
const [connectionActive, setConnectionActive] = useState(false);
@ -379,18 +360,22 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
}
case 'rtf-function-call-start': {
const { function_name, tool_call_id } = message.payload;
const { function_name, tool_call_id, arguments: toolArguments } = message.payload;
setFeedbackMessages(prev => {
// Check if we already have this function call
const existingId = `func-${tool_call_id}`;
const existingId = tool_call_id
? `func-${tool_call_id}`
: `func-${Date.now()}`;
if (prev.some(msg => msg.id === existingId)) {
return prev;
}
return [...prev, {
id: existingId,
type: 'function-call',
text: function_name,
functionName: function_name,
text: function_name ?? 'tool',
functionName: function_name ?? 'tool',
toolCallId: tool_call_id,
arguments: toolArguments,
status: 'running',
timestamp: new Date().toISOString(),
}];
@ -402,7 +387,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const { tool_call_id, result } = message.payload;
setFeedbackMessages(prev => prev.map(msg =>
msg.id === `func-${tool_call_id}`
? { ...msg, status: 'completed' as const, text: result || msg.text }
? { ...msg, status: 'completed' as const, text: result || msg.text, result }
: msg
));
break;

View file

@ -7,8 +7,6 @@ import posthog from 'posthog-js';
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 { TranscriptRailFrame } from '@/app/workflow/[workflowId]/run/[runId]/components/shared/TranscriptRailFrame';
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
import {
createWorkflowRunApiV1WorkflowWorkflowIdRunsPost,
@ -19,6 +17,7 @@ import { OnboardingTooltip } from '@/components/onboarding/OnboardingTooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ConversationRailFrame, RealtimeFeedback, WorkflowRunLogs } from '@/components/workflow/conversation';
import { PostHogEvent } from '@/constants/posthog-events';
import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
import { useOnboarding } from '@/context/OnboardingContext';
@ -398,9 +397,9 @@ export default function WorkflowRunPage() {
</div>
<div className="h-full min-h-0 w-[420px] shrink-0 border-l border-border bg-background p-5">
<TranscriptRailFrame className="h-full">
<ConversationRailFrame className="h-full">
<RealtimeFeedback mode="historical" logs={workflowRun?.logs ?? null} />
</TranscriptRailFrame>
</ConversationRailFrame>
</div>
</div>
);

View file

@ -1,153 +0,0 @@
/**
* 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' | 'pipeline-error' | 'interrupt-warning';
text: string;
final?: boolean;
timestamp: string;
turn?: number;
functionName?: string;
status?: 'running' | 'completed';
nodeName?: string;
previousNode?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
processor?: string;
model?: string;
fatal?: boolean;
}
export interface ProcessedMessage {
id: string;
type: TranscriptEvent['type'];
text: string;
final?: boolean;
timestamp: string;
functionName?: string;
status?: 'running' | 'completed';
nodeName?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
fatal?: boolean;
}
/**
* 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,
allowInterrupt: event.allowInterrupt,
ttfbSeconds: event.ttfbSeconds,
fatal: event.fatal,
};
}

View file

@ -1,15 +1,15 @@
'use client';
"use client";
import { MessageSquare, Mic, MicOff } from 'lucide-react';
import { ReactNode } from 'react';
import { MessageSquare, Mic, MicOff } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils";
type CallStatus = 'ready' | 'live' | 'ended';
import type { ConversationStatus } from "./types";
interface TranscriptContainerProps {
interface ConversationContainerProps {
title: string;
status: CallStatus;
status: ConversationStatus;
children: ReactNode;
messageCount?: number;
}
@ -17,57 +17,54 @@ interface TranscriptContainerProps {
const STATUS_CONFIG = {
ready: {
icon: MicOff,
label: 'Ready',
className: 'bg-muted text-muted-foreground',
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',
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',
label: "Ended",
className: "bg-muted text-muted-foreground",
},
};
} satisfies Record<ConversationStatus, { icon: typeof Mic; label: string; className: string }>;
export function TranscriptContainer({
export function ConversationContainer({
title,
status,
children,
messageCount
}: TranscriptContainerProps) {
messageCount,
}: ConversationContainerProps) {
const statusConfig = STATUS_CONFIG[status];
const StatusIcon = statusConfig.icon;
return (
<div className="flex h-full min-h-0 w-full flex-col bg-background">
{/* Header */}
<div className="shrink-0 border-b border-border px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium whitespace-nowrap">{title}</span>
<span className="truncate whitespace-nowrap text-sm font-medium">{title}</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{messageCount !== undefined && messageCount > 0 ? (
<span className="text-xs text-muted-foreground">
{messageCount} messages
</span>
<span className="text-xs text-muted-foreground">{messageCount} messages</span>
) : null}
<div className={cn(
"flex items-center gap-1 rounded-full px-2 py-0.5 text-xs shrink-0",
statusConfig.className
)}>
<div
className={cn(
"flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs",
statusConfig.className,
)}
>
<StatusIcon className="h-3 w-3" />
<span>{statusConfig.label}</span>
</div>
</div>
</div>
</div>
{/* Content */}
{children}
</div>
);

View file

@ -0,0 +1,15 @@
"use client";
import { MessageSquare } from "lucide-react";
import type { ConversationEmptyStateData } from "./types";
export function ConversationEmptyState({ title, subtitle }: ConversationEmptyStateData) {
return (
<div className="flex h-full flex-col items-center justify-center text-sm text-muted-foreground">
<MessageSquare className="mb-4 h-10 w-10 opacity-30" />
<p className="font-medium">{title}</p>
<p className="mt-1 px-4 text-center text-xs">{subtitle}</p>
</div>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import type { ReactNode } from "react";
import { MessageBubble } from "./MessageBubble";
import { NodeTransitionMarker } from "./NodeTransitionMarker";
import { NoticeCard } from "./NoticeCard";
import { ToolCallCard } from "./ToolCallCard";
import type { ConversationItem } from "./types";
interface ConversationItemViewProps {
item: ConversationItem;
actions?: ReactNode;
}
export function ConversationItemView({ item, actions }: ConversationItemViewProps) {
if (item.kind === "message") {
return (
<div className="group space-y-1">
<MessageBubble
role={item.role}
text={item.text}
final={item.final}
tone={item.tone}
reasoningDurationMs={item.reasoningDurationMs}
/>
{actions ? (
<div className="flex h-5 items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{actions}
</div>
) : null}
</div>
);
}
if (item.kind === "tool-call") {
return (
<ToolCallCard
functionName={item.functionName}
status={item.status}
argumentsValue={item.arguments}
resultValue={item.result}
reasoningDurationMs={item.reasoningDurationMs}
/>
);
}
if (item.kind === "node-transition") {
return <NodeTransitionMarker nodeName={item.nodeName} />;
}
return (
<NoticeCard
tone={item.tone}
title={item.title}
text={item.text}
linkHref={item.linkHref}
linkLabel={item.linkLabel}
/>
);
}

View file

@ -0,0 +1,32 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface ConversationRailFrameProps {
children: ReactNode;
className?: string;
header?: ReactNode;
footer?: ReactNode;
}
export function ConversationRailFrame({
children,
className,
header,
footer,
}: ConversationRailFrameProps) {
return (
<div
className={cn(
"flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm",
className,
)}
>
{header ? <div className="shrink-0 border-b border-border px-4 py-3">{header}</div> : null}
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
{footer ? <div className="shrink-0 border-t border-border px-4 py-3">{footer}</div> : null}
</div>
);
}

View file

@ -0,0 +1,60 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { ConversationEmptyState } from "./ConversationEmptyState";
import { ConversationItemView } from "./ConversationItemView";
import type { ConversationEmptyStateData, ConversationItem } from "./types";
interface ConversationTimelineProps {
items: ConversationItem[];
autoScroll?: boolean;
scrollBehavior?: ScrollBehavior;
emptyState: ConversationEmptyStateData;
pendingIndicator?: ReactNode;
renderItemActions?: (item: ConversationItem) => ReactNode;
className?: string;
}
export function ConversationTimeline({
items,
autoScroll = false,
scrollBehavior = "auto",
emptyState,
pendingIndicator,
renderItemActions,
className,
}: ConversationTimelineProps) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const scrollEndRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!autoScroll) {
return;
}
scrollEndRef.current?.scrollIntoView({ behavior: scrollBehavior, block: "end" });
}, [autoScroll, items, pendingIndicator, scrollBehavior]);
return (
<div ref={scrollContainerRef} className={cn("flex-1 overflow-y-auto", className)}>
{items.length === 0 && !pendingIndicator ? (
<ConversationEmptyState title={emptyState.title} subtitle={emptyState.subtitle} />
) : (
<div className="space-y-3 p-4">
{items.map((item) => (
<ConversationItemView
key={item.id}
item={item}
actions={renderItemActions?.(item)}
/>
))}
{pendingIndicator}
<div ref={scrollEndRef} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import { Brain } from "lucide-react";
import { cn } from "@/lib/utils";
interface MessageBubbleProps {
role: "user" | "assistant";
text: string;
final?: boolean;
tone?: "default" | "muted";
reasoningDurationMs?: number;
}
export function MessageBubble({
role,
text,
final = true,
tone = "default",
reasoningDurationMs,
}: MessageBubbleProps) {
const isUser = role === "user";
const isMuted = tone === "muted";
return (
<div className={cn("flex", isUser ? "justify-end" : "justify-start")}>
<div className="flex max-w-[85%] flex-col gap-1">
{!isUser && reasoningDurationMs !== undefined ? (
<div className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
<Brain className="h-3 w-3" />
<span className="font-medium">Reasoning Delay:</span>
<span>{Math.round(reasoningDurationMs)}ms</span>
</div>
) : null}
<div
className={cn(
"whitespace-pre-wrap break-words rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm",
isUser
? "rounded-br-md bg-primary text-primary-foreground"
: isMuted
? "rounded-bl-md border border-dashed border-border bg-background text-muted-foreground"
: "rounded-bl-md border border-slate-200/80 bg-muted text-foreground",
!final && "opacity-70",
)}
>
<div>{text}</div>
{!final ? (
<div
className={cn(
"mt-1 text-[10px] italic",
isUser ? "text-primary-foreground/70" : "text-muted-foreground",
)}
>
speaking...
</div>
) : null}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,20 @@
"use client";
import { GitBranch } from "lucide-react";
interface NodeTransitionMarkerProps {
nodeName: string;
}
export function NodeTransitionMarker({ nodeName }: NodeTransitionMarkerProps) {
return (
<div className="flex items-center gap-2 py-2">
<div className="h-px flex-1 bg-border" />
<div className="inline-flex items-center gap-1.5 rounded-full border border-blue-500/20 bg-blue-500/10 px-3 py-1 text-xs">
<GitBranch className="h-3 w-3 text-blue-500" />
<span className="font-medium text-blue-700 dark:text-blue-400">{nodeName}</span>
</div>
<div className="h-px flex-1 bg-border" />
</div>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import { AlertTriangle, ExternalLink, MicOff } from "lucide-react";
import { cn } from "@/lib/utils";
interface NoticeCardProps {
tone: "warning" | "error";
title: string;
text: string;
linkHref?: string;
linkLabel?: string;
}
export function NoticeCard({
tone,
title,
text,
linkHref,
linkLabel,
}: NoticeCardProps) {
const isWarning = tone === "warning";
const Icon = isWarning ? MicOff : AlertTriangle;
return (
<div
className={cn(
"flex items-start gap-2 rounded-lg border px-3 py-2",
isWarning
? "border-amber-500/20 bg-amber-500/10"
: "border-red-500/20 bg-red-500/10",
)}
>
<Icon
className={cn(
"mt-0.5 h-4 w-4 shrink-0",
isWarning ? "text-amber-500" : "text-red-500",
)}
/>
<div className="min-w-0 flex-1">
<div
className={cn(
"text-xs font-medium",
isWarning ? "text-amber-700 dark:text-amber-400" : "text-red-700 dark:text-red-400",
)}
>
{title}
</div>
<div
className={cn(
"mt-0.5 break-words text-sm",
isWarning ? "text-amber-600 dark:text-amber-300" : "text-red-600 dark:text-red-300",
)}
>
{text}
</div>
{linkHref && linkLabel ? (
<a
href={linkHref}
target="_blank"
rel="noopener noreferrer"
className={cn(
"mt-1 inline-flex items-center gap-1 text-xs hover:underline",
isWarning ? "text-amber-600 dark:text-amber-400" : "text-red-600 dark:text-red-400",
)}
>
{linkLabel} <ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import {
conversationItemsFromLiveFeedback,
conversationItemsFromRealtimeFeedbackEvents,
} from "./adapters/fromRealtimeFeedback";
import { ConversationContainer } from "./ConversationContainer";
import { ConversationTimeline } from "./ConversationTimeline";
import type {
ConversationStatus,
RealtimeFeedbackMessage,
WorkflowRunLogs,
} from "./types";
import { countConversationMessages } from "./utils";
interface LiveModeProps {
mode: "live";
messages: RealtimeFeedbackMessage[];
isCallActive: boolean;
isCallCompleted: boolean;
}
interface HistoricalModeProps {
mode: "historical";
logs: WorkflowRunLogs | null;
}
type RealtimeFeedbackProps = LiveModeProps | HistoricalModeProps;
export function RealtimeFeedback(props: RealtimeFeedbackProps) {
let items;
let status: ConversationStatus;
let title: string;
let emptyState: { title: string; subtitle: string };
let autoScroll = false;
if (props.mode === "historical") {
items = props.logs?.realtime_feedback_events
? conversationItemsFromRealtimeFeedbackEvents(props.logs.realtime_feedback_events)
: [];
status = "ended";
title = "Call Transcript";
emptyState = {
title: "No conversation recorded",
subtitle: "Real-time feedback events were not captured for this call",
};
} else {
items = conversationItemsFromLiveFeedback(props.messages);
status = props.isCallActive ? "live" : props.isCallCompleted ? "ended" : "ready";
title = "Live Transcript";
emptyState = {
title: "No messages yet",
subtitle: props.isCallActive
? "Start speaking to see the transcript"
: "Start the call to begin the conversation",
};
autoScroll = true;
}
return (
<ConversationContainer
title={title}
status={status}
messageCount={countConversationMessages(items) || undefined}
>
<ConversationTimeline
items={items}
autoScroll={autoScroll}
emptyState={emptyState}
/>
</ConversationContainer>
);
}

View file

@ -0,0 +1,116 @@
"use client";
import { Brain, ChevronRight, Wrench } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { formatConversationValue } from "./utils";
interface ToolCallCardProps {
functionName: string;
status: "running" | "completed";
argumentsValue?: unknown;
resultValue?: unknown;
reasoningDurationMs?: number;
}
export function ToolCallCard({
functionName,
status,
argumentsValue,
resultValue,
reasoningDurationMs,
}: ToolCallCardProps) {
const [open, setOpen] = useState(false);
const hasArguments = argumentsValue !== undefined;
const hasResult = resultValue !== undefined;
const hasDetails = hasArguments || hasResult;
return (
<div className="flex justify-center">
<div className="flex w-full max-w-[85%] flex-col gap-1">
{reasoningDurationMs !== undefined ? (
<div className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
<Brain className="h-3 w-3" />
<span className="font-medium">Reasoning Delay:</span>
<span>{Math.round(reasoningDurationMs)}ms</span>
</div>
) : null}
<Collapsible
open={hasDetails ? open : false}
onOpenChange={hasDetails ? setOpen : undefined}
className="rounded-2xl border border-amber-500/20 bg-amber-500/10"
>
<div className="flex items-start gap-2 px-3.5 py-3 text-sm">
<Wrench className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-amber-700 dark:text-amber-400">
{functionName}()
</span>
<Badge
variant="outline"
className={cn(
"h-5 px-1.5 text-[10px] uppercase tracking-[0.14em]",
status === "running"
? "border-amber-400/60 text-amber-700 dark:text-amber-300"
: "border-emerald-500/30 text-emerald-700 dark:text-emerald-300",
)}
>
{status === "running" ? "Running" : "Completed"}
</Badge>
</div>
{hasDetails ? (
<div className="mt-2">
<CollapsibleTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform",
open && "rotate-90",
)}
/>
Details
</button>
</CollapsibleTrigger>
</div>
) : null}
</div>
</div>
{hasDetails ? (
<CollapsibleContent className="border-t border-amber-500/20 px-3.5 py-3">
<div className="space-y-3">
{hasArguments ? (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Arguments
</p>
<pre className="overflow-x-auto rounded-xl bg-background/70 p-3 text-xs leading-5 text-foreground">
{formatConversationValue(argumentsValue)}
</pre>
</div>
) : null}
{hasResult ? (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Result
</p>
<pre className="overflow-x-auto rounded-xl bg-background/70 p-3 text-xs leading-5 text-foreground">
{formatConversationValue(resultValue)}
</pre>
</div>
) : null}
</div>
</CollapsibleContent>
) : null}
</Collapsible>
</div>
</div>
);
}

View file

@ -0,0 +1,279 @@
import type {
ConversationItem,
RealtimeFeedbackEvent,
RealtimeFeedbackMessage,
} from "../types";
function feedbackEventText(event: RealtimeFeedbackEvent) {
return (
event.payload.text ??
event.payload.error ??
(typeof event.payload.result === "string" ? event.payload.result : undefined) ??
event.payload.function_name ??
event.payload.node_name ??
""
);
}
function liveFeedbackItem(message: RealtimeFeedbackMessage, reasoningDurationMs?: number): ConversationItem | null {
if (message.type === "ttfb-metric") {
return null;
}
if (message.type === "user-transcription") {
return {
kind: "message",
id: message.id,
timestamp: message.timestamp,
role: "user",
text: message.text,
final: message.final,
};
}
if (message.type === "bot-text") {
return {
kind: "message",
id: message.id,
timestamp: message.timestamp,
role: "assistant",
text: message.text,
final: message.final,
reasoningDurationMs,
};
}
if (message.type === "function-call") {
return {
kind: "tool-call",
id: message.id,
timestamp: message.timestamp,
functionName: message.functionName ?? "tool",
toolCallId: message.toolCallId,
arguments: message.arguments,
result: message.result,
status: message.status ?? "completed",
reasoningDurationMs,
};
}
if (message.type === "node-transition") {
return {
kind: "node-transition",
id: message.id,
timestamp: message.timestamp,
nodeName: message.nodeName ?? message.text,
previousNodeName: message.previousNode,
allowInterrupt: message.allowInterrupt,
};
}
if (message.type === "interrupt-warning") {
return {
kind: "notice",
id: message.id,
timestamp: message.timestamp,
tone: "warning",
title: "Interruption Disabled",
text: message.text,
linkHref: "https://docs.dograh.com/configurations/interruption",
linkLabel: "Learn more",
};
}
if (message.type === "pipeline-error") {
return {
kind: "notice",
id: message.id,
timestamp: message.timestamp,
tone: "error",
title: message.fatal ? "Fatal Pipeline Error" : "Pipeline Error",
text: message.text,
fatal: message.fatal,
};
}
return null;
}
export function conversationItemsFromLiveFeedback(messages: RealtimeFeedbackMessage[]) {
const items: ConversationItem[] = [];
let pendingReasoningDurationMs: number | undefined;
messages.forEach((message) => {
if (message.type === "ttfb-metric") {
if (message.ttfbSeconds !== undefined) {
pendingReasoningDurationMs = message.ttfbSeconds * 1000;
}
return;
}
const item = liveFeedbackItem(message, pendingReasoningDurationMs);
if (!item) {
return;
}
items.push(item);
if (item.kind === "message" || item.kind === "tool-call") {
pendingReasoningDurationMs = undefined;
}
});
return items;
}
export function conversationItemsFromRealtimeFeedbackEvents(events: RealtimeFeedbackEvent[]) {
const items: ConversationItem[] = [];
const toolCallIndexById = new Map<string, number>();
let pendingReasoningDurationMs: number | undefined;
let currentBotItemIndex: number | null = null;
let currentBotTurn: number | null = null;
events.forEach((event, index) => {
if (event.type === "rtf-ttfb-metric") {
if (event.payload.ttfb_seconds !== undefined) {
pendingReasoningDurationMs = event.payload.ttfb_seconds * 1000;
}
return;
}
if (event.type === "rtf-user-transcription") {
currentBotItemIndex = null;
currentBotTurn = null;
items.push({
kind: "message",
id: `user-${event.turn}-${index}`,
timestamp: event.timestamp,
role: "user",
text: feedbackEventText(event),
final: event.payload.final,
});
return;
}
if (event.type === "rtf-bot-text") {
const text = feedbackEventText(event);
const lastItem = currentBotItemIndex !== null ? items[currentBotItemIndex] : null;
if (
currentBotItemIndex !== null &&
currentBotTurn === event.turn &&
lastItem?.kind === "message" &&
lastItem.role === "assistant"
) {
items[currentBotItemIndex] = {
...lastItem,
text: `${lastItem.text} ${text}`.trim(),
};
return;
}
items.push({
kind: "message",
id: `bot-${event.turn}-${index}`,
timestamp: event.timestamp,
role: "assistant",
text,
final: event.payload.final,
reasoningDurationMs: pendingReasoningDurationMs,
});
currentBotItemIndex = items.length - 1;
currentBotTurn = event.turn;
pendingReasoningDurationMs = undefined;
return;
}
currentBotItemIndex = null;
currentBotTurn = null;
if (event.type === "rtf-function-call-start") {
const toolCallId = event.payload.tool_call_id;
items.push({
kind: "tool-call",
id: toolCallId ?? `tool-${event.turn}-${index}`,
timestamp: event.timestamp,
functionName: event.payload.function_name ?? "tool",
toolCallId,
arguments: event.payload.arguments,
status: "running",
reasoningDurationMs: pendingReasoningDurationMs,
});
if (toolCallId) {
toolCallIndexById.set(toolCallId, items.length - 1);
}
pendingReasoningDurationMs = undefined;
return;
}
if (event.type === "rtf-function-call-end") {
const toolCallId = event.payload.tool_call_id;
const existingIndex = toolCallId ? toolCallIndexById.get(toolCallId) : undefined;
if (existingIndex !== undefined) {
const existingItem = items[existingIndex];
if (existingItem?.kind === "tool-call") {
items[existingIndex] = {
...existingItem,
status: "completed",
result: event.payload.result,
};
}
return;
}
items.push({
kind: "tool-call",
id: toolCallId ?? `tool-result-${event.turn}-${index}`,
timestamp: event.timestamp,
functionName: event.payload.function_name ?? "tool",
toolCallId,
result: event.payload.result,
status: "completed",
reasoningDurationMs: pendingReasoningDurationMs,
});
pendingReasoningDurationMs = undefined;
return;
}
if (event.type === "rtf-node-transition") {
items.push({
kind: "node-transition",
id: `node-${event.turn}-${index}`,
timestamp: event.timestamp,
nodeName: event.payload.node_name ?? feedbackEventText(event) ?? "Node",
previousNodeName: event.payload.previous_node_name ?? event.payload.previous_node,
allowInterrupt: event.payload.allow_interrupt,
});
return;
}
if (event.type === "rtf-interrupt-warning") {
items.push({
kind: "notice",
id: `warning-${event.turn}-${index}`,
timestamp: event.timestamp,
tone: "warning",
title: "Interruption Disabled",
text: feedbackEventText(event),
linkHref: "https://docs.dograh.com/configurations/interruption",
linkLabel: "Learn more",
});
return;
}
if (event.type === "rtf-pipeline-error") {
items.push({
kind: "notice",
id: `error-${event.turn}-${index}`,
timestamp: event.timestamp,
tone: "error",
title: event.payload.fatal ? "Fatal Pipeline Error" : "Pipeline Error",
text: feedbackEventText(event),
fatal: event.payload.fatal,
});
}
});
return items;
}

View file

@ -0,0 +1,178 @@
import type { ConversationItem } from "../types";
interface TextChatMessageLike {
text?: string;
created_at?: string;
}
interface TextChatEventLike {
type?: unknown;
payload?: unknown;
created_at?: unknown;
}
interface TextChatTurnLike {
id: string;
status?: string;
created_at?: string;
user_message?: TextChatMessageLike | null;
assistant_message?: TextChatMessageLike | null;
events?: Array<Record<string, unknown>>;
}
function asRecord(value: unknown) {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown) {
return typeof value === "string" ? value : undefined;
}
function conversationItemsFromTextChatEvents(
events: Array<Record<string, unknown>>,
turnId: string,
fallbackTimestamp?: string,
) {
const items: ConversationItem[] = [];
const toolCallIndexById = new Map<string, number>();
events.forEach((rawEvent, index) => {
const event = rawEvent as TextChatEventLike;
const eventType = asString(event.type);
const payload = asRecord(event.payload);
if (!eventType || !payload) {
return;
}
const timestamp = asString(event.created_at) ?? fallbackTimestamp;
if (eventType === "node_transition") {
const nodeName = asString(payload.node_name) ?? "Node";
items.push({
kind: "node-transition",
id: `${turnId}-node-${index}`,
turnId,
timestamp,
nodeName,
previousNodeName: asString(payload.previous_node_name),
allowInterrupt: typeof payload.allow_interrupt === "boolean" ? payload.allow_interrupt : undefined,
});
return;
}
if (eventType === "execution_error") {
items.push({
kind: "notice",
id: `${turnId}-error-${index}`,
turnId,
timestamp,
tone: "error",
title: "Execution Error",
text: asString(payload.message) ?? "Execution error",
fatal: true,
});
return;
}
if (eventType === "tool_call_started") {
const functionName = asString(payload.function_name) ?? "tool";
const toolCallId = asString(payload.tool_call_id);
items.push({
kind: "tool-call",
id: toolCallId ?? `${turnId}-tool-${index}`,
turnId,
timestamp,
functionName,
toolCallId,
status: "running",
arguments: payload.arguments,
});
if (toolCallId) {
toolCallIndexById.set(toolCallId, items.length - 1);
}
return;
}
if (eventType === "tool_call_result") {
const functionName = asString(payload.function_name) ?? "tool";
const toolCallId = asString(payload.tool_call_id);
const existingIndex = toolCallId ? toolCallIndexById.get(toolCallId) : undefined;
if (existingIndex !== undefined) {
const existingItem = items[existingIndex];
if (existingItem?.kind === "tool-call") {
items[existingIndex] = {
...existingItem,
status: "completed",
result: payload.result,
};
}
return;
}
items.push({
kind: "tool-call",
id: toolCallId ?? `${turnId}-tool-result-${index}`,
turnId,
timestamp,
functionName,
toolCallId,
status: "completed",
result: payload.result,
});
}
});
return items;
}
export function conversationItemsFromTextChatTurns(turns: TextChatTurnLike[]) {
const items: ConversationItem[] = [];
turns.forEach((turn) => {
if (turn.user_message?.text) {
items.push({
kind: "message",
id: `${turn.id}-user`,
turnId: turn.id,
timestamp: turn.user_message.created_at ?? turn.created_at,
role: "user",
text: turn.user_message.text,
});
}
items.push(
...conversationItemsFromTextChatEvents(
turn.events ?? [],
turn.id,
turn.created_at,
),
);
if (turn.assistant_message?.text) {
items.push({
kind: "message",
id: `${turn.id}-assistant`,
turnId: turn.id,
timestamp: turn.assistant_message.created_at ?? turn.created_at,
role: "assistant",
text: turn.assistant_message.text,
});
return;
}
if (turn.status === "failed") {
items.push({
kind: "message",
id: `${turn.id}-assistant-failed`,
turnId: turn.id,
timestamp: turn.created_at,
role: "assistant",
text: "Agent turn failed",
tone: "muted",
});
}
});
return items;
}

View file

@ -0,0 +1,5 @@
export * from "./ConversationContainer";
export * from "./ConversationRailFrame";
export * from "./ConversationTimeline";
export * from "./RealtimeFeedback";
export * from "./types";

View file

@ -0,0 +1,111 @@
export type ConversationStatus = "ready" | "live" | "ended";
export type RealtimeFeedbackMessageType =
| "user-transcription"
| "bot-text"
| "function-call"
| "node-transition"
| "ttfb-metric"
| "pipeline-error"
| "interrupt-warning";
export interface RealtimeFeedbackMessage {
id: string;
type: RealtimeFeedbackMessageType;
text: string;
final?: boolean;
timestamp: string;
functionName?: string;
toolCallId?: string;
arguments?: unknown;
result?: unknown;
status?: "running" | "completed";
nodeName?: string;
previousNode?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
processor?: string;
model?: string;
fatal?: boolean;
}
export interface RealtimeFeedbackEvent {
type: string;
payload: {
text?: string;
final?: boolean;
user_id?: string;
timestamp?: string;
function_name?: string;
tool_call_id?: string;
arguments?: unknown;
result?: unknown;
node_name?: string;
previous_node?: string;
previous_node_name?: string;
allow_interrupt?: boolean;
ttfb_seconds?: number;
processor?: string;
model?: string;
error?: string;
fatal?: boolean;
};
timestamp: string;
turn: number;
}
export interface WorkflowRunLogs {
realtime_feedback_events?: RealtimeFeedbackEvent[];
}
interface ConversationItemBase {
id: string;
timestamp?: string;
turnId?: string;
reasoningDurationMs?: number;
}
export interface ConversationMessageItem extends ConversationItemBase {
kind: "message";
role: "user" | "assistant";
text: string;
final?: boolean;
tone?: "default" | "muted";
}
export interface ConversationToolCallItem extends ConversationItemBase {
kind: "tool-call";
functionName: string;
toolCallId?: string;
status: "running" | "completed";
arguments?: unknown;
result?: unknown;
}
export interface ConversationNodeTransitionItem extends ConversationItemBase {
kind: "node-transition";
nodeName: string;
previousNodeName?: string;
allowInterrupt?: boolean;
}
export interface ConversationNoticeItem extends ConversationItemBase {
kind: "notice";
tone: "warning" | "error";
title: string;
text: string;
fatal?: boolean;
linkHref?: string;
linkLabel?: string;
}
export type ConversationItem =
| ConversationMessageItem
| ConversationToolCallItem
| ConversationNodeTransitionItem
| ConversationNoticeItem;
export interface ConversationEmptyStateData {
title: string;
subtitle: string;
}

View file

@ -0,0 +1,21 @@
import type { ConversationItem } from "./types";
export function formatConversationValue(value: unknown) {
if (value == null) {
return "None";
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
export function countConversationMessages(items: ConversationItem[]) {
return items.filter(
(item) => item.kind === "message" && item.tone !== "muted",
).length;
}