diff --git a/web/src/components/O2AIChat.vue b/web/src/components/O2AIChat.vue index 2f4d41b62c..a557b0182c 100644 --- a/web/src/components/O2AIChat.vue +++ b/web/src/components/O2AIChat.vue @@ -9,34 +9,43 @@ -
- O2 Assistant - - Beta -
- - -
- - + +
+ + {{ displayedTitle || 'New Chat' }} + + {{ displayedTitle }} + + + +
- - +
- +
@@ -49,11 +58,25 @@ v-close-popup @click="loadChat(chat.id)" dense + class="history-item" > -
-
{{ chat.title }}
-
{{ formatTime(chat.timestamp) }}
+
+
+
{{ chat.title }}
+
{{ formatTime(chat.timestamp) }}
+
+ + Delete chat +
@@ -64,15 +87,44 @@
+ + +
+ + +
- +
+ +
+ + + Edit title + + +
- + @@ -106,12 +158,68 @@ + + + + +
Edit Chat Title
+
+ + + + + + + + + +
+
+ + + + + + +
- AI native observability +
+ O2 Assistant + BETA +
-
- - {{ currentAnalyzingMessage }} +
+
+ + {{ currentAnalyzingMessage }} +
@@ -339,6 +449,25 @@
+ + +
+ +
+ + {{ activeToolCall.message }} +
+ +
+ + {{ currentAnalyzingMessage }} +
+
+
(null); + + // AI-generated chat title state + const aiGeneratedTitle = ref(null); + const displayedTitle = ref(''); + const isTypingTitle = ref(false); + const titleAnimationId = ref(0); // Used to cancel stale animations + // Track expanded tool calls by message index and block index const expandedToolCalls = ref>(new Set()); @@ -520,6 +670,11 @@ export default defineComponent({ // AbortController for managing request cancellation - allows users to stop ongoing AI requests const currentAbortController = ref(null); + // Typewriter animation state for LLM responses + const displayedStreamingContent = ref(''); + const typewriterAnimationId = ref(null); + const TYPEWRITER_SPEED = 8; // ms per character - fast like ChatGPT (5-10ms range) + // Throttle save during streaming to prevent data loss on page reload const lastStreamingSaveTime = ref(0); const STREAMING_SAVE_INTERVAL = 3000; // Save at most every 3 seconds during streaming @@ -570,6 +725,120 @@ export default defineComponent({ } }; + // Interval ID for title animation + let titleIntervalId: ReturnType | null = null; + + /** + * Animate title with typewriter effect + * Characters appear one by one from left to right + * Uses setInterval for reliable timing with Vue reactivity + */ + const animateTitle = (title: string) => { + // Clear any existing animation + if (titleIntervalId) { + clearInterval(titleIntervalId); + titleIntervalId = null; + } + + // Increment animation ID to track this animation + const currentAnimationId = ++titleAnimationId.value; + + isTypingTitle.value = true; + displayedTitle.value = ''; + let charIndex = 0; + + titleIntervalId = setInterval(() => { + // Check if this animation was superseded + if (titleAnimationId.value !== currentAnimationId) { + if (titleIntervalId) { + clearInterval(titleIntervalId); + titleIntervalId = null; + } + return; + } + + if (charIndex < title.length) { + displayedTitle.value = title.slice(0, charIndex + 1); + charIndex++; + } else { + // Animation complete + if (titleIntervalId) { + clearInterval(titleIntervalId); + titleIntervalId = null; + } + isTypingTitle.value = false; + } + }, 30); // 30ms per character + }; + + /** + * Reset title state for new chat + */ + const resetTitleState = () => { + // Cancel any ongoing animation + titleAnimationId.value++; + if (titleIntervalId) { + clearInterval(titleIntervalId); + titleIntervalId = null; + } + aiGeneratedTitle.value = null; + displayedTitle.value = ''; + isTypingTitle.value = false; + }; + + /** + * Reset typewriter animation state + */ + const resetTypewriterState = () => { + displayedStreamingContent.value = ''; + if (typewriterAnimationId.value) { + cancelAnimationFrame(typewriterAnimationId.value); + typewriterAnimationId.value = null; + } + }; + + /** + * Animate text reveal with typewriter effect + * Skips animation for code blocks (reveals them instantly) + */ + const animateStreamingText = () => { + const target = currentTextSegment.value; + const current = displayedStreamingContent.value; + + if (current.length >= target.length) { + // Caught up, stop animation + if (typewriterAnimationId.value) { + cancelAnimationFrame(typewriterAnimationId.value); + typewriterAnimationId.value = null; + } + return; + } + + // Check if we're at the start of a code block - if so, skip to end of code block + const remaining = target.slice(current.length); + const codeBlockStart = remaining.match(/^```[\w]*/); + + if (codeBlockStart) { + // Find the closing ``` and reveal entire code block instantly + const codeBlockEnd = remaining.indexOf('```', codeBlockStart[0].length); + if (codeBlockEnd !== -1) { + const endPos = codeBlockEnd + 3; + displayedStreamingContent.value = target.slice(0, current.length + endPos); + } else { + // Code block not complete yet, reveal opening and wait + displayedStreamingContent.value = target.slice(0, current.length + codeBlockStart[0].length); + } + } else { + // Regular text - reveal one character + displayedStreamingContent.value = target.slice(0, current.length + 1); + } + + // Schedule next frame + typewriterAnimationId.value = requestAnimationFrame(() => { + setTimeout(animateStreamingText, TYPEWRITER_SPEED); + }); + }; + // Query history functionality const queryHistory = ref([]); const historyIndex = ref(-1); @@ -690,6 +959,13 @@ export default defineComponent({ activeToolCall.value = null; stopAnalyzingRotation(); + // Immediately show all buffered text (like ChatGPT's "Stop generating") + displayedStreamingContent.value = currentTextSegment.value; + if (typewriterAnimationId.value) { + cancelAnimationFrame(typewriterAnimationId.value); + typewriterAnimationId.value = null; + } + // Handle partial message cleanup if (chatMessages.value.length > 0) { const lastMessage = chatMessages.value[chatMessages.value.length - 1]; @@ -698,15 +974,23 @@ export default defineComponent({ // Remove empty assistant message that was added for streaming chatMessages.value.pop(); } else if (currentStreamingMessage.value) { + // Update final text in contentBlocks to show all buffered content + if (lastMessage.contentBlocks) { + const lastBlock = lastMessage.contentBlocks[lastMessage.contentBlocks.length - 1]; + if (lastBlock && lastBlock.type === 'text') { + lastBlock.text = currentTextSegment.value; + } + } // Keep partial content but indicate it was cancelled lastMessage.content += '\n\n_[Response stopped by user]_'; } } } - + // Reset streaming state currentStreamingMessage.value = ''; currentTextSegment.value = ''; + displayedStreamingContent.value = ''; // Save the current state including cancellation await saveToHistory(); @@ -784,6 +1068,13 @@ export default defineComponent({ try { const data = JSON.parse(jsonStr); + // Handle title events - AI-generated chat title from first message + if (data && data.type === 'title') { + aiGeneratedTitle.value = data.title; + animateTitle(data.title); + continue; + } + // Handle tool_call events - show spinner indicator, don't add to chat yet if (data && data.type === 'tool_call') { // If there's already an active tool call, complete it first @@ -944,14 +1235,20 @@ export default defineComponent({ currentStreamingMessage.value += content; currentTextSegment.value += content; + // Start typewriter animation if not already running + if (!typewriterAnimationId.value) { + animateStreamingText(); + } + // Get or create assistant message let lastMessage = chatMessages.value[chatMessages.value.length - 1]; if (!lastMessage || lastMessage.role !== 'assistant') { // Create new assistant message with pending tool calls + text + // Use displayedStreamingContent for animated display chatMessages.value.push({ role: 'assistant', content: currentStreamingMessage.value, - contentBlocks: [...pendingToolCalls.value, { type: 'text', text: currentTextSegment.value }] + contentBlocks: [...pendingToolCalls.value, { type: 'text', text: displayedStreamingContent.value }] }); pendingToolCalls.value = []; // Clear pending // Save immediately when assistant message is first created to prevent data loss on reload @@ -968,11 +1265,11 @@ export default defineComponent({ // Find the last text block and update it, or create new one const lastBlock = lastMessage.contentBlocks[lastMessage.contentBlocks.length - 1]; if (lastBlock && lastBlock.type === 'text') { - // Append to existing text block (same segment) - lastBlock.text = currentTextSegment.value; + // Append to existing text block (same segment) - use animated content for display + lastBlock.text = displayedStreamingContent.value; } else { // Add new text block (after tool call - new segment) - lastMessage.contentBlocks.push({ type: 'text', text: currentTextSegment.value }); + lastMessage.contentBlocks.push({ type: 'text', text: displayedStreamingContent.value }); } // Throttled save during streaming to preserve progress await throttledStreamingSave(); @@ -1003,6 +1300,13 @@ export default defineComponent({ const data = JSON.parse(jsonStr); + // Handle title events - AI-generated chat title from first message + if (data && data.type === 'title') { + aiGeneratedTitle.value = data.title; + animateTitle(data.title); + continue; + } + // Handle tool_call events - show spinner, don't add to chat yet if (data && data.type === 'tool_call') { // If there's already an active tool call, complete it first @@ -1159,13 +1463,19 @@ export default defineComponent({ currentStreamingMessage.value += content; currentTextSegment.value += content; + // Start typewriter animation if not already running + if (!typewriterAnimationId.value) { + animateStreamingText(); + } + let lastMessage = chatMessages.value[chatMessages.value.length - 1]; if (!lastMessage || lastMessage.role !== 'assistant') { // Create new assistant message with pending tool calls + text + // Use displayedStreamingContent for animated display chatMessages.value.push({ role: 'assistant', content: currentStreamingMessage.value, - contentBlocks: [...pendingToolCalls.value, { type: 'text', text: currentTextSegment.value }] + contentBlocks: [...pendingToolCalls.value, { type: 'text', text: displayedStreamingContent.value }] }); pendingToolCalls.value = []; // Clear pending // Save immediately when assistant message is first created @@ -1178,9 +1488,10 @@ export default defineComponent({ } const lastBlock = lastMessage.contentBlocks[lastMessage.contentBlocks.length - 1]; if (lastBlock && lastBlock.type === 'text') { - lastBlock.text = currentTextSegment.value; + // Use animated content for display + lastBlock.text = displayedStreamingContent.value; } else { - lastMessage.contentBlocks.push({ type: 'text', text: currentTextSegment.value }); + lastMessage.contentBlocks.push({ type: 'text', text: displayedStreamingContent.value }); } // Throttled save during streaming await throttledStreamingSave(); @@ -1198,6 +1509,20 @@ export default defineComponent({ // If we completed a message, save to history if (messageComplete) { + // Immediately show all remaining text and stop typewriter animation + displayedStreamingContent.value = currentTextSegment.value; + if (typewriterAnimationId.value) { + cancelAnimationFrame(typewriterAnimationId.value); + typewriterAnimationId.value = null; + } + // Update final text in contentBlocks + const lastMessage = chatMessages.value[chatMessages.value.length - 1]; + if (lastMessage && lastMessage.role === 'assistant' && lastMessage.contentBlocks) { + const lastBlock = lastMessage.contentBlocks[lastMessage.contentBlocks.length - 1]; + if (lastBlock && lastBlock.type === 'text') { + lastBlock.text = currentTextSegment.value; + } + } await saveToHistory(); } } catch (error) { @@ -1221,13 +1546,13 @@ export default defineComponent({ const transaction = db.transaction(STORE_NAME, 'readwrite'); const DbIndexStore = transaction.objectStore(STORE_NAME); - // Generate a title from the first user message + // Prefer AI-generated title, fallback to truncated first user message const firstUserMessage = chatMessages.value.find(msg => msg.role === 'user'); - const title = firstUserMessage ? - (firstUserMessage.content.length > 40 ? - firstUserMessage.content.substring(0, 40) + '...' : - firstUserMessage.content) : - 'New Chat'; + const title = aiGeneratedTitle.value || (firstUserMessage ? + (firstUserMessage.content.length > 40 ? + firstUserMessage.content.substring(0, 40) + '...' : + firstUserMessage.content) : + 'New Chat'); // Create a serializable version of the messages (including contentBlocks) // Use JSON parse/stringify to strip Vue reactive proxies @@ -1356,6 +1681,8 @@ export default defineComponent({ showHistory.value = false; currentChatTimestamp.value = null; shouldAutoScroll.value = true; // Reset auto-scroll for new chat + resetTitleState(); // Clear AI-generated title for new chat + resetTypewriterState(); // Clear typewriter animation state for new chat store.dispatch('setCurrentChatTimestamp', null); store.dispatch('setChatUpdated', true); }; @@ -1365,6 +1692,129 @@ export default defineComponent({ await loadHistory(); }; + const openEditTitleDialog = () => { + editingTitle.value = displayedTitle.value || ''; + showEditTitleDialog.value = true; + }; + + const saveEditedTitle = async () => { + if (!currentChatId.value || !editingTitle.value.trim()) { + showEditTitleDialog.value = false; + return; + } + + try { + const db = await initDB(); + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(currentChatId.value); + + request.onsuccess = () => { + const chat = request.result; + if (chat) { + // Update the title + chat.title = editingTitle.value.trim(); + const updateRequest = store.put(chat); + + updateRequest.onsuccess = () => { + // Update the displayed title + displayedTitle.value = editingTitle.value.trim(); + aiGeneratedTitle.value = editingTitle.value.trim(); + + // Reload history to reflect changes + loadHistory(); + + showEditTitleDialog.value = false; + }; + + updateRequest.onerror = () => { + console.error('Error updating chat title:', updateRequest.error); + showEditTitleDialog.value = false; + }; + } + }; + + request.onerror = () => { + console.error('Error retrieving chat for edit:', request.error); + showEditTitleDialog.value = false; + }; + } catch (error) { + console.error('Error updating chat title:', error); + showEditTitleDialog.value = false; + } + }; + + const deleteChat = (chatId: number) => { + chatToDelete.value = chatId; + showDeleteChatConfirmDialog.value = true; + }; + + const confirmDeleteChat = async () => { + if (!chatToDelete.value) return; + + try { + const db = await initDB(); + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const deleteRequest = store.delete(chatToDelete.value); + + deleteRequest.onsuccess = () => { + // If the deleted chat is the current one, reset to new chat + if (currentChatId.value === chatToDelete.value) { + addNewChat(); + } + + // Reload history to reflect changes + loadHistory(); + + // Reset state + chatToDelete.value = null; + showDeleteChatConfirmDialog.value = false; + }; + + deleteRequest.onerror = () => { + console.error('Error deleting chat:', deleteRequest.error); + chatToDelete.value = null; + showDeleteChatConfirmDialog.value = false; + }; + } catch (error) { + console.error('Error deleting chat:', error); + chatToDelete.value = null; + showDeleteChatConfirmDialog.value = false; + } + }; + + const clearAllConversations = () => { + showClearAllConfirmDialog.value = true; + }; + + const confirmClearAllConversations = async () => { + try { + const db = await initDB(); + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const clearRequest = store.clear(); + + clearRequest.onsuccess = () => { + // Reset to new chat + addNewChat(); + + // Clear the chat history array + chatHistory.value = []; + + showClearAllConfirmDialog.value = false; + }; + + clearRequest.onerror = () => { + console.error('Error clearing all conversations:', clearRequest.error); + showClearAllConfirmDialog.value = false; + }; + } catch (error) { + console.error('Error clearing all conversations:', error); + showClearAllConfirmDialog.value = false; + } + }; + const loadChat = async (chatId: number) => { try { @@ -1395,13 +1845,18 @@ export default defineComponent({ currentSessionId.value = chat.sessionId || null; // Restore session ID from history showHistory.value = false; shouldAutoScroll.value = true; // Reset auto-scroll when loading chat - + + // Load title from history (no animation for existing chats) + displayedTitle.value = chat.title || ''; + aiGeneratedTitle.value = chat.title || null; + isTypingTitle.value = false; + if(chatId !== store.state.currentChatTimestamp) { store.dispatch('setCurrentChatTimestamp', chatId); store.dispatch('setChatUpdated', true); } - - + + // Scroll to bottom after loading chat await nextTick(() => { scrollToBottom(); @@ -1438,6 +1893,7 @@ export default defineComponent({ isLoading.value = true; currentStreamingMessage.value = ''; currentTextSegment.value = ''; + resetTypewriterState(); // Reset typewriter animation for new message startAnalyzingRotation(); // Start rotating analyzing messages // Create new AbortController for this request - enables cancellation via Stop button @@ -1682,6 +2138,18 @@ export default defineComponent({ currentAbortController.value.abort(); currentAbortController.value = null; } + + // Clean up typewriter animation to prevent memory leaks + if (typewriterAnimationId.value) { + cancelAnimationFrame(typewriterAnimationId.value); + typewriterAnimationId.value = null; + } + + // Clean up title animation interval + if (titleIntervalId) { + clearInterval(titleIntervalId); + titleIntervalId = null; + } //this step is added because we are using seperate instances of o2 ai chat component to make sync between them //whenever a new chat is created or a new message is sent, the currentChatTimestamp is set to the chatId @@ -1695,7 +2163,7 @@ export default defineComponent({ addNewChat(); } }) - //this watch is added to make sure that the chat gets updated + //this watch is added to make sure that the chat gets updated // when the component is unmounted so that the main layout component can load the correct chat watch(chatUpdated, (newChatUpdated: boolean) => { if (newChatUpdated && store.state.currentChatTimestamp) { @@ -1707,6 +2175,19 @@ export default defineComponent({ store.dispatch('setChatUpdated', false); }); + // Watch for typewriter animation updates to refresh the displayed text + watch(displayedStreamingContent, (newContent) => { + if (!isLoading.value) return; + + const lastMessage = chatMessages.value[chatMessages.value.length - 1]; + if (lastMessage && lastMessage.role === 'assistant' && lastMessage.contentBlocks) { + const lastBlock = lastMessage.contentBlocks[lastMessage.contentBlocks.length - 1]; + if (lastBlock && lastBlock.type === 'text') { + lastBlock.text = newContent; + } + } + }); + const copyToClipboard = async (text: string) => { try { await navigator.clipboard.writeText(text); @@ -2013,9 +2494,21 @@ export default defineComponent({ selectCapability, showHistory, chatHistory, + currentChatId, addNewChat, openHistory, loadChat, + showEditTitleDialog, + editingTitle, + openEditTitleDialog, + saveEditedTitle, + deleteChat, + confirmDeleteChat, + showDeleteChatConfirmDialog, + chatToDelete, + clearAllConversations, + showClearAllConfirmDialog, + confirmClearAllConversations, processedMessages, pendingToolCalls, processTextBlock, @@ -2055,6 +2548,10 @@ export default defineComponent({ formatToolCallMessage, formatTimestamp, formatContextValue, + // AI-generated title + aiGeneratedTitle, + displayedTitle, + isTypingTitle, } } }); @@ -2063,44 +2560,20 @@ export default defineComponent({