mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
feat: add chat based testing for voice agent (#308)
* feat: add backend foundations * feat: add text chat UI * chore: simplify the reload behaviour * fix: fix upgrade banner to be triggered after package upload * feat: simplify TesterPanel design * chore: fix formatting and generate client * chore: fix tracing for text chat mode * fix: fix revert and edit CTA * refactor: refactor TesterPanel into smaller components * feat: enable runtime transition of nodes * fix: fix review comments
This commit is contained in:
parent
67479e98fd
commit
d97d1d72cd
96 changed files with 7630 additions and 1684 deletions
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquare, Mic, MicOff } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { ConversationStatus } from "./types";
|
||||
|
||||
interface ConversationContainerProps {
|
||||
title: string;
|
||||
status: ConversationStatus;
|
||||
children: ReactNode;
|
||||
messageCount?: number;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
ready: {
|
||||
icon: MicOff,
|
||||
label: "Ready",
|
||||
className: "bg-muted text-muted-foreground",
|
||||
},
|
||||
live: {
|
||||
icon: Mic,
|
||||
label: "Live",
|
||||
className: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||
},
|
||||
ended: {
|
||||
icon: MicOff,
|
||||
label: "Ended",
|
||||
className: "bg-muted text-muted-foreground",
|
||||
},
|
||||
} satisfies Record<ConversationStatus, { icon: typeof Mic; label: string; className: string }>;
|
||||
|
||||
export function ConversationContainer({
|
||||
title,
|
||||
status,
|
||||
children,
|
||||
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">
|
||||
<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 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>
|
||||
) : null}
|
||||
<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>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
61
ui/src/components/workflow/conversation/MessageBubble.tsx
Normal file
61
ui/src/components/workflow/conversation/MessageBubble.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
73
ui/src/components/workflow/conversation/NoticeCard.tsx
Normal file
73
ui/src/components/workflow/conversation/NoticeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
ui/src/components/workflow/conversation/RealtimeFeedback.tsx
Normal file
73
ui/src/components/workflow/conversation/RealtimeFeedback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
ui/src/components/workflow/conversation/ToolCallCard.tsx
Normal file
116
ui/src/components/workflow/conversation/ToolCallCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
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,
|
||||
nodeId: message.nodeId,
|
||||
nodeName: message.nodeName ?? message.text,
|
||||
previousNodeId: message.previousNodeId,
|
||||
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,
|
||||
nodeId: event.payload.node_id,
|
||||
nodeName: event.payload.node_name ?? feedbackEventText(event) ?? "Node",
|
||||
previousNodeId: event.payload.previous_node_id,
|
||||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
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,
|
||||
nodeId: asString(payload.node_id),
|
||||
nodeName,
|
||||
previousNodeId: asString(payload.previous_node_id),
|
||||
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;
|
||||
}
|
||||
5
ui/src/components/workflow/conversation/index.ts
Normal file
5
ui/src/components/workflow/conversation/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./ConversationContainer";
|
||||
export * from "./ConversationRailFrame";
|
||||
export * from "./ConversationTimeline";
|
||||
export * from "./RealtimeFeedback";
|
||||
export * from "./types";
|
||||
117
ui/src/components/workflow/conversation/types.ts
Normal file
117
ui/src/components/workflow/conversation/types.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
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";
|
||||
nodeId?: string;
|
||||
nodeName?: string;
|
||||
previousNodeId?: 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_id?: string;
|
||||
node_name?: string;
|
||||
previous_node_id?: 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";
|
||||
nodeId?: string;
|
||||
nodeName: string;
|
||||
previousNodeId?: 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;
|
||||
}
|
||||
21
ui/src/components/workflow/conversation/utils.ts
Normal file
21
ui/src/components/workflow/conversation/utils.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue