mirror of https://github.com/kortix-ai/suna.git
613 lines
18 KiB
TypeScript
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;
|