// 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 `
${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 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');
// Variáveis de Estado
let chats = JSON.parse(localStorage.getItem('camila_chats')) || [];
let currentChatId = localStorage.getItem('camila_current_chat_id') || null;
let isGenerating = 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();
}
}
});
// ==========================================================================
// HISTÓRICO DE CHATS (LOCALSTORAGE)
// ==========================================================================
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');
}
};
const renderHistory = () => {
historyItems.innerHTML = '';
if (chats.length === 0) {
historyItems.innerHTML = 'Nenhum chat salvo
';
return;
}
// Ordenar por timestamp mais recente
const sortedChats = [...chats].sort((a, b) => b.updatedAt - a.updatedAt);
sortedChats.forEach(chat => {
const item = document.createElement('div');
item.className = `history-item ${chat.id === currentChatId ? 'active' : ''}`;
item.dataset.id = chat.id;
item.innerHTML = `
${escapeHtml(chat.title)}
`;
// Selecionar chat
item.addEventListener('click', (e) => {
if (e.target.closest('.btn-delete-chat')) {
e.stopPropagation();
deleteChat(chat.id);
return;
}
selectChat(chat.id);
toggleSidebar(false);
});
historyItems.appendChild(item);
});
};
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
`
: `C
`;
let contentHtml = '';
if (isUser) {
contentHtml = `${escapeHtml(content).replace(/\n/g, '
')}
`;
} else {
contentHtml = marked.parse(content);
}
row.innerHTML = `
${avatarHtml}
${contentHtml}
`;
conversationList.appendChild(row);
// Colorir blocos de código com Highlight.js
row.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
return row;
};
const scrollToBottom = () => {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
};
// Função principal de envio de mensagens
const handleSendMessage = async (text) => {
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,
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
const userMsg = { role: 'user', content: text };
chat.messages.push(userMsg);
chat.updatedAt = Date.now();
appendMessageUI('user', text);
scrollToBottom();
// Salvar estado temporário do chat
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 = `
`;
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();
}
});