diff --git a/api/services/pipecat/realtime_feedback_events.py b/api/services/pipecat/realtime_feedback_events.py index a674f27..e140fc6 100644 --- a/api/services/pipecat/realtime_feedback_events.py +++ b/api/services/pipecat/realtime_feedback_events.py @@ -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, } diff --git a/api/services/pipecat/realtime_feedback_observer.py b/api/services/pipecat/realtime_feedback_observer.py index f5ead33..3cc85c6 100644 --- a/api/services/pipecat/realtime_feedback_observer.py +++ b/api/services/pipecat/realtime_feedback_observer.py @@ -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 diff --git a/api/services/workflow/text_chat_logs.py b/api/services/workflow/text_chat_logs.py index 4faa3f3..42a9274 100644 --- a/api/services/workflow/text_chat_logs.py +++ b/api/services/workflow/text_chat_logs.py @@ -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, diff --git a/ui/src/app/workflow/[workflowId]/components/WorkflowTesterPanel.tsx b/ui/src/app/workflow/[workflowId]/components/WorkflowTesterPanel.tsx index a0df70b..c7d7d91 100644 --- a/ui/src/app/workflow/[workflowId]/components/WorkflowTesterPanel.tsx +++ b/ui/src/app/workflow/[workflowId]/components/WorkflowTesterPanel.tsx @@ -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>; - usage: Record; -} - -interface TextChatSessionData { - version: number; - status: string; - cursor_turn_id: string | null; - turns: TextChatTurn[]; - discarded_future: Array>; - simulator: { - enabled: boolean; - config: Record; - }; -} - -interface TextChatCheckpoint { - version: number; - anchor_turn_id: string | null; - current_node_id: string | null; - messages: Array>; - gathered_context: Record; - tool_state: Record; -} - -type TextChatSession = Omit & { - 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 ( -
-
- -
-

Testing is paused

-

{reason}

-
-
-
- ); -} - -function EmptyState({ - icon, - title, - description, - action, -}: { - icon: ReactNode; - title: string; - description: string; - action?: ReactNode; -}) { - return ( -
-
- {icon} -
-
-

{title}

-

{description}

-
- {action ?
{action}
: null} -
- ); -} - -function MessageBubble({ - role, - text, - state, -}: { - role: "user" | "agent"; - text: ReactNode; - state?: "default" | "muted"; -}) { - const isUser = role === "user"; - const isMuted = state === "muted"; - return ( -
-
- {text} -
-
- ); -} - -function TypingBubble() { - return ( -
-
-
- - - -
-
-
- ); -} - -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>): TextChatToolEvent[] { - return events.reduce((acc, event) => { - const eventType = event.type; - const payload = event.payload; - if (!payload || typeof payload !== "object") { - return acc; - } - const typedPayload = payload as Record; - - 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 ( -
-
-
- - {event.kind === "start" ? "Tool" : "Result"} - - - {event.kind === "start" - ? `${event.functionName}()` - : `${event.functionName} -> ${event.resultText ?? "No result"}`} - -
-
-
- ); -} - -function EmbeddedVoiceTester({ - workflowId, - workflowRunId, - initialContextVariables, - accessToken, - onReset, -}: { - workflowId: number; - workflowRunId: number; - initialContextVariables?: Record; - 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 ( - <> -
-
- -
- -
-
- - {permissionError ? ( -

{permissionError}

- ) : null} - -
-
- -
- - router.push("/api-keys")} - onNavigateToModelConfig={() => router.push("/model-configurations")} - /> - - router.push(`/workflow/${workflowId}`)} - /> - - ); -} - -function ManualTextChat({ - workflowId, - ready, - initialContextVariables, - disabled, - disabledReason, - onActiveChange, -}: { - workflowId: number; - ready: boolean; - initialContextVariables?: Record; - disabled: boolean; - disabledReason: string | null; - onActiveChange?: (active: boolean) => void; -}) { - const [session, setSession] = useState(null); - const [started, setStarted] = useState(false); - const [draft, setDraft] = useState(""); - const [creatingSession, setCreatingSession] = useState(false); - const [sendingMessage, setSendingMessage] = useState(false); - const [editingTurnId, setEditingTurnId] = useState(null); - const [activeTurnAction, setActiveTurnAction] = useState(null); - const scrollEndRef = useRef(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 ( -
- {disabledReason ? : null} - } - 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={ - - } - /> -
- ); - } - - return ( -
- {disabledReason ? ( -
- -
- ) : null} - -
- {creatingSession && !session ? ( -
- - -
- ) : turns.length === 0 ? ( -
-

- {disabled - ? (disabledReason ?? "Testing is paused.") - : "Send a message to start the conversation."} -

-
- ) : ( -
- {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 ( -
- {turn.user_message ? ( -
- -
- - -
-
- ) : null} - {toolEvents.map((event, index) => ( - - ))} - {turn.assistant_message ? ( - - ) : turn.status === "failed" ? ( - - ) : null} -
- ); - })} - {sendingMessage ? : null} -
-
- )} -
- -
- {editingTurn ? ( -
- Edit the selected user message, then press Enter to rerun from that point. - -
- ) : null} -
-