suna/frontend/src/components/thread/content/PlaybackControls.tsx

613 lines
18 KiB
TypeScript

import React, { useCallback, useEffect, useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Play, Pause, ArrowDown, FileText, Info } from 'lucide-react';
import { UnifiedMessage } from '@/components/thread/types';
import { safeJsonParse } from '@/components/thread/utils';
import Link from 'next/link';
// Define the set of tags whose raw XML should be hidden during streaming
const HIDE_STREAMING_XML_TAGS = new Set([
'execute-command',
'create-file',
'delete-file',
'full-file-rewrite',
'str-replace',
'browser-click-element',
'browser-close-tab',
'browser-drag-drop',
'browser-get-dropdown-options',
'browser-go-back',
'browser-input-text',
'browser-navigate-to',
'browser-scroll-down',
'browser-scroll-to-text',
'browser-scroll-up',
'browser-select-dropdown-option',
'browser-send-keys',
'browser-switch-tab',
'browser-wait',
'deploy',
'ask',
'complete',
'crawl-webpage',
'web-search',
]);
export interface PlaybackControlsProps {
messages: UnifiedMessage[];
isSidePanelOpen: boolean;
onToggleSidePanel: () => void;
toolCalls: any[];
setCurrentToolIndex: (index: number) => void;
onFileViewerOpen: () => void;
projectName?: string;
}
export interface PlaybackState {
isPlaying: boolean;
currentMessageIndex: number;
visibleMessages: UnifiedMessage[];
streamingText: string;
isStreamingText: boolean;
currentToolCall: any | null;
toolPlaybackIndex: number;
}
export interface PlaybackController {
playbackState: PlaybackState;
updatePlaybackState: (updates: Partial<PlaybackState>) => void;
renderHeader: () => JSX.Element;
renderFloatingControls: () => JSX.Element;
renderWelcomeOverlay: () => JSX.Element;
togglePlayback: () => void;
resetPlayback: () => void;
skipToEnd: () => void;
}
export const PlaybackControls = ({
messages,
isSidePanelOpen,
onToggleSidePanel,
toolCalls,
setCurrentToolIndex,
onFileViewerOpen,
projectName = 'Shared Conversation',
}: PlaybackControlsProps): PlaybackController => {
const [playbackState, setPlaybackState] = useState<PlaybackState>({
isPlaying: false,
currentMessageIndex: 0,
visibleMessages: [],
streamingText: '',
isStreamingText: false,
currentToolCall: null,
toolPlaybackIndex: -1,
});
// Extract state variables for easier access
const {
isPlaying,
currentMessageIndex,
visibleMessages,
streamingText,
isStreamingText,
currentToolCall,
toolPlaybackIndex,
} = playbackState;
// Helper function to update playback state
const updatePlaybackState = useCallback((updates: Partial<PlaybackState>) => {
setPlaybackState((prev) => ({ ...prev, ...updates }));
}, []);
// Define togglePlayback and resetPlayback functions
const togglePlayback = useCallback(() => {
updatePlaybackState({
isPlaying: !isPlaying,
});
// When starting playback, show the side panel
if (!isPlaying && !isSidePanelOpen) {
onToggleSidePanel();
}
}, [isPlaying, isSidePanelOpen, onToggleSidePanel]);
const resetPlayback = useCallback(() => {
updatePlaybackState({
isPlaying: false,
currentMessageIndex: 0,
visibleMessages: [],
streamingText: '',
isStreamingText: false,
currentToolCall: null,
toolPlaybackIndex: -1,
});
}, [updatePlaybackState]);
const skipToEnd = useCallback(() => {
updatePlaybackState({
isPlaying: false,
currentMessageIndex: messages.length,
visibleMessages: messages,
streamingText: '',
isStreamingText: false,
currentToolCall: null,
toolPlaybackIndex: toolCalls.length - 1,
});
if (toolCalls.length > 0) {
setCurrentToolIndex(toolCalls.length - 1);
if (!isSidePanelOpen) {
onToggleSidePanel();
}
}
}, [
messages,
toolCalls,
isSidePanelOpen,
onToggleSidePanel,
setCurrentToolIndex,
updatePlaybackState,
]);
// Streaming text function
const streamText = useCallback(
(text: string, onComplete: () => void) => {
if (!text || !isPlaying) {
onComplete();
return () => {};
}
updatePlaybackState({
isStreamingText: true,
streamingText: '',
});
// Define regex to find tool calls in text
const toolCallRegex =
/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g;
// Split text into chunks (handling tool calls as special chunks)
const chunks: { text: string; isTool: boolean; toolName?: string }[] = [];
let lastIndex = 0;
let match;
while ((match = toolCallRegex.exec(text)) !== null) {
// Add text before the tool call
if (match.index > lastIndex) {
chunks.push({
text: text.substring(lastIndex, match.index),
isTool: false,
});
}
// Add the tool call
const toolName = match[1] || match[2];
chunks.push({
text: match[0],
isTool: true,
toolName,
});
lastIndex = toolCallRegex.lastIndex;
}
// Add any remaining text after the last tool call
if (lastIndex < text.length) {
chunks.push({
text: text.substring(lastIndex),
isTool: false,
});
}
let currentIndex = 0;
let chunkIndex = 0;
let currentText = '';
let isPaused = false;
const processNextCharacter = () => {
// Check if component is unmounted or playback is stopped
if (!isPlaying || isPaused) {
setTimeout(processNextCharacter, 100); // Check again after a short delay
return;
}
if (chunkIndex >= chunks.length) {
// All chunks processed, we're done
updatePlaybackState({
isStreamingText: false,
});
// Update visible messages with the complete message
const currentMessage = messages[currentMessageIndex];
const lastMessage = visibleMessages[visibleMessages.length - 1];
if (lastMessage?.message_id === currentMessage.message_id) {
// Replace the streaming message with the complete one
updatePlaybackState({
visibleMessages: [
...visibleMessages.slice(0, -1),
currentMessage,
],
});
} else {
// Add the complete message
updatePlaybackState({
visibleMessages: [...visibleMessages, currentMessage],
});
}
onComplete();
return;
}
const currentChunk = chunks[chunkIndex];
// If this is a tool call chunk and we're at the start of it
if (currentChunk.isTool && currentIndex === 0) {
// For tool calls, check if they should be hidden during streaming
if (
currentChunk.toolName &&
HIDE_STREAMING_XML_TAGS.has(currentChunk.toolName)
) {
// Instead of showing the XML, create a tool call object
const toolCall = {
name: currentChunk.toolName,
arguments: currentChunk.text,
xml_tag_name: currentChunk.toolName,
};
updatePlaybackState({
currentToolCall: toolCall,
toolPlaybackIndex: toolPlaybackIndex + 1,
});
if (!isSidePanelOpen) {
onToggleSidePanel();
}
setCurrentToolIndex(toolPlaybackIndex + 1);
// Pause streaming briefly while showing the tool
isPaused = true;
setTimeout(() => {
isPaused = false;
updatePlaybackState({ currentToolCall: null });
chunkIndex++; // Move to next chunk
currentIndex = 0; // Reset index for next chunk
processNextCharacter();
}, 500); // Reduced from 1500ms to 500ms pause for tool display
return;
}
}
// Handle normal text streaming for non-tool chunks or visible tool chunks
if (currentIndex < currentChunk.text.length) {
// Dynamically adjust typing speed for a more realistic effect
const baseDelay = 5; // Reduced from 15ms to 5ms
let typingDelay = baseDelay;
// Add more delay for punctuation to make it feel more natural
const char = currentChunk.text[currentIndex];
if ('.!?,;:'.includes(char)) {
typingDelay = baseDelay + Math.random() * 100 + 50; // Reduced from 300+100 to 100+50ms pause after punctuation
} else {
const variableDelay = Math.random() * 5; // Reduced from 15 to 5ms
typingDelay = baseDelay + variableDelay; // 5-10ms for normal typing
}
// Add the next character
currentText += currentChunk.text[currentIndex];
updatePlaybackState({ streamingText: currentText });
currentIndex++;
// Process next character with dynamic delay
setTimeout(processNextCharacter, typingDelay);
} else {
// Move to the next chunk
chunkIndex++;
currentIndex = 0;
processNextCharacter();
}
};
processNextCharacter();
// Return cleanup function
return () => {
updatePlaybackState({
isStreamingText: false,
streamingText: '',
});
isPaused = true; // Stop processing
};
},
[
isPlaying,
messages,
currentMessageIndex,
toolPlaybackIndex,
setCurrentToolIndex,
isSidePanelOpen,
onToggleSidePanel,
updatePlaybackState,
visibleMessages,
],
);
// Main playback function
useEffect(() => {
if (!isPlaying || messages.length === 0) return;
let playbackTimeout: NodeJS.Timeout;
let cleanupStreaming: (() => void) | undefined;
const playbackNextMessage = async () => {
// Ensure we're within bounds
if (currentMessageIndex >= messages.length) {
updatePlaybackState({ isPlaying: false });
return;
}
const currentMessage = messages[currentMessageIndex];
console.log(
`Playing message ${currentMessageIndex}:`,
currentMessage.type,
currentMessage.message_id,
);
// If it's an assistant message, stream it
if (currentMessage.type === 'assistant') {
try {
// Parse the content if it's JSON
let content = currentMessage.content;
try {
const parsed = JSON.parse(content);
if (parsed.content) {
content = parsed.content;
}
} catch (e) {
// Not JSON, use as is
}
// Stream the message content
await new Promise<void>((resolve) => {
cleanupStreaming = streamText(content, resolve);
});
} catch (error) {
console.error('Error streaming message:', error);
}
} else {
// For non-assistant messages, just add them to visible messages
updatePlaybackState({
visibleMessages: [...visibleMessages, currentMessage],
});
// Wait a moment before showing the next message
await new Promise((resolve) => setTimeout(resolve, 500));
}
// Move to the next message
updatePlaybackState({
currentMessageIndex: currentMessageIndex + 1,
});
};
// Start playback with a small delay
playbackTimeout = setTimeout(playbackNextMessage, 500);
return () => {
clearTimeout(playbackTimeout);
if (cleanupStreaming) cleanupStreaming();
};
}, [
isPlaying,
currentMessageIndex,
messages,
streamText,
updatePlaybackState,
visibleMessages,
]);
// Floating playback controls position based on side panel state
const controlsPositionClass = isSidePanelOpen
? 'left-1/2 -translate-x-1/4 sm:left-[calc(50%-225px)] md:left-[calc(50%-250px)] lg:left-[calc(50%-275px)] xl:left-[calc(50%-325px)]'
: 'left-1/2 -translate-x-1/2';
// Header with playback controls
const renderHeader = useCallback(
() => (
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 relative z-[50]">
<div className="flex h-14 items-center gap-4 px-4">
<div className="flex-1">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-6 h-6 rounded-md overflow-hidden bg-primary/10">
<Link href="/">
<img
src="/kortix-symbol.svg"
alt="Kortix"
width={16}
height={16}
className="object-contain"
/>
</Link>
</div>
<h1>
<span className="font-medium text-foreground">
{projectName}
</span>
</h1>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={onFileViewerOpen}
className="h-8 w-8"
aria-label="View Files"
>
<FileText className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={togglePlayback}
className="h-8 w-8"
aria-label={isPlaying ? 'Pause Replay' : 'Play Replay'}
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={resetPlayback}
className="h-8 w-8"
aria-label="Restart Replay"
>
<ArrowDown className="h-4 w-4 rotate-90" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onToggleSidePanel}
className={`h-8 w-8 ${isSidePanelOpen ? 'text-primary' : ''}`}
aria-label="Toggle Tool Panel"
>
<Info className="h-4 w-4" />
</Button>
</div>
</div>
</div>
),
[
isPlaying,
isSidePanelOpen,
onFileViewerOpen,
onToggleSidePanel,
projectName,
resetPlayback,
togglePlayback,
],
);
const renderFloatingControls = useCallback(
() => (
<>
{messages.length > 0 && (
<div
className={`fixed bottom-4 z-10 transform bg-background/90 backdrop-blur rounded-full border shadow-md px-3 py-1.5 transition-all duration-200 ${controlsPositionClass}`}
>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={togglePlayback}
className="h-8 w-8"
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<div className="flex items-center text-xs text-muted-foreground">
<span>
{Math.min(
currentMessageIndex + (isStreamingText ? 0 : 1),
messages.length,
)}
/{messages.length}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={resetPlayback}
className="h-8 w-8"
>
<ArrowDown className="h-4 w-4 rotate-90" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={skipToEnd}
className="text-xs"
>
Skip to end
</Button>
</div>
</div>
)}
</>
),
[
controlsPositionClass,
currentMessageIndex,
isPlaying,
isStreamingText,
messages.length,
resetPlayback,
skipToEnd,
togglePlayback,
],
);
// When s are displayed yet, show the welcome overlay
const renderWelcomeOverlay = useCallback(
() => (
<>
{visibleMessages.length === 0 && !streamingText && !currentToolCall && (
<div className="fixed inset-0 flex flex-col items-center justify-center">
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent dark:from-black/90 dark:via-black/50 dark:to-transparent" />
<div className="text-center max-w-md mx-auto relative z-10 px-4">
<div className="rounded-full bg-primary/10 backdrop-blur-sm w-12 h-12 mx-auto flex items-center justify-center mb-4">
<Play className="h-5 w-5 text-primary" />
</div>
<h3 className="text-lg font-medium mb-2 text-white">
Watch this agent in action
</h3>
<p className="text-sm text-white/80 mb-4">
This is a shared view-only agent run. Click play to replay the
entire conversation with realistic timing.
</p>
<Button
onClick={togglePlayback}
className="flex items-center mx-auto bg-white/10 hover:bg-white/20 backdrop-blur-sm text-white border-white/20"
size="lg"
variant="outline"
>
<Play className="h-4 w-4 mr-2" />
Start Playback
</Button>
</div>
</div>
)}
</>
),
[currentToolCall, streamingText, togglePlayback, visibleMessages.length],
);
return {
playbackState,
updatePlaybackState,
renderHeader,
renderFloatingControls,
renderWelcomeOverlay,
togglePlayback,
resetPlayback,
skipToEnd,
};
};
export default PlaybackControls;