// 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 `
${pinIcon}
${escapeHtml(chat.title)}
${tagBadges ? `
${tagBadges}
` : ''}
')) {
contentHtml = content;
} 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, files = null) => {
if (!text && (!files || files.length === 0)) return;
// Se não recebe files por parâmetro, usa os anexados globalmente
const filesToSend = files || attachedFiles;
let fullText = text || '';
// Processar anexos: evitar base64 gigantesco em imagens e erro de leitura binária em PDF/DOCX
if (filesToSend.length > 0) {
for (const file of filesToSend) {
if (file.type.startsWith('image/')) {
// Apenas anexa uma referência textual limpa da imagem para a IA saber que ela existe
fullText += `\n\n*[Imagem anexada: ${file.name} (${Math.round(file.size/1024)} KB)]*`;
} else if (file.name.endsWith('.txt') || file.name.endsWith('.md') || file.name.endsWith('.json') || file.name.endsWith('.csv') || file.name.endsWith('.js') || file.name.endsWith('.css') || file.name.endsWith('.html')) {
const reader = new FileReader();
const content = await new Promise((resolve) => {
reader.onload = (e) => resolve(e.target.result);
reader.readAsText(file);
});
const truncated = content.length > 3000 ? content.substring(0, 3000) + '... [conteúdo truncado]' : content;
fullText += `\n\n*[Conteúdo do arquivo "${file.name}":]*\n\`\`\`\n${truncated}\n\`\`\``;
} else {
// Para arquivos binários como PDF/DOCX que não podem ser lidos diretamente como texto cru no frontend
fullText += `\n\n*[Arquivo anexado: ${file.name} (${Math.round(file.size/1024)} KB) - formato não textual]*`;
}
}
// Limpar anexos após envio
attachedFiles = [];
if (attachPreview) {
attachPreview.innerHTML = '';
attachPreview.style.display = 'none';
btnAttach.classList.remove('has-files');
}
}
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 = fullText.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: fullText };
chat.messages.push(userMsg);
chat.updatedAt = Date.now();
appendMessageUI('user', fullText);
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 = `
`;
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 = '';
let isMediaResponse = false;
let mediaMsgData = null;
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();
} else if (parsed.type === 'media_status') {
isMediaResponse = true;
botContentDiv.innerHTML = `
`;
scrollToBottom();
} else if (parsed.type === 'image') {
isMediaResponse = true;
mediaMsgData = { type: 'image', url: parsed.url, originalPrompt: parsed.originalPrompt };
assistantContent = `
`;
botContentDiv.innerHTML = assistantContent;
scrollToBottom();
} else if (parsed.type === 'audio') {
isMediaResponse = true;
mediaMsgData = { type: 'audio', url: parsed.url, originalPrompt: parsed.originalPrompt };
const audioId = 'audio_' + Date.now();
assistantContent = `
`;
botContentDiv.innerHTML = assistantContent;
scrollToBottom();
} else if (parsed.type === 'video') {
isMediaResponse = true;
mediaMsgData = { type: 'video', url: parsed.url, originalPrompt: parsed.originalPrompt };
assistantContent = `
`;
botContentDiv.innerHTML = assistantContent;
scrollToBottom();
}
} catch (err) {
// Ignorar erros de JSON incompletos do buffer
}
}
}
}
// Remover o cursor de digitação no final se não for mensagem de mídia
const cursor = botContentDiv.querySelector('.typing-cursor');
if (cursor) cursor.remove();
// Parse final limpo do markdown (se for texto normal)
if (!isMediaResponse) {
botContentDiv.innerHTML = marked.parse(assistantContent);
botContentDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
}
// Adicionar botões de ação (Copiar, Regenerar, Ouvir) após o streaming
const escapedContent = escapeHtml(assistantContent).replace(/"/g, '"');
const actionsDiv = document.createElement('div');
actionsDiv.className = 'message-actions';
actionsDiv.innerHTML = `
`;
const wrapper = botRow.querySelector('.message-content-wrapper');
wrapper.appendChild(actionsDiv);
// Vincular listeners dos botões
const copyBtn = actionsDiv.querySelector('.btn-copy-msg');
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(assistantContent).then(() => {
const span = copyBtn.querySelector('span');
const original = span.textContent;
span.textContent = 'Copiado!';
setTimeout(() => span.textContent = original, 2000);
});
});
const regenBtn = actionsDiv.querySelector('.btn-regenerate');
regenBtn.addEventListener('click', () => {
if (!isGenerating) regenerateLastResponse();
});
const speakBtn = actionsDiv.querySelector('.btn-speak');
speakBtn.addEventListener('click', () => {
speakText(assistantContent, speakBtn);
});
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();
};
// Player de áudio personalizado global para as mídias geradas
window.toggleCustomAudio = (btn) => {
const player = btn.closest('.custom-audio-player');
const audio = player.querySelector('audio');
const playIcon = btn.querySelector('.play-icon');
const pauseIcon = btn.querySelector('.pause-icon');
const progress = player.querySelector('.audio-progress');
const timeSpan = player.querySelector('.audio-time');
if (audio.paused) {
// Pausar outros players que possam estar ativos
document.querySelectorAll('.custom-audio-player audio').forEach(a => {
if (a !== audio && !a.paused) {
a.pause();
const otherBtn = a.closest('.custom-audio-player').querySelector('.audio-play-btn');
otherBtn.querySelector('.play-icon').classList.remove('hidden');
otherBtn.querySelector('.pause-icon').classList.add('hidden');
}
});
audio.play();
playIcon.classList.add('hidden');
pauseIcon.classList.remove('hidden');
} else {
audio.pause();
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
}
// Atualizar progresso em tempo real
audio.ontimeupdate = () => {
if (audio.duration) {
const percent = (audio.currentTime / audio.duration) * 100;
progress.style.width = percent + '%';
const mins = Math.floor(audio.currentTime / 60);
const secs = Math.floor(audio.currentTime % 60).toString().padStart(2, '0');
timeSpan.textContent = `${mins}:${secs}`;
}
};
audio.onended = () => {
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
progress.style.width = '0%';
timeSpan.textContent = '0:00';
};
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initApp);
} else {
initApp();
}