Files
camila/public/app.js
T

891 lines
33 KiB
JavaScript

// 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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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 `
<div class="code-container">
<div class="code-header">
<span class="code-lang">${lang}</span>
<button class="btn-copy-code" onclick="copyCode(this)">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H5.25m11.9-3.664A2.251 2.251 0 0 0 15 2.25h-3a2.251 2.251 0 0 0-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5M16.5 9h3.75c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125H16.5m-3-12h3m-3 3h3M13.5 14.25h3" />
</svg>
<span>Copiar</span>
</button>
</div>
<pre><code class="hljs language-${lang}">${escapeHtml(codeContent)}</code></pre>
</div>
`;
};
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';
// 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);
}
});
// ==========================================================================
// 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
? `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="pin-icon"><path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/></svg>`
: '';
const tagBadges = (chat.tags || []).map(tag =>
`<span class="history-tag-badge">${getTagEmoji(tag)}</span>`
).join('');
item.innerHTML = `
<div class="history-item-content">
${pinIcon}
<div class="history-item-title">${escapeHtml(chat.title)}</div>
${tagBadges ? `<div class="history-item-tags">${tagBadges}</div>` : ''}
</div>
<div class="history-item-actions">
<button class="btn-history-action btn-pin-chat" aria-label="${chat.pinned ? 'Desafixar' : 'Fixar'}">
<svg xmlns="http://www.w3.org/2000/svg" fill="${chat.pinned ? 'currentColor' : 'none'}" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="16.689 16.689L19.3 19.3a1 1 0 0 0 1.414-1.414l-1.066-1.066a7.5 7.5 0 1 0-1.66 1.114l1.004 1.004A1 1 0 0 0 19.3 17.886l-.707-.707a1 1 0 0 0-1.414 1.414l.707.707a1 1 0 0 0 1.414-1.414l-.707-.707a1 1 0 0 0-1.414 1.414l.707.707a1 1 0 0 0 1.414-1.414l-.811-.811a7.5 7.5 0 0 0 .11 2.12l.796.796a1 1 0 1 0 1.414-1.414l-.707-.707a1 1 0 0 0-1.414 1.414l.707.707a1 1 0 0 0 1.414-1.414l-.72-.72a7.5 7.5 0 0 0-.47-2.12 1 1 0 0 0-1.89.574l.707.707a1 1 0 0 0 1.414-1.414l-.707-.707a1 1 0 0 0-1.414 1.414l.707.707a1 1 0 0 0 1.414-1.414l-.707-.707a1 1 0 0 0-1.414 1.414l.5.5a1 1 0 1 0 1.414-1.414l-.5-.5a1 1 0 0 0-1.414 1.414l.707.707a1 1 0 0 0-1.414-1.414l-.707.707a1 1 0 0 0 1.414 1.414l.707-.707a1 1 0 0 0-1.414-1.414l-.707.707a1 1 0 0 0 1.414 1.414l.707-.707a1 1 0 0 0-1.414-1.414l-1.004 1.004a1 1 0 0 0 1.89-.574l-.796-.796a7.5 7.5 0 0 0-.11-2.12l.81.811a1 1 0 0 0-1.414-1.414l-.707.707a1 1 0 0 0 1.414 1.414l.707-.707a1 1 0 0 0-1.414-1.414l-.707.707a1 1 0 0 0 1.414 1.414l.72-.72a7.5 7.5 0 0 0 .47 2.12l-.5-.5a1 1 0 1 0-1.414 1.414l.5.5a1 1 0 0 0-1.414-1.414l-.5.5a1 1 0 0 0 1.414 1.414l.707-.707a1 1 0 0 0-1.414-1.414l-.707.707a1 1 0 0 0 1.414 1.414l.707-.707a1 1 0 0 0-1.414-1.414l-1.004 1.004a7.5 7.5 0 1 0 1.66-1.114l-1.004-1.004a1 1 0 1 0-1.414 1.414l1.066 1.066a1 1 0 0 0-1.414-1.414l-1.066 1.066a1 1 0 0 0 1.414-1.414l-3-3z"/>
</svg>
</button>
<button class="btn-history-action btn-rename-chat" aria-label="Renomear">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
</button>
<button class="btn-history-action btn-delete-chat" aria-label="Excluir">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</button>
</div>
`;
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 = '<div style="color: var(--text-muted); font-size: 12px; padding: 10px; text-align: center;">Nenhum chat salvo</div>';
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 = `<h3>${label}</h3>`;
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
? `<div class="avatar user-avatar">U</div>`
: `<div class="avatar bot-avatar bot-avatar-img-wrapper"><img src="assets/camila_prof.png" alt="Camila AI" class="bot-avatar-img"></div>`;
let contentHtml = '';
if (isUser) {
contentHtml = `<p>${escapeHtml(content).replace(/\n/g, '<br>')}</p>`;
} else {
contentHtml = marked.parse(content);
}
const actionsHtml = !isUser ? `
<div class="message-actions">
<button class="msg-action-btn btn-copy-msg" title="Copiar resposta" data-content="${escapeHtml(content).replace(/"/g, '&quot;')}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
</svg>
<span>Copiar</span>
</button>
<button class="msg-action-btn btn-regenerate" title="Regenerar resposta">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span>Regenerar</span>
</button>
</div>
` : `
<div class="message-actions">
<button class="msg-action-btn btn-edit" title="Editar e reenviar" data-content="${escapeHtml(content).replace(/"/g, '&quot;')}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
<span>Editar</span>
</button>
</div>
`;
row.innerHTML = `
<div class="message-icon">${avatarHtml}</div>
<div class="message-content-wrapper">
<div class="message-content">${contentHtml}</div>
${actionsHtml}
</div>
`;
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 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 = `
<div class="message-icon">
<div class="avatar bot-avatar bot-avatar-img-wrapper"><img src="assets/camila_prof.png" alt="Camila AI" class="bot-avatar-img"></div>
</div>
<div class="message-content">
<span class="typing-cursor"></span>
</div>
`;
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) + '<span class="typing-cursor"></span>';
// 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 += `
<p style="color: var(--error); margin-top: 8px; font-size: 13.5px; display: flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="width: 16px; height: 16px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
</svg>
Desculpe, ocorreu um erro ao obter resposta da Camila AI. Por favor, tente novamente.
</p>
`;
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();
});