|
|
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'; |
|
|
|
|
|
|
|
|
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, |
|
|
}); |
|
|
|
|
|
|
|
|
const { |
|
|
isPlaying, |
|
|
currentMessageIndex, |
|
|
visibleMessages, |
|
|
streamingText, |
|
|
isStreamingText, |
|
|
currentToolCall, |
|
|
toolPlaybackIndex, |
|
|
} = playbackState; |
|
|
|
|
|
|
|
|
const updatePlaybackState = useCallback((updates: Partial<PlaybackState>) => { |
|
|
setPlaybackState((prev) => ({ ...prev, ...updates })); |
|
|
}, []); |
|
|
|
|
|
|
|
|
const togglePlayback = useCallback(() => { |
|
|
updatePlaybackState({ |
|
|
isPlaying: !isPlaying, |
|
|
}); |
|
|
|
|
|
|
|
|
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, |
|
|
]); |
|
|
|
|
|
|
|
|
const streamText = useCallback( |
|
|
(text: string, onComplete: () => void) => { |
|
|
if (!text || !isPlaying) { |
|
|
onComplete(); |
|
|
return () => {}; |
|
|
} |
|
|
|
|
|
updatePlaybackState({ |
|
|
isStreamingText: true, |
|
|
streamingText: '', |
|
|
}); |
|
|
|
|
|
|
|
|
const toolCallRegex = |
|
|
/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g; |
|
|
|
|
|
|
|
|
const chunks: { text: string; isTool: boolean; toolName?: string }[] = []; |
|
|
let lastIndex = 0; |
|
|
let match; |
|
|
|
|
|
while ((match = toolCallRegex.exec(text)) !== null) { |
|
|
|
|
|
if (match.index > lastIndex) { |
|
|
chunks.push({ |
|
|
text: text.substring(lastIndex, match.index), |
|
|
isTool: false, |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
const toolName = match[1] || match[2]; |
|
|
chunks.push({ |
|
|
text: match[0], |
|
|
isTool: true, |
|
|
toolName, |
|
|
}); |
|
|
|
|
|
lastIndex = toolCallRegex.lastIndex; |
|
|
} |
|
|
|
|
|
|
|
|
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 = () => { |
|
|
|
|
|
if (!isPlaying || isPaused) { |
|
|
setTimeout(processNextCharacter, 100); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (chunkIndex >= chunks.length) { |
|
|
|
|
|
updatePlaybackState({ |
|
|
isStreamingText: false, |
|
|
}); |
|
|
|
|
|
|
|
|
const currentMessage = messages[currentMessageIndex]; |
|
|
const lastMessage = visibleMessages[visibleMessages.length - 1]; |
|
|
|
|
|
if (lastMessage?.message_id === currentMessage.message_id) { |
|
|
|
|
|
updatePlaybackState({ |
|
|
visibleMessages: [ |
|
|
...visibleMessages.slice(0, -1), |
|
|
currentMessage, |
|
|
], |
|
|
}); |
|
|
} else { |
|
|
|
|
|
updatePlaybackState({ |
|
|
visibleMessages: [...visibleMessages, currentMessage], |
|
|
}); |
|
|
} |
|
|
|
|
|
onComplete(); |
|
|
return; |
|
|
} |
|
|
|
|
|
const currentChunk = chunks[chunkIndex]; |
|
|
|
|
|
|
|
|
if (currentChunk.isTool && currentIndex === 0) { |
|
|
|
|
|
if ( |
|
|
currentChunk.toolName && |
|
|
HIDE_STREAMING_XML_TAGS.has(currentChunk.toolName) |
|
|
) { |
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
isPaused = true; |
|
|
setTimeout(() => { |
|
|
isPaused = false; |
|
|
updatePlaybackState({ currentToolCall: null }); |
|
|
chunkIndex++; |
|
|
currentIndex = 0; |
|
|
processNextCharacter(); |
|
|
}, 500); |
|
|
|
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (currentIndex < currentChunk.text.length) { |
|
|
|
|
|
const baseDelay = 5; |
|
|
let typingDelay = baseDelay; |
|
|
|
|
|
|
|
|
const char = currentChunk.text[currentIndex]; |
|
|
if ('.!?,;:'.includes(char)) { |
|
|
typingDelay = baseDelay + Math.random() * 100 + 50; |
|
|
} else { |
|
|
const variableDelay = Math.random() * 5; |
|
|
typingDelay = baseDelay + variableDelay; |
|
|
} |
|
|
|
|
|
|
|
|
currentText += currentChunk.text[currentIndex]; |
|
|
updatePlaybackState({ streamingText: currentText }); |
|
|
currentIndex++; |
|
|
|
|
|
|
|
|
setTimeout(processNextCharacter, typingDelay); |
|
|
} else { |
|
|
|
|
|
chunkIndex++; |
|
|
currentIndex = 0; |
|
|
processNextCharacter(); |
|
|
} |
|
|
}; |
|
|
|
|
|
processNextCharacter(); |
|
|
|
|
|
|
|
|
return () => { |
|
|
updatePlaybackState({ |
|
|
isStreamingText: false, |
|
|
streamingText: '', |
|
|
}); |
|
|
isPaused = true; |
|
|
}; |
|
|
}, |
|
|
[ |
|
|
isPlaying, |
|
|
messages, |
|
|
currentMessageIndex, |
|
|
toolPlaybackIndex, |
|
|
setCurrentToolIndex, |
|
|
isSidePanelOpen, |
|
|
onToggleSidePanel, |
|
|
updatePlaybackState, |
|
|
visibleMessages, |
|
|
], |
|
|
); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (!isPlaying || messages.length === 0) return; |
|
|
|
|
|
let playbackTimeout: NodeJS.Timeout; |
|
|
let cleanupStreaming: (() => void) | undefined; |
|
|
|
|
|
const playbackNextMessage = async () => { |
|
|
|
|
|
if (currentMessageIndex >= messages.length) { |
|
|
updatePlaybackState({ isPlaying: false }); |
|
|
return; |
|
|
} |
|
|
|
|
|
const currentMessage = messages[currentMessageIndex]; |
|
|
console.log( |
|
|
`Playing message ${currentMessageIndex}:`, |
|
|
currentMessage.type, |
|
|
currentMessage.message_id, |
|
|
); |
|
|
|
|
|
|
|
|
if (currentMessage.type === 'assistant') { |
|
|
try { |
|
|
|
|
|
let content = currentMessage.content; |
|
|
try { |
|
|
const parsed = JSON.parse(content); |
|
|
if (parsed.content) { |
|
|
content = parsed.content; |
|
|
} |
|
|
} catch (e) { |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
await new Promise<void>((resolve) => { |
|
|
cleanupStreaming = streamText(content, resolve); |
|
|
}); |
|
|
} catch (error) { |
|
|
console.error('Error streaming message:', error); |
|
|
} |
|
|
} else { |
|
|
|
|
|
updatePlaybackState({ |
|
|
visibleMessages: [...visibleMessages, currentMessage], |
|
|
}); |
|
|
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 500)); |
|
|
} |
|
|
|
|
|
|
|
|
updatePlaybackState({ |
|
|
currentMessageIndex: currentMessageIndex + 1, |
|
|
}); |
|
|
}; |
|
|
|
|
|
|
|
|
playbackTimeout = setTimeout(playbackNextMessage, 500); |
|
|
|
|
|
return () => { |
|
|
clearTimeout(playbackTimeout); |
|
|
if (cleanupStreaming) cleanupStreaming(); |
|
|
}; |
|
|
}, [ |
|
|
isPlaying, |
|
|
currentMessageIndex, |
|
|
messages, |
|
|
streamText, |
|
|
updatePlaybackState, |
|
|
visibleMessages, |
|
|
]); |
|
|
|
|
|
|
|
|
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'; |
|
|
|
|
|
|
|
|
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, |
|
|
], |
|
|
); |
|
|
|
|
|
|
|
|
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; |
|
|
|