/** * Chat composable * Handles AI chat functionality with streaming responses */ export function useChat(state, utils) { const { showChat, isChatMaximized, chatMessages, chatInput, isChatLoading, chatMessagesRef, chatInputRef, selectedRecording, csrfToken } = state; const { showToast, setGlobalError, onChatComplete, t } = utils; // Helper function to check if chat is scrolled to bottom (within bottom 5%) const isChatScrolledToBottom = () => { if (!chatMessagesRef.value) return true; const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.value; const scrollableHeight = scrollHeight - clientHeight; if (scrollableHeight <= 0) return true; const scrollPercentage = scrollTop / scrollableHeight; return scrollPercentage >= 0.95; }; // Helper function to scroll chat to bottom const scrollChatToBottom = () => { if (chatMessagesRef.value) { requestAnimationFrame(() => { if (chatMessagesRef.value) { chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight; } }); } }; const focusChatInput = () => { Vue.nextTick(() => { if (chatInputRef.value) { chatInputRef.value.focus(); } }); }; const toggleChatMaximize = () => { if (isChatMaximized.value) { isChatMaximized.value = false; } else { isChatMaximized.value = true; if (!showChat.value) { showChat.value = true; } } }; const sendChatMessage = async () => { if (!chatInput.value.trim() || isChatLoading.value || !selectedRecording.value || selectedRecording.value.status !== 'COMPLETED') { return; } const message = chatInput.value.trim(); if (!Array.isArray(chatMessages.value)) { chatMessages.value = []; } chatMessages.value.push({ role: 'user', content: message }); chatInput.value = ''; isChatLoading.value = true; focusChatInput(); await Vue.nextTick(); scrollChatToBottom(); let assistantMessage = null; try { const messageHistory = chatMessages.value .slice(0, -1) .map(msg => ({ role: msg.role, content: msg.content })); // Check if this is an incognito recording const isIncognito = selectedRecording.value.incognito === true; let response; if (isIncognito) { // Use incognito chat endpoint - pass transcription directly response = await fetch('/api/recordings/incognito/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcription: selectedRecording.value.transcription, participants: selectedRecording.value.participants || '', notes: selectedRecording.value.notes || '', message: message, message_history: messageHistory }) }); } else { // Use regular chat endpoint response = await fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recording_id: selectedRecording.value.id, message: message, message_history: messageHistory }) }); } if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to get chat response'); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; const processStream = async () => { let isFirstChunk = true; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { if (line.startsWith('data: ')) { const jsonStr = line.substring(6); // Handle [DONE] marker from incognito endpoint if (jsonStr === '[DONE]') { return; } if (jsonStr) { try { const data = JSON.parse(jsonStr); if (data.thinking) { const shouldScroll = isChatScrolledToBottom(); if (isFirstChunk) { isChatLoading.value = false; assistantMessage = Vue.reactive({ role: 'assistant', content: '', html: '', thinking: data.thinking, thinkingExpanded: false }); chatMessages.value.push(assistantMessage); isFirstChunk = false; } else if (assistantMessage) { if (assistantMessage.thinking) { assistantMessage.thinking += '\n\n' + data.thinking; } else { assistantMessage.thinking = data.thinking; } } if (shouldScroll) { await Vue.nextTick(); scrollChatToBottom(); } } // Handle both 'delta' (regular) and 'content' (incognito) formats const textContent = data.delta || data.content; if (textContent) { const shouldScroll = isChatScrolledToBottom(); if (isFirstChunk) { isChatLoading.value = false; assistantMessage = Vue.reactive({ role: 'assistant', content: '', html: '', thinking: '', thinkingExpanded: false }); chatMessages.value.push(assistantMessage); isFirstChunk = false; } assistantMessage.content += textContent; assistantMessage.html = marked.parse(assistantMessage.content); if (shouldScroll) { await Vue.nextTick(); scrollChatToBottom(); } } if (data.end_of_stream) { return; } if (data.error) { if (data.budget_exceeded) { throw new Error(t('adminDashboard.tokenBudgetExceeded')); } throw new Error(data.error); } } catch (e) { console.error('Error parsing stream data:', e); } } } } } }; await processStream(); } catch (error) { console.error('Chat Error:', error); if (assistantMessage) { assistantMessage.content = `Error: ${error.message}`; assistantMessage.html = `Error: ${error.message}`; } else { chatMessages.value.push({ role: 'assistant', content: `Error: ${error.message}`, html: `Error: ${error.message}` }); } } finally { isChatLoading.value = false; await Vue.nextTick(); if (isChatScrolledToBottom()) { scrollChatToBottom(); } focusChatInput(); // Refresh token budget after chat completion if (onChatComplete) { onChatComplete(); } } }; const handleChatKeydown = (event) => { if (event.key === 'Enter') { if (event.ctrlKey || event.shiftKey) { return; } else { event.preventDefault(); sendChatMessage(); } } }; const clearChat = () => { if (chatMessages.value.length > 0) { chatMessages.value = []; showToast(t('chat.cleared'), 'fa-broom'); } }; const downloadChat = async () => { if (!selectedRecording.value || chatMessages.value.length === 0) { showToast(t('chat.noMessagesToDownload'), 'fa-exclamation-circle'); return; } try { const csrfTokenValue = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const response = await fetch(`/recording/${selectedRecording.value.id}/download/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfTokenValue }, body: JSON.stringify({ messages: chatMessages.value }) }); if (!response.ok) { const error = await response.json(); showToast(error.error || t('chat.downloadFailed'), 'fa-exclamation-circle'); return; } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; const contentDisposition = response.headers.get('Content-Disposition'); let filename = 'chat.docx'; if (contentDisposition) { const utf8Match = /filename\*=utf-8''(.+)/.exec(contentDisposition); if (utf8Match) { filename = decodeURIComponent(utf8Match[1]); } else { const regularMatch = /filename="(.+)"/.exec(contentDisposition); if (regularMatch) { filename = regularMatch[1]; } } } a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); showToast(t('chat.downloadSuccess')); } catch (error) { console.error('Download failed:', error); showToast(t('chat.downloadFailed'), 'fa-exclamation-circle'); } }; const copyMessage = (text, event) => { const button = event.currentTarget; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text) .then(() => { showToast(t('messages.copiedSuccessfully')); animateCopyButton(button); }) .catch(err => { console.error('Copy failed:', err); showToast(t('messages.copyFailed') + ': ' + err.message, 'fa-exclamation-circle'); fallbackCopyTextToClipboard(text, button); }); } else { fallbackCopyTextToClipboard(text, button); } }; const animateCopyButton = (button) => { button.classList.add('copy-success'); const originalContent = button.innerHTML; button.innerHTML = ''; setTimeout(() => { button.classList.remove('copy-success'); button.innerHTML = originalContent; }, 1500); }; const fallbackCopyTextToClipboard = (text, button = null) => { try { const textArea = document.createElement("textarea"); textArea.value = text; textArea.style.position = "fixed"; textArea.style.left = "-999999px"; textArea.style.top = "-999999px"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); const successful = document.execCommand('copy'); document.body.removeChild(textArea); if (successful) { showToast(t('messages.copiedSuccessfully')); if (button) animateCopyButton(button); } else { showToast(t('messages.copyNotSupported'), 'fa-exclamation-circle'); } } catch (err) { console.error('Fallback copy failed:', err); showToast(t('messages.copyFailed') + ': ' + err.message, 'fa-exclamation-circle'); } }; return { isChatScrolledToBottom, scrollChatToBottom, toggleChatMaximize, sendChatMessage, handleChatKeydown, clearChat, downloadChat, copyMessage, animateCopyButton, fallbackCopyTextToClipboard }; }