// Configuração do Markdown parser personalizado com Highlight.js integrado const renderer = new marked.Renderer(); // Função auxiliar para escapar caracteres HTML function escapeHtml(text) { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } renderer.code = function(code, language) { // Ajuste para lidar com as diferenças na assinatura do marked.js const codeContent = typeof code === 'object' ? code.text : code; const lang = (typeof code === 'object' ? code.lang : language) || 'txt'; return `
${lang}
${escapeHtml(codeContent)}
`; }; marked.use({ renderer }); // Função global de copiar código para que funcione no onclick window.copyCode = function(button) { const container = button.closest('.code-container'); const codeElement = container.querySelector('code'); const textToCopy = codeElement.textContent; navigator.clipboard.writeText(textToCopy).then(() => { const textSpan = button.querySelector('span'); const originalText = textSpan.textContent; textSpan.textContent = 'Copiado!'; button.style.color = 'var(--success)'; setTimeout(() => { textSpan.textContent = originalText; button.style.color = ''; }, 2000); }).catch(err => { console.error('Erro ao copiar código:', err); }); }; document.addEventListener('DOMContentLoaded', () => { // Elementos do DOM const sidebar = document.getElementById('sidebar'); const btnMenu = document.getElementById('btnMenu'); const btnCloseSidebar = document.getElementById('btnCloseSidebar'); const sidebarOverlay = document.getElementById('sidebarOverlay'); const userInput = document.getElementById('userInput'); const btnSend = document.getElementById('btnSend'); const btnVoice = document.getElementById('btnVoice'); const chatForm = document.getElementById('chatForm'); const messagesContainer = document.getElementById('messagesContainer'); const welcomeContainer = document.getElementById('welcomeContainer'); const conversationList = document.getElementById('conversationList'); const btnNewChat = document.getElementById('btnNewChat'); const btnNewChatMobile = document.getElementById('btnNewChatMobile'); const historyItems = document.getElementById('historyItems'); const btnLogout = document.getElementById('btnLogout'); const suggestionCards = document.querySelectorAll('.suggestion-card'); const searchInput = document.getElementById('searchInput'); const searchClear = document.getElementById('searchClear'); const searchResultsHeader = document.getElementById('searchResultsHeader'); const searchResultsCount = document.getElementById('searchResultsCount'); const searchClearAll = document.getElementById('searchClearAll'); const sidebarTags = document.getElementById('sidebarTags'); // Variáveis de Estado let chats = JSON.parse(localStorage.getItem('camila_chats')) || []; // Migração: adicionar campos tags e pinned a chats antigos chats = chats.map(c => ({ ...c, tags: c.tags || [], pinned: c.pinned || false })); let currentChatId = localStorage.getItem('camila_current_chat_id') || null; let isGenerating = false; let searchQuery = ''; let activeTag = 'all'; let currentUtterance = null; // para controlar TTS em andamento // Variáveis de Reconhecimento de Voz let recognition = null; let isListening = false; // ========================================================================== // CONTROLES DE INTERFACE & SIDEBAR // ========================================================================== const toggleSidebar = (open) => { if (open) { sidebar.classList.add('open'); } else { sidebar.classList.remove('open'); } }; btnMenu.addEventListener('click', () => toggleSidebar(true)); btnCloseSidebar.addEventListener('click', () => toggleSidebar(false)); sidebarOverlay.addEventListener('click', () => toggleSidebar(false)); // Redimensionamento automático do Input de Texto (textarea) userInput.addEventListener('input', () => { userInput.style.height = 'auto'; userInput.style.height = (userInput.scrollHeight - 4) + 'px'; // Habilitar ou desabilitar botão btnSend.disabled = userInput.value.trim() === '' || isGenerating; }); // Prevenir comportamento padrão do Enter no textarea (enviar em vez de quebrar linha, Shift+Enter para nova linha) userInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (userInput.value.trim() !== '' && !isGenerating) { chatForm.requestSubmit(); } } }); // Busca no histórico searchInput.addEventListener('input', () => { searchQuery = searchInput.value; searchClear.style.display = searchQuery ? 'flex' : 'none'; renderHistory(); }); searchClear.addEventListener('click', () => { searchInput.value = ''; searchQuery = ''; searchClear.style.display = 'none'; renderHistory(); }); searchClearAll.addEventListener('click', () => { searchInput.value = ''; searchQuery = ''; searchClear.style.display = 'none'; renderHistory(); }); // Filtro por tags sidebarTags.addEventListener('click', (e) => { const tagBtn = e.target.closest('.tag-filter'); if (!tagBtn) return; document.querySelectorAll('.tag-filter').forEach(b => b.classList.remove('active')); tagBtn.classList.add('active'); activeTag = tagBtn.dataset.tag; renderHistory(); }); // ========================================================================== // VOZ — WEB SPEECH API (ditado) // ========================================================================== const initVoiceRecognition = () => { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { btnVoice.style.display = 'none'; return; } recognition = new SpeechRecognition(); recognition.lang = 'pt-BR'; recognition.continuous = false; recognition.interimResults = false; recognition.maxAlternatives = 1; recognition.onresult = (event) => { const transcript = event.results[0][0].transcript; userInput.value = transcript; userInput.dispatchEvent(new Event('input')); setVoiceState(false); }; recognition.onerror = (event) => { console.warn('Voice error:', event.error); setVoiceState(false); }; recognition.onend = () => { setVoiceState(false); }; }; const setVoiceState = (listening) => { isListening = listening; if (listening) { btnVoice.classList.add('listening'); btnVoice.setAttribute('title', 'Clique para parar'); } else { btnVoice.classList.remove('listening'); btnVoice.setAttribute('title', 'Ditar mensagem'); } }; btnVoice.addEventListener('click', () => { if (!recognition) return; if (isListening) { recognition.stop(); } else { recognition.start(); setVoiceState(true); } }); // ========================================================================== // TTS — Speech Synthesis (ouvir respostas) // ========================================================================== // Carregar vozes disponíveis (pode ser assíncrono em alguns navegadores) const loadVoices = () => { return new Promise((resolve) => { const voices = window.speechSynthesis.getVoices(); if (voices.length > 0) { resolve(voices); } else { window.speechSynthesis.addEventListener('voiceschanged', () => { resolve(window.speechSynthesis.getVoices()); }, { once: true }); } }); }; let cachedVoices = []; loadVoices().then(voices => { cachedVoices = voices; }); // Escolher melhor voz feminina brasileira disponível const getBestBRVoice = () => { const brVoices = cachedVoices.filter(v => v.lang.includes('pt')); // Prioridade: feminina > masculino, local > remote const female = brVoices.find(v => /female|brasil|maria|luciana|fernanda|virtual/i.test(v.name) ); if (female) return female; // fallback: qualquer voz brasileira const brazil = brVoices.find(v => /brasil|brazil|pt-BR/i.test(v.lang)); if (brazil) return brazil; // fallback: qualquer voz portuguesa return brVoices[0] || null; }; // Falar texto com TTS nativo const speakText = (text, btn) => { // Se já está falando, parar if (window.speechSynthesis.speaking) { window.speechSynthesis.cancel(); if (btn) { btn.classList.remove('speaking'); const span = btn.querySelector('span'); if (span) span.textContent = 'Ouvir'; } return; } // Limpar markdown básico antes de falar const clean = text .replace(/```[\s\S]*?```/g, 'trecho de código ignorado') .replace(/`[^`]+`/g, '') .replace(/[*_#~\[\]]/g, '') .replace(/\n+/g, ' ') .trim(); const utterance = new window.SpeechSynthesisUtterance(clean); utterance.lang = 'pt-BR'; utterance.rate = 0.95; utterance.pitch = 1.05; const voice = getBestBRVoice(); if (voice) utterance.voice = voice; utterance.onstart = () => { if (btn) { btn.classList.add('speaking'); const span = btn.querySelector('span'); if (span) span.textContent = 'Parar'; } }; utterance.onend = utterance.onerror = () => { if (btn) { btn.classList.remove('speaking'); const span = btn.querySelector('span'); if (span) span.textContent = 'Ouvir'; } }; window.speechSynthesis.speak(utterance); }; // ========================================================================== // ATALHOS DE TECLADO // ========================================================================== document.addEventListener('keydown', (e) => { // Ctrl+N → novo chat if ((e.ctrlKey || e.metaKey) && e.key === 'n') { e.preventDefault(); createNewChat(); } // Ctrl+F ou Ctrl+K → focar busca if ((e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'k')) { e.preventDefault(); toggleSidebar(true); setTimeout(() => searchInput.focus(), 50); } // Escape → fechar sidebar if (e.key === 'Escape') { toggleSidebar(false); } }); // ========================================================================== // HISTÓRICO DE CHATS (LOCALSTORAGE) — VERSÃO COMPLETA COM PINNED, TAGS, BUSCA // ========================================================================== const saveChats = () => { localStorage.setItem('camila_chats', JSON.stringify(chats)); if (currentChatId) { localStorage.setItem('camila_current_chat_id', currentChatId); } else { localStorage.removeItem('camila_current_chat_id'); } }; // Agrupamento por data const getDateGroup = (timestamp) => { const now = new Date(); const date = new Date(timestamp); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); const weekAgo = new Date(today); weekAgo.setDate(weekAgo.getDate() - 7); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate()); if (dateOnly.getTime() === today.getTime()) return 'Hoje'; if (dateOnly.getTime() === yesterday.getTime()) return 'Ontem'; if (dateOnly >= weekAgo) return 'Esta Semana'; if (dateOnly >= monthStart) return 'Este Mês'; return 'Mais Antigas'; }; // Filtrar chats por tag const filterByTag = (chatList) => { if (activeTag === 'all') return chatList; return chatList.filter(chat => chat.tags && chat.tags.includes(activeTag)); }; // Filtrar chats por busca const filterBySearch = (chatList) => { if (!searchQuery.trim()) return chatList; const q = searchQuery.toLowerCase(); return chatList.filter(chat => { if (chat.title.toLowerCase().includes(q)) return true; return chat.messages.some(msg => msg.content.toLowerCase().includes(q)); }); }; // Renderizar um item de chat const createHistoryItem = (chat) => { const item = document.createElement('div'); item.className = `history-item ${chat.id === currentChatId ? 'active' : ''} ${chat.pinned ? 'pinned' : ''}`; item.dataset.id = chat.id; const pinIcon = chat.pinned ? `` : ''; const tagBadges = (chat.tags || []).map(tag => `${getTagEmoji(tag)}` ).join(''); item.innerHTML = `
${pinIcon}
${escapeHtml(chat.title)}
${tagBadges ? `
${tagBadges}
` : ''}
`; return item; }; // Emoji para cada tag const getTagEmoji = (tag) => { const emojis = { relatorios: '📋', planejamento: '📝', mindlab: '🧠', estudos: '📚' }; return emojis[tag] || '🏷️'; }; // Renderizar histórico completo const renderHistory = () => { historyItems.innerHTML = ''; if (chats.length === 0) { historyItems.innerHTML = '
Nenhum chat salvo
'; return; } // Aplicar filtros let filtered = [...chats]; filtered = filterByTag(filtered); filtered = filterBySearch(filtered); // Se tem busca, mostrar resultados direto (sem grupo) if (searchQuery.trim()) { searchResultsHeader.style.display = 'block'; searchResultsCount.textContent = `${filtered.length} resultado${filtered.length !== 1 ? 's' : ''}`; filtered.sort((a, b) => b.updatedAt - a.updatedAt); filtered.forEach(chat => { const item = createHistoryItem(chat); item.addEventListener('click', (e) => { if (e.target.closest('.btn-delete-chat')) { e.stopPropagation(); deleteChat(chat.id); return; } if (e.target.closest('.btn-pin-chat')) { e.stopPropagation(); togglePin(chat.id); return; } if (e.target.closest('.btn-rename-chat')) { e.stopPropagation(); renameChat(chat.id); return; } selectChat(chat.id); toggleSidebar(false); }); historyItems.appendChild(item); }); return; } searchResultsHeader.style.display = 'none'; // Separar pinned e não-pinned const pinned = filtered.filter(c => c.pinned).sort((a, b) => b.updatedAt - a.updatedAt); const unpinned = filtered.filter(c => !c.pinned).sort((a, b) => b.updatedAt - a.updatedAt); // Renderizar grupo se tiver items const renderGroup = (label, items) => { if (items.length === 0) return; const group = document.createElement('div'); group.className = 'history-group'; group.innerHTML = `

${label}

`; const container = document.createElement('div'); container.className = 'history-items'; items.forEach(chat => { const item = createHistoryItem(chat); item.addEventListener('click', (e) => { if (e.target.closest('.btn-delete-chat')) { e.stopPropagation(); deleteChat(chat.id); return; } if (e.target.closest('.btn-pin-chat')) { e.stopPropagation(); togglePin(chat.id); return; } if (e.target.closest('.btn-rename-chat')) { e.stopPropagation(); renameChat(chat.id); return; } selectChat(chat.id); toggleSidebar(false); }); container.appendChild(item); }); group.appendChild(container); historyItems.appendChild(group); }; // Pinned no topo if (pinned.length > 0) { renderGroup('📌 Fixadas', pinned); } // Agrupar por data os não-pinned const groups = {}; unpinned.forEach(chat => { const group = getDateGroup(chat.updatedAt); if (!groups[group]) groups[group] = []; groups[group].push(chat); }); ['Hoje', 'Ontem', 'Esta Semana', 'Este Mês', 'Mais Antigas'].forEach(g => { if (groups[g]) renderGroup(g, groups[g]); }); }; // Fixar/desfixar chat const togglePin = (id) => { const chat = chats.find(c => c.id === id); if (!chat) return; chat.pinned = !chat.pinned; saveChats(); renderHistory(); }; // Renomear chat const renameChat = (id) => { const chat = chats.find(c => c.id === id); if (!chat) return; const newTitle = prompt('Novo nome da conversa:', chat.title); if (newTitle && newTitle.trim()) { chat.title = newTitle.trim(); saveChats(); renderHistory(); } }; const selectChat = (id) => { currentChatId = id; const chat = chats.find(c => c.id === id); if (!chat) return; saveChats(); renderHistory(); // Ocultar welcome e renderizar as mensagens welcomeContainer.style.display = 'none'; conversationList.innerHTML = ''; chat.messages.forEach(msg => { appendMessageUI(msg.role, msg.content); }); scrollToBottom(); }; const createNewChat = () => { if (isGenerating) return; currentChatId = null; localStorage.removeItem('camila_current_chat_id'); welcomeContainer.style.display = 'flex'; conversationList.innerHTML = ''; userInput.value = ''; userInput.style.height = 'auto'; btnSend.disabled = true; renderHistory(); }; const deleteChat = (id) => { chats = chats.filter(c => c.id !== id); if (currentChatId === id) { currentChatId = null; createNewChat(); } else { saveChats(); renderHistory(); } }; btnNewChat.addEventListener('click', createNewChat); btnNewChatMobile.addEventListener('click', createNewChat); // ========================================================================== // FLUXO DO CHAT & RENDERIZAÇÃO // ========================================================================== const appendMessageUI = (role, content) => { const isUser = role === 'user'; const row = document.createElement('div'); row.className = `message-row ${role}`; const avatarHtml = isUser ? `
U
` : `
Camila AI
`; let contentHtml = ''; if (isUser) { contentHtml = `

${escapeHtml(content).replace(/\n/g, '
')}

`; } else { contentHtml = marked.parse(content); } const actionsHtml = !isUser ? `
` : `
`; row.innerHTML = `
${avatarHtml}
${contentHtml}
${actionsHtml}
`; conversationList.appendChild(row); // Colorir blocos de código com Highlight.js row.querySelectorAll('pre code').forEach((block) => { hljs.highlightElement(block); }); // Event listener para copiar mensagem const copyBtn = row.querySelector('.btn-copy-msg'); if (copyBtn) { copyBtn.addEventListener('click', () => { const text = copyBtn.dataset.content; navigator.clipboard.writeText(text).then(() => { const span = copyBtn.querySelector('span'); const original = span.textContent; span.textContent = 'Copiado!'; setTimeout(() => span.textContent = original, 2000); }); }); } // Event listener para editar/reenviar mensagem const editBtn = row.querySelector('.btn-edit'); if (editBtn) { editBtn.addEventListener('click', () => { const text = editBtn.dataset.content; const chat = chats.find(c => c.id === currentChatId); if (!chat) return; // Encontrar índice desta mensagem no chat const rows = conversationList.querySelectorAll('.message-row.user'); let msgRow = null; for (const r of rows) { const btn = r.querySelector('.btn-edit'); if (btn && btn.dataset.content === text) { msgRow = r; break; } } // Remover esta mensagem e todas as subsequentes (do user e do bot) if (msgRow) { const allRows = Array.from(conversationList.querySelectorAll('.message-row')); const idx = allRows.indexOf(msgRow); for (let i = allRows.length - 1; i >= idx; i--) { allRows[i].remove(); } // Remover do estado também const msgIndex = chat.messages.findIndex(m => m.role === 'user' && m.content === text); if (msgIndex >= 0) { chat.messages = chat.messages.slice(0, msgIndex); } saveChats(); } // Colocar texto no input e focar userInput.value = text; userInput.focus(); userInput.dispatchEvent(new Event('input')); btnSend.disabled = false; }); } // Event listener para ouvir resposta (TTS nativo) const speakBtn = row.querySelector('.btn-speak'); if (speakBtn) { speakBtn.addEventListener('click', () => { const text = speakBtn.dataset.content; speakText(text, speakBtn); }); } // Event listener para regenerar resposta const regenBtn = row.querySelector('.btn-regenerate'); if (regenBtn) { regenBtn.addEventListener('click', () => { if (isGenerating) return; regenerateLastResponse(); }); } return row; }; // Regenerar última resposta do bot const regenerateLastResponse = () => { const chat = chats.find(c => c.id === currentChatId); if (!chat || chat.messages.length === 0) return; // Encontrar última mensagem do bot let lastBotIdx = -1; for (let i = chat.messages.length - 1; i >= 0; i--) { if (chat.messages[i].role === 'assistant') { lastBotIdx = i; break; } } if (lastBotIdx === -1) return; // Remover última mensagem do bot e todas as mensagens depois da última mensagem do usuário let lastUserIdx = -1; for (let i = lastBotIdx - 1; i >= 0; i--) { if (chat.messages[i].role === 'user') { lastUserIdx = i; break; } } // Cortar mensagens até a última do usuário if (lastUserIdx >= 0) { chat.messages = chat.messages.slice(0, lastUserIdx + 1); } else { chat.messages = []; } // Remover da UI as mensagens depois do último user const rows = conversationList.querySelectorAll('.message-row'); for (let i = rows.length - 1; i >= 0; i--) { const row = rows[i]; const isBot = row.classList.contains('assistant'); const isUser = row.classList.contains('user'); if (isBot) { row.remove(); } else if (isUser) { break; } } saveChats(); scrollToBottom(); // Reenviar última mensagem do usuário para gerar nova resposta const lastUserMsg = chat.messages[chat.messages.length - 1]; if (lastUserMsg && lastUserMsg.role === 'user') { handleSendMessage(lastUserMsg.content, true); } }; const scrollToBottom = () => { messagesContainer.scrollTop = messagesContainer.scrollHeight; }; // Função principal de envio de mensagens const handleSendMessage = async (text, skipUserAppend = false) => { if (!text || isGenerating) return; isGenerating = true; btnSend.disabled = true; userInput.disabled = true; userInput.value = ''; userInput.style.height = 'auto'; // Ocultar tela de boas-vindas se for a primeira mensagem if (welcomeContainer.style.display !== 'none') { welcomeContainer.style.display = 'none'; } // Criar o chat se for novo if (!currentChatId) { const newId = 'chat_' + Date.now(); const firstLine = text.split('\n')[0]; const title = firstLine.length > 26 ? firstLine.substring(0, 26) + '...' : firstLine; chats.push({ id: newId, title: title, tags: [], pinned: false, messages: [], createdAt: Date.now(), updatedAt: Date.now() }); currentChatId = newId; } // Achar o chat atual no estado const chat = chats.find(c => c.id === currentChatId); if (!chat) return; // Adicionar mensagem do usuário no estado e na tela (pula se for regeneração) if (!skipUserAppend) { const userMsg = { role: 'user', content: text }; chat.messages.push(userMsg); chat.updatedAt = Date.now(); appendMessageUI('user', text); scrollToBottom(); saveChats(); renderHistory(); } // Criar container para a resposta do bot (Camila AI) na tela const botRow = document.createElement('div'); botRow.className = 'message-row assistant'; botRow.innerHTML = `
Camila AI
`; conversationList.appendChild(botRow); scrollToBottom(); const botContentDiv = botRow.querySelector('.message-content'); let assistantContent = ''; try { // Filtrar mensagens para enviar apenas o histórico necessário ao OpenRouter const messagesPayload = chat.messages.map(m => ({ role: m.role, content: m.content })); // Fazer a chamada HTTP usando stream const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: messagesPayload }) }); if (!response.ok) { throw new Error('Falha na resposta do servidor'); } // Processar stream de dados (SSE) const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); // Deixar a última parte inacabada no buffer buffer = lines.pop(); for (const line of lines) { const cleanLine = line.trim(); if (cleanLine.startsWith('data: ')) { const dataContent = cleanLine.slice(6); if (dataContent === '[DONE]') { continue; } try { const parsed = JSON.parse(dataContent); if (parsed.content) { assistantContent += parsed.content; // Renderizar markdown em progresso (com o cursor no final) botContentDiv.innerHTML = marked.parse(assistantContent) + ''; // Colorir códigos em tempo real botContentDiv.querySelectorAll('pre code').forEach((block) => { hljs.highlightElement(block); }); scrollToBottom(); } } catch (err) { // Ignorar erros de JSON incompletos do buffer } } } } // Remover o cursor de digitação no final const cursor = botContentDiv.querySelector('.typing-cursor'); if (cursor) cursor.remove(); // Parse final limpo do markdown botContentDiv.innerHTML = marked.parse(assistantContent); botContentDiv.querySelectorAll('pre code').forEach((block) => { hljs.highlightElement(block); }); scrollToBottom(); // Salvar resposta no estado e localStorage const assistantMsg = { role: 'assistant', content: assistantContent }; chat.messages.push(assistantMsg); chat.updatedAt = Date.now(); saveChats(); renderHistory(); } catch (error) { console.error('Erro na resposta do chat:', error); const cursor = botContentDiv.querySelector('.typing-cursor'); if (cursor) cursor.remove(); botContentDiv.innerHTML += `

Desculpe, ocorreu um erro ao obter resposta da Camila AI. Por favor, tente novamente.

`; scrollToBottom(); } finally { isGenerating = false; btnSend.disabled = userInput.value.trim() === ''; userInput.disabled = false; userInput.focus(); } }; // Envio pelo Formulário chatForm.addEventListener('submit', (e) => { e.preventDefault(); const text = userInput.value.trim(); handleSendMessage(text); }); // Sugestões de Prompt Rápidas suggestionCards.forEach(card => { card.addEventListener('click', () => { const prompt = card.dataset.prompt; if (prompt) { handleSendMessage(prompt); } }); }); // ========================================================================== // LOGOUT & INICIALIZAÇÃO // ========================================================================== btnLogout.addEventListener('click', async () => { try { const response = await fetch('/api/logout', { method: 'POST' }); if (response.ok) { window.location.href = '/login'; } } catch (error) { console.error('Erro ao fazer logout:', error); } }); // Inicializar interface if (currentChatId) { // Tenta carregar o chat ativo const activeChat = chats.find(c => c.id === currentChatId); if (activeChat) { selectChat(currentChatId); } else { createNewChat(); } } else { createNewChat(); } // Inicializar reconhecimento de voz initVoiceRecognition(); });