Files
camila/public/app.js
T

1379 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
};
const initApp = () => {
// 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;
// ==========================================================================
// THEME TOGGLE — Claro/Escuro
// ==========================================================================
const btnThemeToggle = document.getElementById('btnThemeToggle');
const iconThemeSun = document.getElementById('iconThemeSun');
const iconThemeMoon = document.getElementById('iconThemeMoon');
const applyTheme = (theme) => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('camila_theme', theme);
if (theme === 'light') {
iconThemeSun.style.display = 'none';
iconThemeMoon.style.display = 'block';
} else {
iconThemeSun.style.display = 'block';
iconThemeMoon.style.display = 'none';
}
};
// Init theme from localStorage or system preference
const savedTheme = localStorage.getItem('camila_theme');
if (savedTheme) {
applyTheme(savedTheme);
} else {
// Default to dark
applyTheme('dark');
}
btnThemeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
applyTheme(current === 'dark' ? 'light' : 'dark');
});
// ==========================================================================
// BADGE DE MODELO DINÂMICO
// ==========================================================================
const modelBadgeText = document.getElementById('modelBadgeText');
const updateModelBadge = async () => {
try {
const res = await fetch('/api/status');
if (res.ok) {
const data = await res.json();
// Extrai nome abreviado do modelo (ex: "openai/gpt-4.1-nano" → "GPT-4.1 Nano")
const modelName = data.model.split('/').pop();
const formatted = modelName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
modelBadgeText.textContent = formatted;
}
} catch (e) {
// Mantém texto padrão se falhar
}
};
updateModelBadge();
// ==========================================================================
// MODAL DE TAGS
// ==========================================================================
const AVAILABLE_TAGS = ['HTPC', 'ATA', 'Planejamento', 'Formação', 'Projeto', 'Relatório', 'Sugestão', 'Dúvida'];
const modalTagOverlay = document.getElementById('modalTagOverlay');
const modalTagList = document.getElementById('modalTagList');
const modalTagClose = document.getElementById('modalTagClose');
const modalTagCancel = document.getElementById('modalTagCancel');
const modalTagConfirm = document.getElementById('modalTagConfirm');
let pendingTagAction = null; // { chatId, callback }
let selectedTagsState = [];
const openTagModal = (chatId, currentTags, callback) => {
pendingTagAction = { chatId, callback };
selectedTagsState = [...currentTags];
renderTagModalList();
modalTagOverlay.classList.add('active');
};
const closeTagModal = () => {
modalTagOverlay.classList.remove('active');
pendingTagAction = null;
};
const renderTagModalList = () => {
modalTagList.innerHTML = AVAILABLE_TAGS.map(tag => `
<div class="modal-tag-item ${selectedTagsState.includes(tag) ? 'selected' : ''}" data-tag="${tag}">
<input type="checkbox" ${selectedTagsState.includes(tag) ? 'checked' : ''}>
<span>${tag}</span>
</div>
`).join('');
modalTagList.querySelectorAll('.modal-tag-item').forEach(item => {
item.addEventListener('click', () => {
const tag = item.dataset.tag;
if (selectedTagsState.includes(tag)) {
selectedTagsState = selectedTagsState.filter(t => t !== tag);
} else {
selectedTagsState.push(tag);
}
renderTagModalList();
});
});
};
modalTagClose.addEventListener('click', closeTagModal);
modalTagCancel.addEventListener('click', closeTagModal);
modalTagOverlay.addEventListener('click', (e) => {
if (e.target === modalTagOverlay) closeTagModal();
});
modalTagConfirm.addEventListener('click', () => {
if (pendingTagAction) {
pendingTagAction.callback(selectedTagsState);
}
closeTagModal();
});
// Expose globally for inline onclick handlers
window.openTagModal = openTagModal;
// ==========================================================================
// 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 nativa (voz do navegador)
// ==========================================================================
let cachedVoices = [];
const loadVoices = () => {
const voices = window.speechSynthesis.getVoices();
if (voices.length > 0) {
cachedVoices = voices;
console.log('Vozes pt-BR disponíveis:', cachedVoices.filter(v => v.lang.startsWith('pt')).map(v => v.name));
} else {
window.speechSynthesis.addEventListener('voiceschanged', () => {
cachedVoices = window.speechSynthesis.getVoices();
console.log('Vozes pt-BR disponíveis:', cachedVoices.filter(v => v.lang.startsWith('pt')).map(v => v.name));
}, { once: true });
}
};
loadVoices();
// Selecionar melhor voz pt-BR disponível no dispositivo
const getBestVoice = () => {
const ptBR = cachedVoices.filter(v => v.lang === 'pt-BR' || v.lang === 'pt_BR');
if (ptBR.length === 0) {
const pt = cachedVoices.filter(v => v.lang.startsWith('pt'));
return pt[0] || null;
}
// 1. Microsoft Neural (Edge): Thalita, Francisca, Giovanna
const msNeural = ptBR.find(v => /thalita|francisca|giovanna|natural|neural/i.test(v.name));
if (msNeural) return msNeural;
// 2. Google (Chrome/Android): vozes naturais
const google = ptBR.find(v => /google/i.test(v.name));
if (google) return google;
// 3. Apple (Safari/iOS): Luciana é excelente
const apple = ptBR.find(v => /luciana|fernanda/i.test(v.name));
if (apple) return apple;
// 4. Qualquer feminina
const female = ptBR.find(v => /female|femin|maria|raquel|camila/i.test(v.name));
if (female) return female;
// 5. Primeira disponível
return ptBR[0];
};
// Falar texto com TTS nativa
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 antes de falar
const clean = text
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]+`/g, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/[*_#~\[\]>]/g, '')
.replace(/\n+/g, '. ')
.replace(/\s{2,}/g, ' ')
.trim();
if (!clean) return;
const utterance = new SpeechSynthesisUtterance(clean);
utterance.lang = 'pt-BR';
utterance.rate = 1.15;
utterance.pitch = 1.0;
const voice = getBestVoice();
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);
};
// ==========================================================================
// ANEXOS — Upload de imagens e documentos
// ==========================================================================
const fileInput = document.getElementById('fileInput');
const btnAttach = document.getElementById('btnAttach');
let attachedFiles = [];
// Criar preview container se não existir
let attachPreview = document.querySelector('.attachments-preview');
const ensureAttachPreview = () => {
if (!attachPreview) {
attachPreview = document.createElement('div');
attachPreview.className = 'attachments-preview';
document.getElementById('chatForm').appendChild(attachPreview);
}
};
// Botão de anexar → abre seletor de arquivos
btnAttach.addEventListener('click', () => {
fileInput.click();
});
// Quando arquivos são selecionados
fileInput.addEventListener('change', () => {
const files = Array.from(fileInput.files);
if (files.length === 0) return;
files.forEach(file => {
// Limite de 10MB por arquivo
if (file.size > 10 * 1024 * 1024) {
alert(`Arquivo "${file.name}" é muito grande. Máximo: 10MB.`);
return;
}
attachedFiles.push(file);
});
renderAttachPreview();
fileInput.value = ''; // reset para permitir selects again
});
// Arrastar e soltar na área do chat
const chatArea = document.querySelector('.chat-area');
if (chatArea) {
chatArea.addEventListener('dragover', (e) => {
e.preventDefault();
chatArea.classList.add('drag-over');
});
chatArea.addEventListener('dragleave', () => {
chatArea.classList.remove('drag-over');
});
chatArea.addEventListener('drop', (e) => {
e.preventDefault();
chatArea.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files);
files.forEach(file => {
if (file.size <= 10 * 1024 * 1024) {
attachedFiles.push(file);
}
});
renderAttachPreview();
});
}
// Renderizar preview dos anexos
const renderAttachPreview = () => {
ensureAttachPreview();
attachPreview.innerHTML = '';
if (attachedFiles.length === 0) {
attachPreview.style.display = 'none';
btnAttach.classList.remove('has-files');
return;
}
btnAttach.classList.add('has-files');
attachPreview.style.display = 'flex';
attachedFiles.forEach((file, index) => {
const item = document.createElement('div');
item.className = 'attachment-item';
const isImage = file.type.startsWith('image/');
let previewHtml = '';
if (isImage) {
const url = URL.createObjectURL(file);
previewHtml = `<img src="${url}" alt="${file.name}">`;
} else {
previewHtml = `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>`;
}
item.innerHTML = `
${previewHtml}
<span class="file-name">${file.name}</span>
<button type="button" class="remove-attachment" data-index="${index}">×</button>
`;
attachPreview.appendChild(item);
});
// Botões de remover
attachPreview.querySelectorAll('.remove-attachment').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index);
attachedFiles.splice(idx, 1);
renderAttachPreview();
});
});
};
// ==========================================================================
// 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"><line x1="12" y1="17" x2="12" y2="22" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line><path d="M5 17h14v-1.76a2 2 0 0 0-.44-1.24l-2.78-3.47A2 2 0 0 1 15 9.29V5a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4.29a2 2 0 0 1-.78 1.24L5.44 14a2 2 0 0 0-.44 1.24z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></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" title="${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">
<line x1="12" y1="17" x2="12" y2="22"></line>
<path d="M5 17h14v-1.76a2 2 0 0 0-.44-1.24l-2.78-3.47A2 2 0 0 1 15 9.29V5a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4.29a2 2 0 0 1-.78 1.24L5.44 14a2 2 0 0 0-.44 1.24z"></path>
</svg>
</button>
<button class="btn-history-action btn-tag-chat" title="Tags">
<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="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581a2.25 2.25 0 0 0 3.181 0l4.318-4.318a2.25 2.25 0 0 0 0-3.181l-9.58-9.581A2.25 2.25 0 0 0 9.568 3Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
</svg>
</button>
<button class="btn-history-action btn-rename-chat" title="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" title="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(); renameChatInline(chat.id, item); return; }
if (e.target.closest('.btn-tag-chat')) { e.stopPropagation(); editTags(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(); renameChatInline(chat.id, item); return; }
if (e.target.closest('.btn-tag-chat')) { e.stopPropagation(); editTags(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 inline
const renameChatInline = (id, itemElement) => {
const chat = chats.find(c => c.id === id);
if (!chat) return;
const titleDiv = itemElement.querySelector('.history-item-title');
if (!titleDiv) return;
const originalTitle = chat.title;
const input = document.createElement('input');
input.type = 'text';
input.className = 'rename-input';
input.value = originalTitle;
// Prevenir clique e propagação para o item
input.addEventListener('click', (e) => e.stopPropagation());
const finishRename = () => {
const newTitle = input.value.trim();
if (newTitle && newTitle !== originalTitle) {
chat.title = newTitle;
chat.updatedAt = Date.now();
saveChats();
}
renderHistory();
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
finishRename();
} else if (e.key === 'Escape') {
e.preventDefault();
renderHistory();
}
});
input.addEventListener('blur', finishRename);
titleDiv.replaceWith(input);
input.focus();
input.select();
};
// Gerenciar tags da conversa
const editTags = (id) => {
const chat = chats.find(c => c.id === id);
if (!chat) return;
const currentTags = chat.tags || [];
openTagModal(id, currentTags, (newTags) => {
chat.tags = newTags;
chat.updatedAt = Date.now();
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>
<button class="msg-action-btn btn-speak" title="Ouvir 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="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>
<span>Ouvir</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 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 = `
<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-wrapper">
<div class="message-content">
<span class="typing-cursor"></span>
</div>
</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);
});
// Adicionar botões de ação (Copiar, Regenerar, Ouvir) após o streaming
const escapedContent = escapeHtml(assistantContent).replace(/"/g, '&quot;');
const actionsDiv = document.createElement('div');
actionsDiv.className = 'message-actions';
actionsDiv.innerHTML = `
<button class="msg-action-btn btn-copy-msg" title="Copiar resposta" data-content="${escapedContent}">
<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>
<button class="msg-action-btn btn-speak" title="Ouvir resposta" data-content="${escapedContent}">
<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="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>
<span>Ouvir</span>
</button>
`;
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 += `
<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();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initApp);
} else {
initApp();
}