sandbox connected and streaming

This commit is contained in:
2026-03-30 23:29:03 -04:00
parent c3a8fae132
commit 998f69fa1a
130 changed files with 7416 additions and 2123 deletions

View File

@@ -5,9 +5,12 @@ import Badge from 'primevue/badge'
import Button from 'primevue/button'
import { wsManager } from '../composables/useWebSocket'
import type { WebSocketMessage } from '../composables/useWebSocket'
import { useChannelStore } from '../stores/channel'
register()
const channelStore = useChannelStore()
const SESSION_ID = 'default'
const CURRENT_USER_ID = 'user-123'
const AGENT_ID = 'agent'
@@ -33,46 +36,110 @@ const rooms = computed(() => [{
// Streaming state
let currentStreamingMessageId: string | null = null
let lastSentMessageId: string | null = null
let streamingBuffer = ''
const isAgentProcessing = ref(false)
const toolCallStatus = ref<string | null>(null)
// Generate message ID
const generateMessageId = () => `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
// Storage for images received during streaming
const streamingImages = ref<any[]>([])
// Handle WebSocket messages
const handleMessage = (data: WebSocketMessage) => {
console.log('[ChatPanel] Received message:', data)
if (data.type === 'agent_chunk') {
if (data.type === 'agent_tool_call') {
toolCallStatus.value = data.label ?? data.toolName ?? null
return
}
if (data.type === 'image') {
// Handle image message - attach to current streaming message or create standalone
console.log('[ChatPanel] Processing image message')
const imageFile = {
name: `chart_${Date.now()}.png`,
size: 0,
type: 'png',
url: `data:${data.mimeType};base64,${data.data}`,
preview: `data:${data.mimeType};base64,${data.data}`
}
if (currentStreamingMessageId) {
// Attach to current streaming message
streamingImages.value.push(imageFile)
const msgIndex = messages.value.findIndex(m => m._id === currentStreamingMessageId)
if (msgIndex !== -1) {
messages.value[msgIndex] = {
...messages.value[msgIndex],
files: [...streamingImages.value]
}
messages.value = [...messages.value]
}
} else {
// No active streaming message - create a standalone image message
const timestamp = new Date().toTimeString().split(' ')[0].slice(0, 5)
messages.value = [...messages.value, {
_id: generateMessageId(),
content: data.caption || '',
senderId: AGENT_ID,
timestamp: timestamp,
date: new Date().toLocaleDateString(),
saved: true,
distributed: true,
seen: true,
files: [imageFile]
}]
}
} else if (data.type === 'agent_chunk') {
console.log('[ChatPanel] Processing agent_chunk, content:', data.content, 'done:', data.done)
const timestamp = new Date().toTimeString().split(' ')[0].slice(0, 5)
if (!currentStreamingMessageId) {
console.log('[ChatPanel] Starting new streaming message')
// Start new streaming message
// Set up streaming state and mark user message as seen
isAgentProcessing.value = true
currentStreamingMessageId = generateMessageId()
streamingBuffer = data.content
streamingImages.value = []
toolCallStatus.value = null
messages.value = [...messages.value, {
_id: currentStreamingMessageId,
content: streamingBuffer,
senderId: AGENT_ID,
timestamp: timestamp,
date: new Date().toLocaleDateString(),
saved: false,
distributed: false,
seen: false,
files: []
}]
// Mark the last sent user message as seen (double-checkmark)
if (lastSentMessageId) {
const idx = messages.value.findIndex(m => m._id === lastSentMessageId)
if (idx !== -1) {
messages.value[idx] = { ...messages.value[idx], seen: true }
messages.value = [...messages.value]
}
lastSentMessageId = null
}
// Only add the agent bubble once there is actual content to show
if (data.content) {
messages.value = [...messages.value, {
_id: currentStreamingMessageId,
content: streamingBuffer,
senderId: AGENT_ID,
timestamp: timestamp,
date: new Date().toLocaleDateString(),
saved: false,
distributed: false,
seen: false,
files: []
}]
}
} else {
// Update existing streaming message
// Update (or lazily create) the streaming message
streamingBuffer += data.content
const msgIndex = messages.value.findIndex(m => m._id === currentStreamingMessageId)
if (msgIndex !== -1) {
const updatedMessage: any = {
...messages.value[msgIndex],
content: streamingBuffer
content: streamingBuffer,
files: [...streamingImages.value] // Include accumulated images
}
// Add plot images if present in metadata
@@ -84,11 +151,25 @@ const handleMessage = (data: WebSocketMessage) => {
url: `${BACKEND_URL}${url}`,
preview: `${BACKEND_URL}${url}`
}))
updatedMessage.files = plotFiles
updatedMessage.files = [...updatedMessage.files, ...plotFiles]
}
messages.value[msgIndex] = updatedMessage
messages.value = [...messages.value]
} else if (streamingBuffer) {
// First chunk with content after an empty ack — create the bubble now
const timestamp2 = new Date().toTimeString().split(' ')[0].slice(0, 5)
messages.value = [...messages.value, {
_id: currentStreamingMessageId!,
content: streamingBuffer,
senderId: AGENT_ID,
timestamp: timestamp2,
date: new Date().toLocaleDateString(),
saved: false,
distributed: false,
seen: false,
files: [...streamingImages.value]
}]
}
}
@@ -100,7 +181,8 @@ const handleMessage = (data: WebSocketMessage) => {
...messages.value[msgIndex],
saved: true,
distributed: true,
seen: true
seen: true,
files: [...streamingImages.value] // Include all accumulated images
}
// Ensure plot images are included in final message
@@ -112,7 +194,7 @@ const handleMessage = (data: WebSocketMessage) => {
url: `${BACKEND_URL}${url}`,
preview: `${BACKEND_URL}${url}`
}))
finalMessage.files = plotFiles
finalMessage.files = [...finalMessage.files, ...plotFiles]
}
messages.value[msgIndex] = finalMessage
@@ -121,7 +203,9 @@ const handleMessage = (data: WebSocketMessage) => {
currentStreamingMessageId = null
streamingBuffer = ''
streamingImages.value = []
isAgentProcessing.value = false
toolCallStatus.value = null
}
}
}
@@ -137,6 +221,8 @@ const stopAgent = () => {
}
wsManager.send(wsMessage)
isAgentProcessing.value = false
toolCallStatus.value = null
lastSentMessageId = null
}
// Send message handler
@@ -216,14 +302,18 @@ const sendMessage = async (event: any) => {
}
wsManager.send(wsMessage)
// Mark as distributed
// Track this message so the agent_chunk handler can mark it seen
lastSentMessageId = messageId
// Show typing indicator immediately (before first chunk arrives)
isAgentProcessing.value = true
// Mark as distributed (single checkmark) after confirming WS send
setTimeout(() => {
const msgIndex = messages.value.findIndex(m => m._id === messageId)
if (msgIndex !== -1) {
messages.value[msgIndex] = {
...messages.value[msgIndex],
distributed: true,
seen: true
}
messages.value = [...messages.value]
}
@@ -243,44 +333,42 @@ const openFile = ({ file }: any) => {
// Theme configuration for dark mode
const chatTheme = 'dark'
// Styles to match PrimeVue theme
// Styles to match TradingView dark theme
const chatStyles = computed(() => JSON.stringify({
general: {
color: '#cdd6e8',
colorSpinner: '#00d4aa',
borderStyle: '1px solid #263452'
color: '#d1d4dc',
colorSpinner: '#2962ff',
borderStyle: '1px solid #2a2e39'
},
container: {
background: '#0a0e1a'
background: '#131722'
},
header: {
background: '#0f1629',
colorRoomName: '#cdd6e8',
colorRoomInfo: '#8892a4'
background: '#1e222d',
colorRoomName: '#d1d4dc',
colorRoomInfo: '#787b86'
},
footer: {
background: '#0f1629',
borderStyleInput: '1px solid #263452',
backgroundInput: '#161e35',
colorInput: '#cdd6e8',
colorPlaceholder: '#8892a4',
colorIcons: '#8892a4'
background: '#1e222d',
borderStyleInput: '1px solid #2a2e39',
backgroundInput: '#1e222d',
colorInput: '#d1d4dc',
colorPlaceholder: '#787b86',
colorIcons: '#787b86'
},
content: {
background: '#0a0e1a'
background: '#131722'
},
message: {
background: '#161e35',
backgroundMe: '#1e2d4f',
color: '#cdd6e8',
colorMe: '#cdd6e8'
background: '#1e222d',
backgroundMe: '#2962ff',
color: '#d1d4dc',
colorMe: '#ffffff'
}
}))
onMounted(() => {
wsManager.addHandler(handleMessage)
// Mark messages as loaded after initialization
messagesLoaded.value = true
// Focus on the chat input when component mounts
setTimeout(() => {
@@ -308,7 +396,14 @@ onUnmounted(() => {
</div>
-->
<!-- Workspace loading overlay -->
<div v-if="!channelStore.isReady" class="workspace-loading">
<i class="pi pi-spin pi-spinner workspace-loading-spinner" />
<span class="workspace-loading-message">{{ channelStore.statusMessage || 'Connecting...' }}</span>
</div>
<vue-advanced-chat
v-else
height="100vh"
:current-user-id="CURRENT_USER_ID"
:rooms="JSON.stringify(rooms)"
@@ -324,6 +419,7 @@ onUnmounted(() => {
:show-files="false"
:show-emojis="true"
:show-reaction-emojis="false"
:message-actions="JSON.stringify([])"
:accepted-files="'image/*,video/*,application/pdf'"
:message-images="true"
@send-message="sendMessage"
@@ -333,6 +429,7 @@ onUnmounted(() => {
<!-- Stop button overlay -->
<div v-if="isAgentProcessing" class="stop-button-container">
<div v-if="toolCallStatus" class="tool-call-status">{{ toolCallStatus }}</div>
<Button
icon="pi pi-stop-circle"
label="Stop"
@@ -354,6 +451,26 @@ onUnmounted(() => {
position: relative;
}
.workspace-loading {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: #131722;
color: #787b86;
}
.workspace-loading-spinner {
font-size: 2rem;
color: #787b86;
}
.workspace-loading-message {
font-size: 0.875rem;
}
.chat-container :deep(.vac-container) {
height: 100% !important;
}
@@ -362,6 +479,12 @@ onUnmounted(() => {
white-space: pre-wrap;
}
/* Override the default 50% desktop width on the inner message box */
.chat-container :deep(.vac-message-wrapper .vac-message-box) {
flex: 0 0 80% !important;
max-width: 80% !important;
}
.chat-header-custom {
display: flex;
justify-content: space-between;
@@ -385,6 +508,17 @@ onUnmounted(() => {
z-index: 1000;
}
.tool-call-status {
background: rgba(30, 34, 45, 0.92);
color: #787b86;
font-size: 0.75rem;
padding: 4px 10px;
border-radius: 4px;
margin-bottom: 6px;
text-align: center;
border: 1px solid #2a2e39;
}
.stop-button {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: pulse 2s infinite;