sandbox connected and streaming
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -29,9 +29,6 @@ export function useStateSync(stores: Record<string, Store>) {
|
||||
};
|
||||
|
||||
const handleMessage = (msg: BackendMessage) => {
|
||||
console.log('[StateSync] Received WebSocket message:', msg);
|
||||
console.log('[StateSync] Parsed message type:', msg.type);
|
||||
|
||||
if (msg.type === 'snapshot') {
|
||||
console.log('[StateSync] Processing snapshot for store:', msg.store);
|
||||
const store = stores[msg.store];
|
||||
@@ -77,8 +74,6 @@ export function useStateSync(stores: Record<string, Store>) {
|
||||
} else {
|
||||
console.warn('[StateSync] Store not found:', msg.store);
|
||||
}
|
||||
} else {
|
||||
console.log('[StateSync] Ignoring message type:', msg.type);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -78,9 +78,6 @@ export class WebSocketDatafeed implements IBasicDataFeed {
|
||||
}
|
||||
|
||||
private handleMessage(message: any): void {
|
||||
console.log('[TradingView Datafeed] Received message:', message.type, message)
|
||||
console.log('[TradingView Datafeed] Pending requests count:', this.pendingRequests.size)
|
||||
|
||||
// Handle responses to pending requests
|
||||
if (message.request_id && this.pendingRequests.has(message.request_id)) {
|
||||
console.log('[TradingView Datafeed] Found pending request for:', message.request_id)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type SessionStatus = 'disconnected' | 'authenticating' | 'spinning_up' | 'initializing' | 'ready' | 'error'
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: string
|
||||
[key: string]: any
|
||||
@@ -13,6 +15,8 @@ class WebSocketManager {
|
||||
private reconnectTimeout: number | null = null
|
||||
public isConnected = ref(false)
|
||||
public isAuthenticated = ref(false)
|
||||
public sessionStatus = ref<SessionStatus>('disconnected')
|
||||
public statusMessage = ref('')
|
||||
private token: string | null = null
|
||||
private messageQueue: WebSocketMessage[] = []
|
||||
private reconnectAttempts = 0
|
||||
@@ -78,12 +82,19 @@ class WebSocketManager {
|
||||
const message = JSON.parse(event.data)
|
||||
console.log('[WebSocket] Parsed message type:', message.type)
|
||||
|
||||
// Mark as authenticated when we receive the 'connected' message
|
||||
if (message.type === 'connected') {
|
||||
if (message.type === 'status') {
|
||||
this.sessionStatus.value = message.status as SessionStatus
|
||||
this.statusMessage.value = message.message ?? ''
|
||||
} else if (message.type === 'connected') {
|
||||
console.log('[WebSocket] Received connected message, marking as authenticated')
|
||||
this.isAuthenticated.value = true
|
||||
this.sessionStatus.value = 'ready'
|
||||
this.statusMessage.value = ''
|
||||
// Flush any queued messages now that we're authenticated
|
||||
this.flushMessageQueue()
|
||||
} else if (message.type === 'error') {
|
||||
this.sessionStatus.value = 'error'
|
||||
this.statusMessage.value = message.message ?? ''
|
||||
}
|
||||
|
||||
// Pass to all handlers
|
||||
@@ -103,6 +114,8 @@ class WebSocketManager {
|
||||
this.ws.onclose = (event) => {
|
||||
this.isConnected.value = false
|
||||
this.isAuthenticated.value = false
|
||||
this.sessionStatus.value = 'disconnected'
|
||||
this.statusMessage.value = ''
|
||||
console.log('WebSocket disconnected:', event.code, event.reason)
|
||||
|
||||
// Attempt to reconnect if we have a token
|
||||
@@ -198,6 +211,8 @@ class WebSocketManager {
|
||||
this.ws = null
|
||||
}
|
||||
this.isAuthenticated.value = false
|
||||
this.sessionStatus.value = 'disconnected'
|
||||
this.statusMessage.value = ''
|
||||
}
|
||||
|
||||
getWebSocket() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { wsManager } from '../composables/useWebSocket'
|
||||
|
||||
export interface ChannelInfo {
|
||||
type: string
|
||||
@@ -20,5 +21,12 @@ export interface ChannelState {
|
||||
export const useChannelStore = defineStore('channelState', () => {
|
||||
const connected = ref<Record<string, ChannelInfo>>({})
|
||||
|
||||
return { connected }
|
||||
// Session readiness — computed getters backed by wsManager refs.
|
||||
// Using computed (not ref) keeps these out of $state so useStateSync won't
|
||||
// pick them up and try to send patches to the gateway.
|
||||
const sessionStatus = computed(() => wsManager.sessionStatus.value)
|
||||
const statusMessage = computed(() => wsManager.statusMessage.value)
|
||||
const isReady = computed(() => wsManager.sessionStatus.value === 'ready')
|
||||
|
||||
return { connected, sessionStatus, statusMessage, isReady }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user