// 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, "'"); } // Função auxiliar para exibir alerta customizado e elegante (substitui o alert nativo) function showCustomAlert(title, message) { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'custom-dialog-overlay'; overlay.innerHTML = `
🔔 ${escapeHtml(title)}

${escapeHtml(message)}

`; const btn = overlay.querySelector('.btn-dialog-confirm'); btn.addEventListener('click', () => { overlay.remove(); resolve(); }); document.body.appendChild(overlay); }); } // Função auxiliar para exibir diálogo de confirmação elegante (substitui o confirm nativo) function showCustomConfirm(title, message, isDanger = false) { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'custom-dialog-overlay'; overlay.innerHTML = `
${isDanger ? '⚠️' : '❓'} ${escapeHtml(title)}

${escapeHtml(message)}

`; const btnCancel = overlay.querySelector('.btn-dialog-cancel'); const btnConfirm = overlay.querySelector(isDanger ? '.btn-dialog-danger' : '.btn-dialog-confirm'); btnCancel.addEventListener('click', () => { overlay.remove(); resolve(false); }); btnConfirm.addEventListener('click', () => { overlay.remove(); resolve(true); }); document.body.appendChild(overlay); }); } // Função auxiliar para gerar nome de arquivo de download formatado como . function getDownloadFileName(type, ext) { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); return `${type}_${year}-${month}-${day}_${hours}-${minutes}-${seconds}.${ext}`; } renderer.code = function(code, language) { // Ajuste para lidar com as diferenças na assinatura do marked.js const codeContent = typeof code === 'object' ? code.text : code; const lang = (typeof code === 'object' ? code.lang : language) || 'txt'; return `
${lang}
${escapeHtml(codeContent)}
`; }; marked.use({ renderer }); // Função global de copiar código para que funcione no onclick window.copyCode = function(button) { const container = button.closest('.code-container'); const codeElement = container.querySelector('code'); const textToCopy = codeElement.textContent; navigator.clipboard.writeText(textToCopy).then(() => { const textSpan = button.querySelector('span'); const originalText = textSpan.textContent; textSpan.textContent = 'Copiado!'; button.style.color = 'var(--success)'; setTimeout(() => { textSpan.textContent = originalText; button.style.color = ''; }, 2000); }).catch(err => { console.error('Erro ao copiar código:', err); }); }; 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'); // Configuração de Identidade do Agente (Kemily/Camila) const btnSettings = document.getElementById('btnSettings'); const settingsModal = document.getElementById('settingsModal'); const closeSettingsModal = document.getElementById('closeSettingsModal'); const btnSaveSettings = document.getElementById('btnSaveSettings'); const settingAgentName = document.getElementById('settingAgentName'); const previewKemilyAvatar = document.getElementById('previewKemilyAvatar'); const uploadKemilyAvatar = document.getElementById('uploadKemilyAvatar'); const previewCamilaAvatar = document.getElementById('previewCamilaAvatar'); const uploadCamilaAvatar = document.getElementById('uploadCamilaAvatar'); const welcomeTitle = document.getElementById('welcomeTitle'); const welcomeAvatarImg = document.getElementById('welcomeAvatarImg'); const sidebarAvatarImg = document.getElementById('sidebarAvatarImg'); const temperatureOptions = document.getElementById('temperatureOptions'); const knowledgeList = document.getElementById('knowledgeList'); const manualKnowledgeInput = document.getElementById('manualKnowledgeInput'); const btnAddKnowledge = document.getElementById('btnAddKnowledge'); window.agentConfig = { agentName: "Kemily", kemilyAvatarUrl: "assets/kemily.png", camilaAvatarUrl: "assets/camila_prof.png", temperaturePreset: 'equilibrado' }; window.temperaturePresets = { tecnico: { value: 0.2, label: 'Técnico e Direto', description: 'Respostas curtas, objetivas, sem rodeios. Ideal para quando precisa só do essencial.' }, equilibrado: { value: 0.6, label: 'Equilibrado', description: 'Bom para uso geral — nem muito curto, nem muito longo.' }, detalhista: { value: 1.0, label: 'Detalhista e Falante', description: 'Respostas extensas, explicações completas, contextualização máxima. Ideal para estudo e revisão.' } }; window.conhecimento = { facts: [], lastUpdated: null }; // Carrega as configurações do servidor const loadAgentConfig = async () => { try { const res = await fetch('/api/agent-config'); if (res.ok) { window.agentConfig = await res.json(); updateAgentIdentityUI(); } } catch (e) { console.error('Erro ao carregar configurações do agente:', e); } }; const updateAgentIdentityUI = () => { if (welcomeTitle) { welcomeTitle.innerText = `Olá! Sou sua assistente virtual e me chamo ${window.agentConfig.agentName}. Como posso ajudar?`; } if (welcomeAvatarImg) { welcomeAvatarImg.src = window.agentConfig.kemilyAvatarUrl; } if (sidebarAvatarImg) { sidebarAvatarImg.src = window.agentConfig.camilaAvatarUrl; } if (settingAgentName) { settingAgentName.value = window.agentConfig.agentName; } if (previewKemilyAvatar) { previewKemilyAvatar.src = window.agentConfig.kemilyAvatarUrl; } if (previewCamilaAvatar) { previewCamilaAvatar.src = window.agentConfig.camilaAvatarUrl; } // Atualiza seleção de temperatura renderTemperatureOptions(); }; // Carregar presets de temperatura do servidor const loadTemperaturePresets = async () => { try { const res = await fetch('/api/temperature-presets'); if (res.ok) { window.temperaturePresets = await res.json(); renderTemperatureOptions(); } } catch (e) { console.error('Erro ao carregar presets de temperatura:', e); } }; // Renderizar opções de temperatura const renderTemperatureOptions = () => { if (!temperatureOptions) return; const preset = window.agentConfig.temperaturePreset || 'equilibrado'; temperatureOptions.innerHTML = Object.entries(window.temperaturePresets).map(([key, t]) => ` `).join(''); // Event listeners para as opções temperatureOptions.querySelectorAll('.temperature-option').forEach(opt => { opt.addEventListener('click', () => { temperatureOptions.querySelectorAll('.temperature-option').forEach(o => o.classList.remove('selected')); opt.classList.add('selected'); opt.querySelector('input').checked = true; }); }); }; // Carregar conhecimento do servidor const loadConhecimento = async () => { try { const res = await fetch('/api/conhecimento'); if (res.ok) { window.conhecimento = await res.json(); renderKnowledgeList(); } } catch (e) { console.error('Erro ao carregar conhecimento:', e); } }; // Renderizar lista de conhecimento const renderKnowledgeList = () => { if (!knowledgeList) return; if (!window.conhecimento.facts || window.conhecimento.facts.length === 0) { knowledgeList.innerHTML = '

Nenhum conhecimento aprendido ainda. A assistente aprende automaticamente quando você ensinar algo novo!

'; return; } knowledgeList.innerHTML = window.conhecimento.facts.map(f => `
${f.source}

${f.content}

${new Date(f.addedAt).toLocaleDateString('pt-BR')}
`).join(''); }; // Iniciar edição de item de conhecimento window.startEditKnowledge = (id) => { const fact = window.conhecimento.facts.find(f => f.id === id); if (!fact) return; const contentDiv = document.getElementById(`knowledge-content-${id}`); const actionsDiv = document.getElementById(`knowledge-actions-${id}`); if (!contentDiv) return; if (actionsDiv) actionsDiv.style.display = 'none'; contentDiv.innerHTML = ` ${fact.source}
`; const textarea = document.getElementById(`edit-textarea-${id}`); if (textarea) { textarea.focus(); textarea.setSelectionRange(textarea.value.length, textarea.value.length); } }; // Salvar edição de item de conhecimento window.saveEditKnowledge = async (id) => { const textarea = document.getElementById(`edit-textarea-${id}`); if (!textarea) return; const newContent = textarea.value.trim(); if (!newContent) { alert('O conteúdo do conhecimento não pode ser vazio.'); return; } try { const res = await fetch(`/api/conhecimento/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: newContent }) }); if (res.ok) { const fact = window.conhecimento.facts.find(f => f.id === id); if (fact) fact.content = newContent; renderKnowledgeList(); } else { const err = await res.json(); alert('Erro ao salvar conhecimento: ' + err.error); } } catch (e) { console.error('Erro ao atualizar conhecimento:', e); alert('Erro de conexão ao salvar conhecimento.'); } }; // Deletar item de conhecimento (função global para onclick) window.deleteKnowledge = async (id) => { if (!confirm('Deseja realmente remover este conhecimento aprendido?')) return; try { const res = await fetch(`/api/conhecimento/${id}`, { method: 'DELETE' }); if (res.ok) { window.conhecimento.facts = window.conhecimento.facts.filter(f => f.id !== id); renderKnowledgeList(); } } catch (e) { console.error('Erro ao deletar conhecimento:', e); } }; // Adicionar conhecimento manualmente if (btnAddKnowledge) { btnAddKnowledge.addEventListener('click', async () => { const input = manualKnowledgeInput?.value.trim(); if (!input) return; try { const res = await fetch('/api/conhecimento', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fact: input, source: 'manual' }) }); if (res.ok) { const data = await res.json(); if (data.added) { manualKnowledgeInput.value = ''; await loadConhecimento(); } else { alert('Esta informação já existe ou é similar a um conhecimento que a IA já possui.'); } } else { alert('Erro ao salvar no servidor. Verifique sua conexão.'); } } catch (e) { console.error('Erro ao adicionar conhecimento:', e); alert('Erro ao processar requisição.'); } }); } // Função para extrair knowledge tags da resposta da IA e enviar ao servidor window.extractAndSaveKnowledge = async (assistantMessage) => { if (!assistantMessage || !assistantMessage.includes('[KNOWLEDGE:')) return; try { await fetch('/api/conhecimento/extrair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: assistantMessage }) }); } catch (e) { console.error('Erro ao extrair conhecimento:', e); } }; // Variáveis temporárias para avatares (em base64 ou URLs novas) let newKemilyAvatarData = null; let newCamilaAvatarData = null; // Eventos do Modal if (btnSettings) { btnSettings.addEventListener('click', () => { newKemilyAvatarData = null; newCamilaAvatarData = null; if (settingAgentName) settingAgentName.value = window.agentConfig.agentName; if (previewKemilyAvatar) previewKemilyAvatar.src = window.agentConfig.kemilyAvatarUrl; if (previewCamilaAvatar) previewCamilaAvatar.src = window.agentConfig.camilaAvatarUrl; renderTemperatureOptions(); loadConhecimento(); settingsModal.style.display = 'flex'; }); } if (closeSettingsModal) { closeSettingsModal.addEventListener('click', () => { settingsModal.style.display = 'none'; }); } // Tab switching logic document.querySelectorAll('.settings-tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.settings-tab-content').forEach(c => c.classList.remove('active')); tab.classList.add('active'); const targetId = tab.dataset.tab; const targetContent = document.getElementById(targetId); if (targetContent) targetContent.classList.add('active'); }); }); // Fechar clicando fora do modal-content if (settingsModal) { settingsModal.addEventListener('click', (e) => { if (e.target === settingsModal) { settingsModal.style.display = 'none'; } }); } // Previews de Imagem const handleAvatarFileSelect = (input, previewImg, callback) => { const file = input.files[0]; if (file) { const reader = new FileReader(); reader.onload = (e) => { previewImg.src = e.target.result; callback(e.target.result); }; reader.readAsDataURL(file); } }; if (uploadKemilyAvatar) { uploadKemilyAvatar.addEventListener('change', () => { handleAvatarFileSelect(uploadKemilyAvatar, previewKemilyAvatar, (base64) => { newKemilyAvatarData = base64; }); }); } if (uploadCamilaAvatar) { uploadCamilaAvatar.addEventListener('change', () => { handleAvatarFileSelect(uploadCamilaAvatar, previewCamilaAvatar, (base64) => { newCamilaAvatarData = base64; }); }); } // Salvar Configurações if (btnSaveSettings) { btnSaveSettings.addEventListener('click', async () => { btnSaveSettings.disabled = true; btnSaveSettings.innerText = 'Salvando...'; try { let kemilyUrl = window.agentConfig.kemilyAvatarUrl; let camilaUrl = window.agentConfig.camilaAvatarUrl; // Se houver novas imagens, faz upload if (newKemilyAvatarData) { const res = await fetch('/api/upload-avatar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ avatarType: 'kemily', base64Data: newKemilyAvatarData }) }); if (res.ok) { const data = await res.json(); kemilyUrl = data.url; } } if (newCamilaAvatarData) { const res = await fetch('/api/upload-avatar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ avatarType: 'camila', base64Data: newCamilaAvatarData }) }); if (res.ok) { const data = await res.json(); camilaUrl = data.url; } } // Envia as configurações gerais const selectedTempPreset = temperatureOptions?.querySelector('input[name="temperature"]:checked')?.value || 'equilibrado'; const resConfig = await fetch('/api/agent-config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agentName: settingAgentName.value, kemilyAvatarUrl: kemilyUrl, camilaAvatarUrl: camilaUrl, temperaturePreset: selectedTempPreset }) }); if (resConfig.ok) { const result = await resConfig.json(); window.agentConfig = result.config; updateAgentIdentityUI(); settingsModal.style.display = 'none'; } else { alert('Erro ao salvar as configurações.'); } } catch (err) { console.error(err); alert('Ocorreu um erro ao salvar.'); } finally { btnSaveSettings.disabled = false; btnSaveSettings.innerText = 'Salvar Configurações'; } }); } // Executa o carregamento inicial das configurações loadAgentConfig(); // 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; let lastInputWasVoice = false; // Rastrear se a última mensagem veio do microfone // ========================================================================== // 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 & SIDEBAR TAGS // ========================================================================== const AVAILABLE_TAGS = [ { id: 'relatorios', label: '📋 Relatórios' }, { id: 'planejamento', label: '📝 Planejamento' }, { id: 'mindlab', label: '🧠 Mind Lab' }, { id: 'estudos', label: '📚 Estudos' }, { id: 'htpc', label: '💻 HTPC' }, { id: 'ata', label: '📝 ATA' }, { id: 'sugestao', label: '💡 Sugestão' }, { id: 'duvida', label: '❓ 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 => ` `).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(); }); }); }; const renderSidebarTags = () => { if (!sidebarTags) return; let tagsHtml = ``; tagsHtml += AVAILABLE_TAGS.map(tag => ` `).join(''); sidebarTags.innerHTML = tagsHtml; }; 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; activeTag = tagBtn.dataset.tag; renderHistory(); }); // ========================================================================== // VOZ — WEB SPEECH API (ditado + conversa por voz) // ========================================================================== const initVoiceRecognition = () => { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { btnVoice.style.display = 'none'; console.warn('SpeechRecognition não suportado neste navegador.'); 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); // Marca que o input veio do microfone para auto-responder por voz lastInputWasVoice = true; // Envia automaticamente a mensagem ditada if (transcript.trim()) { setTimeout(() => { chatForm.dispatchEvent(new Event('submit', { cancelable: true })); }, 300); } }; 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 de ouvir'); } else { btnVoice.classList.remove('listening'); btnVoice.setAttribute('title', 'Conversar por voz'); } }; btnVoice.addEventListener('click', () => { if (!recognition) return; if (isListening) { recognition.stop(); } else { recognition.start(); setVoiceState(true); } }); // ========================================================================== // TTS — Speech Synthesis nativa (voz do navegador) // ========================================================================== // Selecionar melhor voz pt-BR disponível no dispositivo de forma dinâmica const getBestVoice = () => { const voices = window.speechSynthesis.getVoices(); const ptBR = voices.filter(v => v.lang === 'pt-BR' || v.lang === 'pt_BR'); if (ptBR.length === 0) { const pt = voices.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]; }; const resetSpeakBtnState = (btn) => { if (btn) { btn.classList.remove('speaking'); const span = btn.querySelector('span'); if (span) span.textContent = 'Ouvir'; } }; let currentSpeakingBtn = null; let currentAudio = null; let ttsFallbackTimeout = null; // Função auxiliar para parar qualquer áudio em andamento (tanto nativo quanto do backend) const stopAllSpeech = () => { // Parar áudio do backend if (currentAudio) { currentAudio.pause(); currentAudio = null; } // Parar SpeechSynthesis nativo window.speechSynthesis.cancel(); // Limpar timeouts pendentes if (ttsFallbackTimeout) { clearTimeout(ttsFallbackTimeout); ttsFallbackTimeout = null; } // Resetar estado do botão ativo anterior if (currentSpeakingBtn) { resetSpeakBtnState(currentSpeakingBtn); currentSpeakingBtn = null; } }; const speakTextLocalFallback = (cleanText, btn) => { console.log('[TTS] Acionando fallback local (voz nativa do navegador)...'); // Configura SpeechSynthesis nativo const utterance = new SpeechSynthesisUtterance(cleanText); 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'; } }; const handleStop = (event) => { console.log('TTS nativo finalizado ou com erro:', event.type); if (currentSpeakingBtn === btn) { resetSpeakBtnState(btn); currentSpeakingBtn = null; } }; utterance.onend = handleStop; utterance.onerror = handleStop; window.speechSynthesis.speak(utterance); }; // Falar texto com reprodução híbrida (prioriza Opção A: MsEdgeTTS com fallback local) const speakText = async (text, btn) => { if (!text) return; // Se clicar no botão que já está ativo, para tudo e retorna if (currentSpeakingBtn === btn) { stopAllSpeech(); return; } // Para qualquer reprodução anterior stopAllSpeech(); currentSpeakingBtn = btn; // Limpar markdown e emojis antes de falar const clean = text .replace(/\p{Extended_Pictographic}/gu, '') // Remove emojis pictográficos (ex: ✍️, 📝, 🧒) .replace(/[\u{1F300}-\u{1F9FF}]|[\u{2700}-\u{27BF}]|[\u{2600}-\u{26FF}]/gu, '') // Fallback para símbolos/dingbats comuns .replace(/```[\s\S]*?```/g, '') .replace(/`[^`]+`/g, '') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/[*_#~\[\]>]/g, '') .replace(/\n+/g, '. ') .replace(/\s{2,}/g, ' ') .trim(); if (!clean) { currentSpeakingBtn = null; return; } // Coloca o botão em estado de "carregando" if (btn) { btn.classList.add('speaking'); const span = btn.querySelector('span'); if (span) span.textContent = 'Carregando...'; } let fallbackTriggered = false; // Configura o timeout de 15 segundos para o fallback nativo ttsFallbackTimeout = setTimeout(() => { if (currentSpeakingBtn === btn) { fallbackTriggered = true; if (currentAudio) { currentAudio.pause(); currentAudio = null; } speakTextLocalFallback(clean, btn); } }, 15000); try { console.log('[TTS] Solicitando voz neural de alta qualidade ao backend...'); const response = await fetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: clean }) }); if (!response.ok) { throw new Error(`Erro no servidor: ${response.status}`); } const blob = await response.blob(); // Se o fallback já foi acionado durante a espera, ignora o retorno do áudio do backend if (fallbackTriggered) { return; } // Limpar o timeout de fallback já que o áudio respondeu a tempo if (ttsFallbackTimeout) { clearTimeout(ttsFallbackTimeout); ttsFallbackTimeout = null; } const audioUrl = URL.createObjectURL(blob); currentAudio = new Audio(audioUrl); currentAudio.onplay = () => { if (currentSpeakingBtn === btn) { const span = btn.querySelector('span'); if (span) span.textContent = 'Parar'; } }; const handleAudioStop = () => { if (currentSpeakingBtn === btn) { resetSpeakBtnState(btn); currentSpeakingBtn = null; currentAudio = null; } URL.revokeObjectURL(audioUrl); }; currentAudio.onended = handleAudioStop; currentAudio.onerror = (e) => { console.error('[TTS] Erro ao reproduzir áudio do backend:', e); if (currentSpeakingBtn === btn && !fallbackTriggered) { fallbackTriggered = true; speakTextLocalFallback(clean, btn); } }; await currentAudio.play(); } catch (err) { console.error('[TTS] Falha ao obter áudio do backend, acionando fallback local imediatamente:', err); // Limpa timeout e ativa fallback local imediatamente se der erro de rede if (ttsFallbackTimeout) { clearTimeout(ttsFallbackTimeout); ttsFallbackTimeout = null; } if (currentSpeakingBtn === btn && !fallbackTriggered) { fallbackTriggered = true; speakTextLocalFallback(clean, btn); } } }; // ========================================================================== // 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 = `${file.name}`; } else { previewHtml = ` `; } item.innerHTML = ` ${previewHtml} ${file.name} `; 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 ? `` : ''; const tagBadges = (chat.tags || []).map(tag => `${getTagEmoji(tag)}` ).join(''); item.innerHTML = `
${pinIcon}
${escapeHtml(chat.title)}
${tagBadges ? `
${tagBadges}
` : ''}
`; return item; }; // Emoji para cada tag const getTagEmoji = (tag) => { const emojis = { relatorios: '📋', planejamento: '📝', mindlab: '🧠', estudos: '📚', htpc: '💻', ata: '📝', sugestao: '💡', duvida: '❓' }; return emojis[tag] || '🏷️'; }; // Renderizar histórico completo const renderHistory = () => { renderSidebarTags(); // Renderizar tags da sidebar de forma dinâmica e atualizada historyItems.innerHTML = ''; if (chats.length === 0) { historyItems.innerHTML = '
Nenhum chat salvo
'; return; } // Aplicar filtros let filtered = [...chats]; filtered = filterByTag(filtered); filtered = filterBySearch(filtered); // Se tem busca, mostrar resultados direto (sem grupo) if (searchQuery.trim()) { searchResultsHeader.style.display = 'block'; searchResultsCount.textContent = `${filtered.length} resultado${filtered.length !== 1 ? 's' : ''}`; filtered.sort((a, b) => b.updatedAt - a.updatedAt); filtered.forEach(chat => { const item = createHistoryItem(chat); item.addEventListener('click', (e) => { if (e.target.closest('.btn-delete-chat')) { e.stopPropagation(); deleteChat(chat.id); return; } if (e.target.closest('.btn-pin-chat')) { e.stopPropagation(); togglePin(chat.id); return; } if (e.target.closest('.btn-rename-chat')) { e.stopPropagation(); 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 = `

${label}

`; const container = document.createElement('div'); container.className = 'history-items'; items.forEach(chat => { const item = createHistoryItem(chat); item.addEventListener('click', (e) => { if (e.target.closest('.btn-delete-chat')) { e.stopPropagation(); deleteChat(chat.id); return; } if (e.target.closest('.btn-pin-chat')) { e.stopPropagation(); togglePin(chat.id); return; } if (e.target.closest('.btn-rename-chat')) { e.stopPropagation(); 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, msg.media); }); 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, media = null) => { const isUser = role === 'user'; const row = document.createElement('div'); row.className = `message-row ${role}`; const avatarHtml = isUser ? `
Camila
` : `
${window.agentConfig?.agentName || 'Kemily'}
`; let contentHtml = ''; if (isUser) { contentHtml = `

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

`; } else { if (media) { if (media.type === 'image') { contentHtml = `
Ilustração gerada

✨ "${escapeHtml(media.originalPrompt)}"

`; } else if (media.type === 'audio') { const audioId = 'audio_' + Date.now() + '_' + Math.floor(Math.random() * 10000); contentHtml = `
0:00

🎵 "${escapeHtml(media.originalPrompt)}"

`; } else if (media.type === 'video') { contentHtml = `

🎥 "${escapeHtml(media.originalPrompt)}"

`; } } else { if (content.trim().startsWith('
')) { 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, forcedIntent = 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 = `
${window.agentConfig?.agentName || 'Kemily'}
`; 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, removendo dados base64 pesados de mídias const messagesPayload = chat.messages.map(m => { let cleanContent = m.content || ''; if (cleanContent.includes('data:image/') || cleanContent.includes('data:audio/') || cleanContent.includes('data:video/')) { const typeLabel = cleanContent.includes('custom-audio-player') ? 'Áudio/Música' : (cleanContent.includes('media-video-container') ? 'Vídeo' : 'Imagem'); cleanContent = `[Mídia gerada anteriormente: ${typeLabel}]`; } return { role: m.role, content: cleanContent }; }); // Fazer a chamada HTTP usando stream const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: messagesPayload, forcedIntent: forcedIntent }) }); 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 = `

${escapeHtml(parsed.message)}

`; scrollToBottom(); } else if (parsed.type === 'image') { isMediaResponse = true; mediaMsgData = { type: 'image', url: parsed.url, originalPrompt: parsed.originalPrompt }; assistantContent = `
Ilustração gerada

✨ "${escapeHtml(parsed.originalPrompt)}"

`; 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 = `
0:00

🎵 "${escapeHtml(parsed.originalPrompt)}"

`; botContentDiv.innerHTML = assistantContent; scrollToBottom(); } else if (parsed.type === 'video') { isMediaResponse = true; mediaMsgData = { type: 'video', url: parsed.url, originalPrompt: parsed.originalPrompt }; assistantContent = `

🎥 "${escapeHtml(parsed.originalPrompt)}"

`; 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); }); } // Extrair e salvar conhecimento aprendido (tags [KNOWLEDGE: ...]) window.extractAndSaveKnowledge(assistantContent); // 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(); // AUTO-TTS: Se a mensagem veio do microfone, ler a resposta em voz alta automaticamente if (lastInputWasVoice && assistantContent && !isMediaResponse) { console.log('[VOZ] Mensagem veio do microfone — respondendo por voz automaticamente.'); setTimeout(() => { speakText(assistantContent, speakBtn); }, 400); } lastInputWasVoice = false; // 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); }); // Botões de Modos Rápidos de Prompt (Rodapé) document.querySelectorAll('.btn-prompt-mode').forEach(btn => { btn.addEventListener('click', () => { if (btn.id === 'barBtnEstudio') return; // Manipulado separadamente if (btn.id === 'barBtnComics') return; // Manipulado separadamente const mode = btn.dataset.mode; const text = userInput.value.trim(); let suggestion = ""; let baseText = text; // Se o texto atual já for uma das sugestões padrão, limpamos para não duplicar const suggestionsList = [ "Crie uma música pedagógica alegre e animada sobre ", "Crie um roteiro detalhado para um vídeo educativo sobre ", "Crie uma ilustração pedagógica com cores vibrantes mostrando ", "Escreva uma poesia infantil rimada e educativa sobre ", "Escreva uma história educativa infantil curta sobre " ]; for (const sugg of suggestionsList) { if (baseText.startsWith(sugg)) { baseText = baseText.replace(sugg, "").trim(); break; } } if (mode === 'AUDIO') { suggestion = "Crie uma música pedagógica alegre e animada sobre "; } else if (mode === 'VIDEO') { suggestion = "Crie um roteiro detalhado para um vídeo educativo sobre "; } else if (mode === 'IMAGE') { suggestion = "Crie uma ilustração pedagógica com cores vibrantes mostrando "; } else if (mode === 'POETRY') { suggestion = "Escreva uma poesia infantil rimada e educativa sobre "; } else if (mode === 'TEXT') { suggestion = "Escreva uma história educativa infantil curta sobre "; } userInput.value = suggestion + baseText; userInput.focus(); // Mover cursor para o final do texto userInput.selectionStart = userInput.selectionEnd = userInput.value.length; // Ajustar altura do textarea para acomodar a sugestão userInput.style.height = 'auto'; userInput.style.height = userInput.scrollHeight + 'px'; if (btnSend) btnSend.disabled = false; }); }); // 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(); // ============================================================ // SISTEMA DE OBSERVAÇÃO POR VOZ // ============================================================ const recordModal = document.getElementById('recordModal'); const fabRecord = document.getElementById('fabRecord'); const btnExitRecord = document.getElementById('btnExitRecord'); const btnStopRecord = document.getElementById('btnStopRecord'); const recordStateRecording = document.getElementById('recordStateRecording'); const recordStateProcessing = document.getElementById('recordStateProcessing'); const recordStatePreview = document.getElementById('recordStatePreview'); const recordTimer = document.getElementById('recordTimer'); const reportContent = document.getElementById('reportContent'); const previewDateTime = document.getElementById('previewDateTime'); const btnDiscardReport = document.getElementById('btnDiscardReport'); const btnSaveReport = document.getElementById('btnSaveReport'); // Novos elementos de transcrição const recordStateTranscribed = document.getElementById('recordStateTranscribed'); const transcriptionTextarea = document.getElementById('transcriptionTextarea'); const btnDiscardTranscription = document.getElementById('btnDiscardTranscription'); const btnAnalyzeTranscription = document.getElementById('btnAnalyzeTranscription'); const recordProcessingText = document.getElementById('recordProcessingText'); const recordProcessingSubtext = document.getElementById('recordProcessingSubtext'); // Variáveis de gravação let mediaRecorder = null; let audioChunks = []; let recordingStartTime = null; let timerInterval = null; let audioContext = null; let analyser = null; let frequencyData = null; let silenceFrames = 0; let totalFrames = 0; let vadCheckInterval = null; let generatedReport = null; let generatedCriancas = null; let generatedTurma = 'não informada'; let generatedTags = []; // Fechar modal - colocada no topo para evitar problemas de inicialização function closeRecordModal() { if (mediaRecorder && mediaRecorder.state !== 'inactive') { try { mediaRecorder.stop(); } catch (e) {} if (mediaRecorder.stream) { mediaRecorder.stream.getTracks().forEach(t => t.stop()); } } clearInterval(timerInterval); clearInterval(vadCheckInterval); if (audioContext) { audioContext.close(); audioContext = null; } if (analyser) { analyser = null; frequencyData = null; } audioChunks = []; generatedReport = null; recordModal.style.display = 'none'; } // Função para controlar a exibição dos estados function showRecordState(state) { recordStateRecording.style.display = 'none'; recordStateProcessing.style.display = 'none'; if (recordStateTranscribed) recordStateTranscribed.style.display = 'none'; recordStatePreview.style.display = 'none'; if (state === 'recording') { recordStateRecording.style.display = 'flex'; } else if (state === 'processing') { recordStateProcessing.style.display = 'flex'; } else if (state === 'transcribed') { if (recordStateTranscribed) recordStateTranscribed.style.display = 'flex'; } else if (state === 'preview') { recordStatePreview.style.display = 'flex'; } } // Abrir modal e iniciar gravação if (fabRecord) { fabRecord.addEventListener('click', async () => { audioChunks = []; generatedReport = null; // Mostra estado inicial (gravando) showRecordState('recording'); recordModal.style.display = 'flex'; // Inicia timer recordingStartTime = Date.now(); timerInterval = setInterval(updateTimer, 1000); updateTimer(); // Solicita permissão e inicia gravação try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); let options = {}; if (typeof MediaRecorder.isTypeSupported === 'function') { if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) { options = { mimeType: 'audio/webm;codecs=opus' }; } else if (MediaRecorder.isTypeSupported('audio/webm')) { options = { mimeType: 'audio/webm' }; } else if (MediaRecorder.isTypeSupported('audio/ogg')) { options = { mimeType: 'audio/ogg' }; } else if (MediaRecorder.isTypeSupported('audio/mp4')) { options = { mimeType: 'audio/mp4' }; } } // Configurar AudioContext para VAD audioContext = new AudioContext(); const source = audioContext.createMediaStreamSource(stream); analyser = audioContext.createAnalyser(); analyser.fftSize = 256; source.connect(analyser); frequencyData = new Uint8Array(analyser.frequencyBinCount); // Iniciar verificação de silêncio (VAD) silenceFrames = 0; totalFrames = 0; vadCheckInterval = setInterval(() => { if (!analyser) return; analyser.getByteFrequencyData(frequencyData); const avg = frequencyData.reduce((a, b) => a + b, 0) / frequencyData.length; totalFrames++; if (avg < 10) { silenceFrames++; } else { silenceFrames = Math.max(0, silenceFrames - 2); // recupera mais rápido } // Atualizar indicator visual const recordWaveform = document.getElementById('recordWaveform'); if (recordWaveform && recordWaveform.children[0]) { const bars = recordWaveform.children; bars.forEach((bar) => { const h = Math.max(4, (avg / 255) * 50 + Math.random() * 10); bar.style.height = `${h}px`; }); } // Se mais de 60% dos últimos 30 frames for silêncio, avisar if (totalFrames > 30 && (silenceFrames / 30) > 0.6) { const btnStopRecord = document.getElementById('btnStopRecord'); if (btnStopRecord && !btnStopRecord.disabled) { btnStopRecord.style.borderColor = '#ff6b6b'; btnStopRecord.title = 'Silêncio detectado - Clique se quiser parar'; } } else { const btnStopRecord = document.getElementById('btnStopRecord'); if (btnStopRecord) { btnStopRecord.style.borderColor = ''; btnStopRecord.title = ''; } } }, 100); mediaRecorder = new MediaRecorder(stream, options); mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunks.push(e.data); }; mediaRecorder.start(100); // chunk a cada 100ms } catch (err) { console.error('Erro ao acessar microfone:', err); alert('Não foi possível acessar o microfone. Verifique as permissões.'); closeRecordModal(); } }); } // Atualizar timer function updateTimer() { if (!recordingStartTime) return; const elapsed = Math.floor((Date.now() - recordingStartTime) / 1000); const mins = Math.floor(elapsed / 60).toString().padStart(2, '0'); const secs = (elapsed % 60).toString().padStart(2, '0'); recordTimer.textContent = `${mins}:${secs}`; } // Parar gravação e transcrever if (btnStopRecord) { btnStopRecord.addEventListener('click', async () => { if (mediaRecorder && mediaRecorder.state !== 'inactive') { try { mediaRecorder.stop(); } catch (e) {} if (mediaRecorder.stream) { mediaRecorder.stream.getTracks().forEach(t => t.stop()); } } clearInterval(timerInterval); clearInterval(vadCheckInterval); if (audioContext) { audioContext.close(); audioContext = null; } if (analyser) { analyser = null; frequencyData = null; } // Mostra processando com texto de transcrição if (recordProcessingText) recordProcessingText.textContent = 'Transcrevendo áudio...'; if (recordProcessingSubtext) recordProcessingSubtext.textContent = 'Aguarde a transcrição por Whisper'; showRecordState('processing'); // Envia áudio para servidor const blob = new Blob(audioChunks, { type: 'audio/webm' }); const formData = new FormData(); formData.append('audio', blob, 'recording.webm'); try { const res = await fetch('/api/observacao/transcrever', { method: 'POST', body: formData }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.error || 'Erro na transcrição'); } const data = await res.json(); // Exibe o texto transcrito na área de texto para edição if (transcriptionTextarea) { transcriptionTextarea.value = data.transcribedText || ''; } showRecordState('transcribed'); } catch (err) { console.error('Erro ao processar observação:', err); alert('Erro ao transcrever áudio: ' + err.message); closeRecordModal(); } }); } // Ação de analisar transcrição if (btnAnalyzeTranscription) { btnAnalyzeTranscription.addEventListener('click', async () => { const text = transcriptionTextarea ? transcriptionTextarea.value.trim() : ''; if (!text || text.length < 5) { alert('Por favor, digite ou revise a transcrição (mínimo de 5 caracteres).'); return; } // Mostra processando com texto de análise if (recordProcessingText) recordProcessingText.textContent = 'Analisando observação...'; if (recordProcessingSubtext) recordProcessingSubtext.textContent = 'Gerando relatório pedagógico estruturado'; showRecordState('processing'); try { const res = await fetch('/api/observacao/analisar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcribedText: text }) }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.error || 'Erro na análise'); } const data = await res.json(); generatedReport = data.relatorio || data.report; generatedCriancas = data.criancas; generatedTurma = data.turma || 'não informada'; generatedTags = data.tags || []; showReportPreview(data); } catch (err) { console.error('Erro ao analisar observação:', err); alert('Erro ao analisar observação: ' + err.message); showRecordState('transcribed'); } }); } // Ação de descartar transcrição if (btnDiscardTranscription) { btnDiscardTranscription.addEventListener('click', () => { closeRecordModal(); }); } // Mostrar prévia do relatório function showReportPreview(data) { showRecordState('preview'); const now = new Date(); previewDateTime.textContent = now.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); // Monta HTML com metadados const criancasHtml = data.criancas && data.criancas.length ? `
👧 Crianças: ${data.criancas.join(', ')}
` : `
👧 Crianças: não identificadas
`; const tagsHtml = data.tags && data.tags.length ? `
🏷️ Tags: ${data.tags.map(t => `${t}`).join(' ')}
` : ''; const turmaHtml = data.turma && data.turma !== 'não informada' ? `
🏫 Turma: ${data.turma}
` : ''; reportContent.innerHTML = `
${criancasHtml}${turmaHtml}${tagsHtml}

${marked.parse(generatedReport || 'Relatório não disponível.')} `; reportContent.querySelectorAll('pre code').forEach(b => hljs.highlightElement(b)); } // Descartar relatório if (btnDiscardReport) { btnDiscardReport.addEventListener('click', () => { generatedReport = null; closeRecordModal(); }); } // Salvar relatório if (btnSaveReport) { btnSaveReport.addEventListener('click', async () => { if (!generatedReport) return; btnSaveReport.disabled = true; btnSaveReport.textContent = 'Salvando...'; try { const res = await fetch('/api/observacao/salvar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ report: generatedReport, criancas: generatedCriancas, turma: generatedTurma, tags: generatedTags }) }); const data = await res.json(); if (data.success) { alert('Relatório salvo com sucesso!'); } else { alert('Erro ao salvar: ' + (data.error || 'desconhecido')); } } catch (err) { console.error('Erro ao salvar:', err); alert('Erro ao salvar o relatório.'); } finally { btnSaveReport.disabled = false; btnSaveReport.textContent = 'Salvar'; closeRecordModal(); } }); } // Sair / descartar if (btnExitRecord) { btnExitRecord.addEventListener('click', closeRecordModal); } // Fechar clicando fora if (recordModal) { recordModal.addEventListener('click', (e) => { if (e.target === recordModal) closeRecordModal(); }); } // ============================================================ // MODAL: MINHAS OBSERVAÇÕES // ============================================================ const obsModal = document.getElementById('obsModal'); const btnObservacoes = document.getElementById('btnObservacoes'); const btnCloseObsModal = document.getElementById('btnCloseObsModal'); const obsList = document.getElementById('obsList'); const obsFilterMes = document.getElementById('obsFilterMes'); const obsFilterTag = document.getElementById('obsFilterTag'); const obsSearchCrianca = document.getElementById('obsSearchCrianca'); let allObservations = []; // Abrir modal de observações if (btnObservacoes) { btnObservacoes.addEventListener('click', async () => { obsModal.style.display = 'flex'; obsList.innerHTML = '
Carregando...
'; try { const res = await fetch('/api/observacoes'); const data = await res.json(); allObservations = data.observations || []; populateMonthFilter(); renderObsList(); } catch (err) { console.error('Erro ao carregar observações:', err); obsList.innerHTML = '
Erro ao carregar observações.
'; } }); } // Fechar modal if (btnCloseObsModal) { btnCloseObsModal.addEventListener('click', () => { obsModal.style.display = 'none'; }); } if (obsModal) { obsModal.addEventListener('click', (e) => { if (e.target === obsModal) obsModal.style.display = 'none'; }); } // Filtros if (obsFilterMes) obsFilterMes.addEventListener('change', renderObsList); if (obsFilterTag) obsFilterTag.addEventListener('change', renderObsList); if (obsSearchCrianca) obsSearchCrianca.addEventListener('input', renderObsList); // Preenche select de meses function populateMonthFilter() { if (!obsFilterMes) return; const months = [...new Set(allObservations.map(o => o.date?.slice(0, 7)))] .filter(Boolean).sort().reverse(); obsFilterMes.innerHTML = '' + months.map(m => ``).join(''); } // Renderiza lista filtrada function renderObsList() { if (!obsList) return; const mes = obsFilterMes?.value || ''; const tag = obsFilterTag?.value || ''; const search = obsSearchCrianca?.value.toLowerCase() || ''; const filtered = allObservations.filter(o => { if (mes && !o.date?.startsWith(mes)) return false; if (tag && !o.tags?.includes(tag)) return false; if (search) { const nomes = (o.criancas || []).join(' ').toLowerCase(); if (!nomes.includes(search) && !(o.preview || '').toLowerCase().includes(search)) return false; } return true; }); if (filtered.length === 0) { obsList.innerHTML = '
Nenhuma observação encontrada.
'; return; } obsList.innerHTML = filtered.map(o => { const tagsHtml = (o.tags || []).map(t => `${t}`).join(''); const criancasHtml = (o.criancas || []).map(c => `${c}`).join(''); const dateFormatted = o.date ? o.date.split('-').reverse().join('/') : ''; return `
📋 ${dateFormatted}${o.time ? ' às ' + o.time : ''} ${o.turma && o.turma !== 'não informada' ? `🏫 ${o.turma}` : ''}
${o.preview || 'Sem prévia disponível.'}
`; }).join(''); // Click para abrir observação obsList.querySelectorAll('.obs-item').forEach(item => { item.addEventListener('click', async () => { const ym = item.dataset.yearmonth; const fn = item.dataset.filename; if (!ym || !fn) return; try { const res = await fetch(`/api/observacoes/${ym}/${fn}`); const text = await res.text(); showObsDetail(text); } catch (err) { console.error('Erro ao carregar observação:', err); } }); }); }; // Mostra detalhe da observação em modal const showObsDetail = (mdText) => { const modal = document.createElement('div'); modal.className = 'settings-modal'; modal.style.display = 'flex'; modal.innerHTML = `

📋 Observação

${marked.parse(mdText.replace(/^---[\s\S]*?---\n/, ''))}
`; document.body.appendChild(modal); modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); }; // ============================================================ // CRUD DE MODELOS DE RELATÓRIO // ============================================================ const modelosRelatorioModal = document.getElementById('modelosRelatorioModal'); const btnModelosRelatorio = document.getElementById('btnModelosRelatorio'); const btnCloseModelosModal = document.getElementById('btnCloseModelosModal'); const templatesList = document.getElementById('templatesList'); const btnCreateTemplate = document.getElementById('btnCreateTemplate'); const btnCancelTemplate = document.getElementById('btnCancelTemplate'); const btnSaveTemplate = document.getElementById('btnSaveTemplate'); const templateFormId = document.getElementById('templateFormId'); const templateFormNome = document.getElementById('templateFormNome'); const templateFormFinalidade = document.getElementById('templateFormFinalidade'); const templateFormPeriodicidade = document.getElementById('templateFormPeriodicidade'); const templateFormEstrutura = document.getElementById('templateFormEstrutura'); const templateFormTitle = document.getElementById('templateFormTitle'); let allTemplates = []; // Abrir modal de modelos if (btnModelosRelatorio) { btnModelosRelatorio.addEventListener('click', async () => { modelosRelatorioModal.style.display = 'flex'; clearTemplateForm(); await fetchTemplates(); }); } // Fechar modal de modelos if (btnCloseModelosModal) { btnCloseModelosModal.addEventListener('click', () => { modelosRelatorioModal.style.display = 'none'; }); } if (modelosRelatorioModal) { modelosRelatorioModal.addEventListener('click', (e) => { if (e.target === modelosRelatorioModal) modelosRelatorioModal.style.display = 'none'; }); } // Buscar modelos do backend async function fetchTemplates() { templatesList.innerHTML = '
Carregando...
'; try { const res = await fetch('/api/modelos'); allTemplates = await res.json(); renderTemplatesList(); } catch (err) { console.error('Erro ao buscar modelos:', err); templatesList.innerHTML = '
Erro ao carregar modelos.
'; } } // Renderizar a lista de modelos na esquerda function renderTemplatesList() { if (allTemplates.length === 0) { templatesList.innerHTML = '
Nenhum modelo cadastrado.
'; return; } templatesList.innerHTML = allTemplates.map(t => `
${t.nome}
${t.periodicidade} • ${t.finalidade || 'Sem finalidade'}
`).join(''); // Eventos de clique na lista para editar/deletar/duplicar templatesList.querySelectorAll('.obs-item').forEach(item => { item.addEventListener('click', (e) => { if (e.target.tagName === 'BUTTON') return; const id = item.dataset.id; const template = allTemplates.find(t => t.id == id); if (template) fillTemplateForm(template); }); }); templatesList.querySelectorAll('.template-duplicate-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const id = btn.dataset.id; const template = allTemplates.find(t => t.id == id); if (template) { fillTemplateForm(template); templateFormId.value = ''; templateFormNome.value = template.nome + ' (Cópia)'; templateFormTitle.textContent = 'Duplicar Modelo'; if (templateFormNome) templateFormNome.focus(); } }); }); templatesList.querySelectorAll('.template-edit-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const id = btn.dataset.id; const template = allTemplates.find(t => t.id == id); if (template) fillTemplateForm(template); }); }); templatesList.querySelectorAll('.template-delete-btn').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = btn.dataset.id; const template = allTemplates.find(t => t.id == id); if (!template) return; if (confirm(`Tem certeza que deseja excluir o modelo "${template.nome}"?`)) { try { const res = await fetch(`/api/modelos/${id}`, { method: 'DELETE' }); if (res.ok) { alert('Modelo excluído com sucesso!'); clearTemplateForm(); await fetchTemplates(); } else { const err = await res.json(); alert('Erro ao excluir: ' + err.error); } } catch (err) { console.error('Erro ao excluir modelo:', err); } } }); }); } // Preencher formulário de edição function fillTemplateForm(template) { templateFormId.value = template.id; templateFormNome.value = template.nome; templateFormFinalidade.value = template.finalidade || ''; templateFormPeriodicidade.value = template.periodicidade || 'Semanal'; templateFormEstrutura.value = template.estrutura || ''; templateFormTitle.textContent = 'Editar Modelo'; } // Limpar formulário function clearTemplateForm() { templateFormId.value = ''; templateFormNome.value = ''; templateFormFinalidade.value = ''; templateFormPeriodicidade.value = 'Semanal'; templateFormEstrutura.value = ''; templateFormTitle.textContent = 'Novo Modelo'; } // Botão "Novo Modelo" if (btnCreateTemplate) { btnCreateTemplate.addEventListener('click', clearTemplateForm); } // Botão "Cancelar" if (btnCancelTemplate) { btnCancelTemplate.addEventListener('click', () => { clearTemplateForm(); if (modelosRelatorioModal) modelosRelatorioModal.style.display = 'none'; }); } // Botão "Salvar Modelo" if (btnSaveTemplate) { btnSaveTemplate.addEventListener('click', async () => { const id = templateFormId.value; const nome = templateFormNome.value.trim(); const finalidade = templateFormFinalidade.value.trim(); const periodicidade = templateFormPeriodicidade.value; const estrutura = templateFormEstrutura.value.trim(); if (!nome || !estrutura) { alert('Nome e estrutura do modelo são obrigatórios.'); return; } const body = { nome, finalidade, periodicidade, estrutura }; const url = id ? `/api/modelos/${id}` : '/api/modelos'; const method = id ? 'PUT' : 'POST'; try { const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (res.ok) { alert('Modelo salvo com sucesso!'); clearTemplateForm(); await fetchTemplates(); } else { const err = await res.json(); alert('Erro ao salvar modelo: ' + err.error); } } catch (err) { console.error('Erro ao salvar modelo:', err); } }); } // ============================================================ // GERAÇÃO / EMISSÃO DE RELATÓRIO PEDAGÓGICO // ============================================================ const emitirRelatorioModal = document.getElementById('emitirRelatorioModal'); const btnEmitirRelatorio = document.getElementById('btnEmitirRelatorio'); const btnCloseEmitirModal = document.getElementById('btnCloseEmitirModal'); const emitirFilterAno = document.getElementById('emitirFilterAno'); const emitirFilterTurma = document.getElementById('emitirFilterTurma'); const emitirFilterCrianca = document.getElementById('emitirFilterCrianca'); const emitirFilterDataInicio = document.getElementById('emitirFilterDataInicio'); const emitirFilterDataFim = document.getElementById('emitirFilterDataFim'); const emitirTagsContainer = document.getElementById('emitirTagsContainer'); const emitirFilterTemplate = document.getElementById('emitirFilterTemplate'); let emitirSelectedTags = []; const emitirObsCountInfo = document.getElementById('emitirObsCountInfo'); const btnGenerateCompiledReport = document.getElementById('btnGenerateCompiledReport'); const emitirReportPreviewArea = document.getElementById('emitirReportPreviewArea'); const btnExportDocx = document.getElementById('btnExportDocx'); const btnExportPdf = document.getElementById('btnExportPdf'); let activeCompiledReport = null; // Guarda o texto do relatório compilado ativo // Abrir modal de emissão if (btnEmitirRelatorio) { btnEmitirRelatorio.addEventListener('click', async () => { emitirRelatorioModal.style.display = 'flex'; activeCompiledReport = null; emitirReportPreviewArea.innerHTML = `
Escolha uma criança e um modelo de relatório para começar.
`; btnExportDocx.disabled = true; btnExportDocx.style.cursor = 'not-allowed'; btnExportDocx.style.borderColor = 'var(--border-light)'; btnExportDocx.style.color = 'var(--text-secondary)'; btnExportPdf.disabled = true; btnExportPdf.style.cursor = 'not-allowed'; btnExportPdf.style.borderColor = 'var(--border-light)'; btnExportPdf.style.color = 'var(--text-secondary)'; emitirFilterDataInicio.value = ''; emitirFilterDataFim.value = ''; emitirSelectedTags = []; if (emitirTagsContainer) { emitirTagsContainer.querySelectorAll('.tag-pill').forEach(p => p.classList.remove('active')); } await loadFiltersData(); }); } // Fechar modal de emissão if (btnCloseEmitirModal) { btnCloseEmitirModal.addEventListener('click', () => { emitirRelatorioModal.style.display = 'none'; }); } if (emitirRelatorioModal) { emitirRelatorioModal.addEventListener('click', (e) => { if (e.target === emitirRelatorioModal) emitirRelatorioModal.style.display = 'none'; }); } // Buscar crianças com base nos filtros de Ano e Turma selecionados async function loadChildrenList() { try { const activeAno = emitirFilterAno ? emitirFilterAno.value : ''; const activeTurma = emitirFilterTurma ? emitirFilterTurma.value : ''; const activeCrianca = emitirFilterCrianca ? emitirFilterCrianca.value : ''; let url = '/api/observacoes/criancas'; const params = []; if (activeAno) params.push(`ano=${activeAno}`); if (activeTurma) params.push(`turma=${encodeURIComponent(activeTurma)}`); if (params.length > 0) url += '?' + params.join('&'); const res = await fetch(url); const criancas = await res.json(); emitirFilterCrianca.innerHTML = '' + `` + (Array.isArray(criancas) ? criancas : []).map(c => ``).join(''); } catch (err) { console.error('Erro ao carregar lista de crianças:', err); } } // Carregar dados de filtros (anos, turmas, crianças e templates) async function loadFiltersData() { try { const activeAno = emitirFilterAno ? emitirFilterAno.value : ''; const activeTurma = emitirFilterTurma ? emitirFilterTurma.value : ''; const res = await fetch('/api/observacoes/metadata'); const meta = await res.json(); if (emitirFilterAno) { emitirFilterAno.innerHTML = '' + (meta.anos || []).map(y => ``).join(''); } if (emitirFilterTurma) { emitirFilterTurma.innerHTML = '' + (meta.turmas || []).map(t => ``).join(''); } } catch (err) { console.error('Erro ao buscar metadados de filtros:', err); } await loadChildrenList(); try { const activeTemplate = emitirFilterTemplate ? emitirFilterTemplate.value : ''; const res = await fetch('/api/modelos'); const modelos = await res.json(); emitirFilterTemplate.innerHTML = '' + (Array.isArray(modelos) ? modelos : []).map(m => ``).join(''); } catch (err) { console.error('Erro ao buscar modelos para filtros:', err); } updateObservationsCount(); } // Ouvintes para atualizar contagem e filtros de criança if (emitirFilterAno) { emitirFilterAno.addEventListener('change', async () => { await loadChildrenList(); updateObservationsCount(); }); } if (emitirFilterTurma) { emitirFilterTurma.addEventListener('change', async () => { await loadChildrenList(); updateObservationsCount(); }); } [emitirFilterCrianca, emitirFilterDataInicio, emitirFilterDataFim, emitirFilterTemplate].forEach(el => { if (el) el.addEventListener('change', updateObservationsCount); }); if (emitirFilterCrianca) { emitirFilterCrianca.addEventListener('input', updateObservationsCount); } // Adicionar ouvintes para as pills de tags if (emitirTagsContainer) { emitirTagsContainer.querySelectorAll('.tag-pill').forEach(pill => { pill.addEventListener('click', () => { const tag = pill.getAttribute('data-tag'); if (pill.classList.contains('active')) { pill.classList.remove('active'); emitirSelectedTags = emitirSelectedTags.filter(t => t !== tag); } else { pill.classList.add('active'); emitirSelectedTags.push(tag); } updateObservationsCount(); }); }); } // Recalcular quantidade de observações elegíveis async function updateObservationsCount() { const crianca = emitirFilterCrianca ? emitirFilterCrianca.value : ''; const dataInicio = emitirFilterDataInicio ? emitirFilterDataInicio.value : ''; const dataFim = emitirFilterDataFim ? emitirFilterDataFim.value : ''; const tag = emitirSelectedTags.join(','); const modeloId = emitirFilterTemplate ? emitirFilterTemplate.value : ''; const ano = emitirFilterAno ? emitirFilterAno.value : ''; const turma = emitirFilterTurma ? emitirFilterTurma.value : ''; if (!crianca || !modeloId) { emitirObsCountInfo.textContent = 'Escolha uma criança (ou "Todas") e um modelo para prosseguir.'; btnGenerateCompiledReport.disabled = true; btnGenerateCompiledReport.style.opacity = '0.6'; btnGenerateCompiledReport.style.cursor = 'not-allowed'; return; } try { let url = `/api/observacoes/contar?crianca=${encodeURIComponent(crianca)}`; if (dataInicio) url += `&dataInicio=${dataInicio}`; if (dataFim) url += `&dataFim=${dataFim}`; if (tag) url += `&tags=${encodeURIComponent(tag)}`; if (ano) url += `&ano=${ano}`; if (turma) url += `&turma=${encodeURIComponent(turma)}`; const res = await fetch(url); const data = await res.json(); const count = data.count || 0; if (count === 0) { emitirObsCountInfo.innerHTML = `⚠️ Nenhuma observação encontrada para esses filtros.`; btnGenerateCompiledReport.disabled = true; btnGenerateCompiledReport.style.opacity = '0.6'; btnGenerateCompiledReport.style.cursor = 'not-allowed'; } else { emitirObsCountInfo.innerHTML = `✨ ${count} observações selecionadas para compilação.`; btnGenerateCompiledReport.disabled = false; btnGenerateCompiledReport.style.opacity = '1'; btnGenerateCompiledReport.style.cursor = 'pointer'; } } catch (err) { console.error('Erro ao contar observações:', err); } } // Gerar relatório consolidado com IA if (btnGenerateCompiledReport) { btnGenerateCompiledReport.addEventListener('click', async () => { const crianca = emitirFilterCrianca.value; const dataInicio = emitirFilterDataInicio.value; const dataFim = emitirFilterDataFim.value; const tags = emitirSelectedTags; const modeloId = emitirFilterTemplate.value; const ano = emitirFilterAno ? emitirFilterAno.value : ''; const turma = emitirFilterTurma ? emitirFilterTurma.value : ''; if (!crianca || !modeloId) return; emitirReportPreviewArea.innerHTML = `
Analisando observações e gerando relatório pedagógico...
`; btnGenerateCompiledReport.disabled = true; btnGenerateCompiledReport.style.opacity = '0.6'; try { const res = await fetch('/api/modelos/gerar-relatorio', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ crianca, dataInicio, dataFim, tags: tags.length > 0 ? tags : undefined, modeloId, ano, turma }) }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.error || 'Erro desconhecido'); } const data = await res.json(); activeCompiledReport = data; // Renderizar prévia do relatório em markdown emitirReportPreviewArea.innerHTML = `
Estudante: ${data.crianca}
Período: ${data.periodo}
Modelo Aplicado: ${data.templateNome}
Quantidade de Observações Consolidadas: ${data.totalObservacoes}
${marked.parse(data.report)}
`; // Ativar botões de exportação btnExportDocx.disabled = false; btnExportDocx.style.cursor = 'pointer'; btnExportDocx.style.borderColor = 'var(--brand-green)'; btnExportDocx.style.color = 'var(--brand-green)'; btnExportPdf.disabled = false; btnExportPdf.style.cursor = 'pointer'; btnExportPdf.style.borderColor = 'var(--brand-green)'; btnExportPdf.style.color = 'var(--brand-green)'; // Rolar suavemente até o relatório no mobile if (window.innerWidth <= 768) { const modalBody = emitirRelatorioModal.querySelector('.settings-modal-body'); if (modalBody) { setTimeout(() => { modalBody.scrollTo({ top: modalBody.scrollHeight, behavior: 'smooth' }); }, 150); } } } catch (err) { console.error('Erro ao gerar relatório compilado:', err); emitirReportPreviewArea.innerHTML = `
Erro ao gerar relatório: ${err.message}
`; } finally { btnGenerateCompiledReport.disabled = false; btnGenerateCompiledReport.style.opacity = '1'; } }); } // Exportar para DOCX (Formato HTML-DOCX) if (btnExportDocx) { btnExportDocx.addEventListener('click', () => { if (!activeCompiledReport) return; const htmlContent = ` Relatório Pedagógico - ${activeCompiledReport.crianca}
Estudante: ${activeCompiledReport.crianca}
Período: ${activeCompiledReport.periodo}
Modelo: ${activeCompiledReport.templateNome}
Quantidade de observações consolidadas: ${activeCompiledReport.totalObservacoes}

${marked.parse(activeCompiledReport.report)} `; const blob = new Blob(['\ufeff' + htmlContent], { type: 'application/msword' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `relatorio_${activeCompiledReport.crianca.replace(/\s+/g, '_')}_${Date.now()}.doc`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }); } // Exportar para PDF (Abre janela de impressão do navegador) if (btnExportPdf) { btnExportPdf.addEventListener('click', () => { if (!activeCompiledReport) return; const printWindow = window.open('', '_blank'); printWindow.document.write(` Relatório Pedagógico - ${activeCompiledReport.crianca}

Relatório Pedagógico de Desenvolvimento

Estudante: ${activeCompiledReport.crianca}
Período: ${activeCompiledReport.periodo}
Modelo Aplicado: ${activeCompiledReport.templateNome}
Quantidade de Observações: ${activeCompiledReport.totalObservacoes}
${marked.parse(activeCompiledReport.report)}
`); printWindow.document.close(); }); } // ============================================================ // ESTÚDIO MUSICAL (Criação de música cantada com MiniMax) // ============================================================ const estudioMusicalModal = document.getElementById('estudioMusicalModal'); const btnCloseEstudioModal = document.getElementById('btnCloseEstudioModal'); const sidebarBtnEstudio = document.getElementById('sidebarBtnEstudio'); const barBtnEstudio = document.getElementById('barBtnEstudio'); const welcomeCardEstudio = document.getElementById('welcomeCardEstudio'); const btnGenerateStudioMusic = document.getElementById('btnGenerateStudioMusic'); const musicTemaInput = document.getElementById('musicTemaInput'); const musicGenerateLoader = document.getElementById('musicGenerateLoader'); const musicLoaderText = document.getElementById('musicLoaderText'); const musicResultArea = document.getElementById('musicResultArea'); const musicStudioAudioPlayer = document.getElementById('musicStudioAudioPlayer'); const musicStudioDownloadBtn = document.getElementById('musicStudioDownloadBtn'); const musicStudioLyricsArea = document.getElementById('musicStudioLyricsArea'); const btnCopyMusicLyrics = document.getElementById('btnCopyMusicLyrics'); const btnDownloadMusicLyrics = document.getElementById('btnDownloadMusicLyrics'); let selectedVoice = 'mulher'; let selectedRhythm = 'roda'; let selectedDuration = '30s'; // Toggle classes active para botões de opções de voz do Estúdio Musical document.querySelectorAll('#estudioMusicalModal .music-option-grid .btn-music-option').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('#estudioMusicalModal .music-option-grid .btn-music-option').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedVoice = btn.dataset.voice; }); }); document.querySelectorAll('.btn-music-rhythm').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.btn-music-rhythm').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedRhythm = btn.dataset.rhythm; }); }); document.querySelectorAll('.btn-music-duration').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.btn-music-duration').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedDuration = btn.dataset.duration; }); }); // Abrir e fechar modal const openEstudioModal = () => { estudioMusicalModal.style.display = 'flex'; musicTemaInput.value = ''; musicResultArea.style.display = 'none'; musicStudioAudioPlayer.src = ''; musicStudioLyricsArea.textContent = ''; if (musicGenerateLoader) musicGenerateLoader.style.display = 'none'; btnGenerateStudioMusic.disabled = false; }; if (sidebarBtnEstudio) { sidebarBtnEstudio.addEventListener('click', openEstudioModal); } if (barBtnEstudio) { barBtnEstudio.addEventListener('click', openEstudioModal); } if (welcomeCardEstudio) { welcomeCardEstudio.addEventListener('click', openEstudioModal); } if (btnCloseEstudioModal) { btnCloseEstudioModal.addEventListener('click', () => { estudioMusicalModal.style.display = 'none'; musicStudioAudioPlayer.pause(); }); } // Fechar modal clicando fora window.addEventListener('click', (e) => { if (e.target === estudioMusicalModal) { estudioMusicalModal.style.display = 'none'; musicStudioAudioPlayer.pause(); } }); // Ação de gerar música no estúdio if (btnGenerateStudioMusic) { btnGenerateStudioMusic.addEventListener('click', async () => { const tema = musicTemaInput.value.trim(); if (!tema) { alert('Por favor, digite um tema para a música.'); return; } btnGenerateStudioMusic.disabled = true; musicGenerateLoader.style.display = 'flex'; musicResultArea.style.display = 'none'; const statusInterval = setInterval(() => { const statusMessages = [ 'Compondo a letra pedagógica...', 'Ajustando a métrica e as rimas...', 'Gerando o arranjo musical...', 'Adicionando os vocais cantados...', 'Quase pronto, finalizando o áudio...' ]; const randomMsg = statusMessages[Math.floor(Math.random() * statusMessages.length)]; musicLoaderText.textContent = randomMsg; }, 5000); try { const response = await fetch('/api/music/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tema, voz: selectedVoice, ritmo: selectedRhythm, duracao: selectedDuration }) }); clearInterval(statusInterval); if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || 'Erro na geração de áudio'); } const data = await response.json(); // Exibir resultados musicStudioLyricsArea.textContent = data.lyrics; musicStudioAudioPlayer.src = data.audioUrl; musicStudioDownloadBtn.href = data.audioUrl; musicGenerateLoader.style.display = 'none'; musicResultArea.style.display = 'flex'; } catch (err) { clearInterval(statusInterval); console.error('Erro ao gerar música no estúdio:', err); alert('Erro ao gerar música: ' + err.message); musicGenerateLoader.style.display = 'none'; btnGenerateStudioMusic.disabled = false; } }); } // Copiar letra if (btnCopyMusicLyrics) { btnCopyMusicLyrics.addEventListener('click', () => { const lyrics = musicStudioLyricsArea.textContent; if (lyrics) { navigator.clipboard.writeText(lyrics) .then(() => { btnCopyMusicLyrics.textContent = '✅ Copiado!'; setTimeout(() => { btnCopyMusicLyrics.textContent = '📋 Copiar Letra'; }, 2000); }) .catch(err => { console.error('Falha ao copiar:', err); }); } }); } // Baixar letra em arquivo de texto (TXT) if (btnDownloadMusicLyrics) { btnDownloadMusicLyrics.addEventListener('click', () => { const lyrics = musicStudioLyricsArea.textContent; const tema = musicTemaInput.value.trim() || 'cantiga'; if (lyrics) { const blob = new Blob([lyrics], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `letra_${tema.replace(/\s+/g, '_').toLowerCase()}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } }); } // ============================================================ // FÁBRICA DE QUADRINHOS (Criação de histórias visuais pedagógicas) // ============================================================ const fabricaQuadrinhosModal = document.getElementById('fabricaQuadrinhosModal'); const btnCloseComicsModal = document.getElementById('btnCloseComicsModal'); const barBtnComics = document.getElementById('barBtnComics'); const btnGenerateComics = document.getElementById('btnGenerateComics'); const comicsTemaInput = document.getElementById('comicsTemaInput'); const comicsGenerateLoader = document.getElementById('comicsGenerateLoader'); const comicsLoaderText = document.getElementById('comicsLoaderText'); const comicsLoaderProgress = document.getElementById('comicsLoaderProgress'); const comicsResultArea = document.getElementById('comicsResultArea'); const comicsResultTitle = document.getElementById('comicsResultTitle'); const comicsGridContainer = document.getElementById('comicsGridContainer'); const comicsCenarioSelect = document.getElementById('comicsCenarioSelect'); const comicsCenarioCustomInput = document.getElementById('comicsCenarioCustomInput'); // Export PDF / Vídeo const btnExportComicsPDFPortrait = document.getElementById('btnExportComicsPDFPortrait'); const btnExportComicsPDFLandscape = document.getElementById('btnExportComicsPDFLandscape'); const btnCompileComicsVideo = document.getElementById('btnCompileComicsVideo'); const comicsVideoMusic = document.getElementById('comicsVideoMusic'); const comicsVideoDuration = document.getElementById('comicsVideoDuration'); const comicsVideoLoader = document.getElementById('comicsVideoLoader'); const comicsVideoResult = document.getElementById('comicsVideoResult'); const comicsVideoPlayer = document.getElementById('comicsVideoPlayer'); const comicsVideoDownloadBtn = document.getElementById('comicsVideoDownloadBtn'); const comicsProjectSelect = document.getElementById('comicsProjectSelect'); const btnNewComicsProject = document.getElementById('btnNewComicsProject'); const btnDeleteComicsProject = document.getElementById('btnDeleteComicsProject'); const comicsProjectTitleInput = document.getElementById('comicsProjectTitleInput'); const btnSaveComicsProject = document.getElementById('btnSaveComicsProject'); const btnSaveNewComicsProject = document.getElementById('btnSaveNewComicsProject'); let currentComicsProjectId = null; let currentComicsCharDescEnglish = ''; let selectedComicsQty = 5; let selectedComicsBubbles = true; let selectedComicsRatio = '16:9'; let generatedPanelsData = []; // Toggle ativo quantidade de quadros document.querySelectorAll('.btn-comics-qty').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.btn-comics-qty').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedComicsQty = parseInt(btn.dataset.qty); }); }); // Toggle ativo falas/diálogos document.querySelectorAll('.btn-comics-bubbles').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.btn-comics-bubbles').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedComicsBubbles = btn.dataset.bubbles === 'true'; }); }); // Toggle ativo proporção visual document.querySelectorAll('.btn-comics-ratio').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.btn-comics-ratio').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedComicsRatio = btn.dataset.ratio; }); }); // Gerenciador dinâmico de personagens const comicsCharListContainer = document.getElementById('comicsCharListContainer'); const btnAddHumanChar = document.getElementById('btnAddHumanChar'); const btnAddAnimalChar = document.getElementById('btnAddAnimalChar'); const addCharacterRow = (type = '', name = '', isAnimal = false) => { const row = document.createElement('div'); row.className = 'comic-char-row'; row.style.cssText = 'display: flex; gap: 8px; align-items: center; background: var(--bg-primary); padding: 8px; border-radius: 8px; border: 1px solid var(--border-light);'; const emoji = document.createElement('span'); emoji.style.fontSize = '1.1rem'; emoji.textContent = isAnimal ? '🐾' : '🧒'; row.appendChild(emoji); const typeInput = document.createElement('input'); typeInput.type = 'text'; typeInput.className = 'obs-input char-type-input'; typeInput.placeholder = isAnimal ? 'Tipo (ex: Gatinho, Leão)' : 'Tipo (ex: Menino, Menina)'; typeInput.value = type; typeInput.style.cssText = 'flex: 1; padding: 6px 10px; font-size: 0.85rem; margin: 0;'; row.appendChild(typeInput); const nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.className = 'obs-input char-name-input'; nameInput.placeholder = 'Nome'; nameInput.value = name; nameInput.style.cssText = 'flex: 1; padding: 6px 10px; font-size: 0.85rem; margin: 0;'; row.appendChild(nameInput); const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'btn-delete-char'; deleteBtn.innerHTML = '×'; deleteBtn.style.cssText = 'background: none; border: none; color: #ef4444; font-size: 1.3rem; cursor: pointer; padding: 0 4px; line-height: 1;'; deleteBtn.addEventListener('click', () => { row.remove(); }); row.appendChild(deleteBtn); comicsCharListContainer.appendChild(row); }; if (btnAddHumanChar) { btnAddHumanChar.addEventListener('click', () => { addCharacterRow('', '', false); }); } if (btnAddAnimalChar) { btnAddAnimalChar.addEventListener('click', () => { addCharacterRow('', '', true); }); } // Exibir/ocultar cenário personalizado if (comicsCenarioSelect) { comicsCenarioSelect.addEventListener('change', () => { if (comicsCenarioSelect.value === 'custom') { comicsCenarioCustomInput.style.display = 'block'; } else { comicsCenarioCustomInput.style.display = 'none'; } }); } // --- GERENCIADOR DE PROJETOS DE HQ --- const startNewComicsProject = () => { currentComicsProjectId = null; currentComicsCharDescEnglish = ''; const modeContainer = document.getElementById('comicsGenerationModeContainer'); if (modeContainer) modeContainer.style.display = 'none'; const radioNew = document.querySelector('input[name="comicsGenMode"][value="new_story"]'); if (radioNew) radioNew.checked = true; if (comicsProjectSelect) comicsProjectSelect.value = ''; if (comicsProjectTitleInput) comicsProjectTitleInput.value = ''; if (btnDeleteComicsProject) btnDeleteComicsProject.style.display = 'none'; if (btnSaveComicsProject) btnSaveComicsProject.style.display = 'none'; comicsTemaInput.value = ''; comicsResultArea.style.display = 'none'; comicsGenerateLoader.style.display = 'none'; comicsVideoResult.style.display = 'none'; comicsVideoLoader.style.display = 'none'; btnGenerateComics.disabled = false; if (comicsLoaderProgress) comicsLoaderProgress.style.width = '0%'; // Set active state for default quick options selectedComicsQty = 5; document.querySelectorAll('.btn-comics-qty').forEach(b => { if (b.dataset.qty === '5') b.classList.add('active'); else b.classList.remove('active'); }); selectedComicsBubbles = true; document.querySelectorAll('.btn-comics-bubbles').forEach(b => { if (b.dataset.bubbles === 'true') b.classList.add('active'); else b.classList.remove('active'); }); selectedComicsRatio = '16:9'; document.querySelectorAll('.btn-comics-ratio').forEach(b => { if (b.dataset.ratio === '16:9') b.classList.add('active'); else b.classList.remove('active'); }); if (comicsCenarioSelect) { comicsCenarioSelect.value = 'no parque de diversões colorido'; if (comicsCenarioCustomInput) { comicsCenarioCustomInput.style.display = 'none'; comicsCenarioCustomInput.value = ''; } } // Inicializar lista de personagens com padrão se estiver vazia comicsCharListContainer.innerHTML = ''; addCharacterRow("Menino", "Lucas", false); addCharacterRow("Menina", "Mariana", false); generatedPanelsData = []; comicsGridContainer.innerHTML = ''; }; const loadComicsProjectsList = async () => { if (!comicsProjectSelect) return; try { const resp = await fetch('/api/comics/projects'); if (!resp.ok) throw new Error('Falha ao listar projetos'); const projects = await resp.json(); comicsProjectSelect.innerHTML = ''; projects.forEach(proj => { const opt = document.createElement('option'); opt.value = proj.id; opt.textContent = `${proj.titulo} (${new Date(proj.updated_at).toLocaleDateString('pt-BR')})`; comicsProjectSelect.appendChild(opt); }); if (currentComicsProjectId) { comicsProjectSelect.value = currentComicsProjectId; } } catch (err) { console.error('Erro ao listar projetos:', err); } }; const loadComicsProject = async (id) => { if (!id) { startNewComicsProject(); return; } try { const resp = await fetch(`/api/comics/projects/${id}`); if (!resp.ok) throw new Error('Falha ao carregar projeto'); const { project, panels } = await resp.json(); currentComicsProjectId = project.id; currentComicsCharDescEnglish = project.character_description_english || ''; const modeContainer = document.getElementById('comicsGenerationModeContainer'); if (modeContainer) modeContainer.style.display = 'flex'; if (comicsProjectTitleInput) comicsProjectTitleInput.value = project.titulo || ''; comicsTemaInput.value = project.tema || ''; if (btnDeleteComicsProject) btnDeleteComicsProject.style.display = 'block'; if (btnSaveComicsProject) btnSaveComicsProject.style.display = 'block'; // Carregar proporção selectedComicsRatio = project.proporcao || '16:9'; document.querySelectorAll('.btn-comics-ratio').forEach(b => { if (b.dataset.ratio === selectedComicsRatio) b.classList.add('active'); else b.classList.remove('active'); }); // Carregar cenário if (comicsCenarioSelect) { const standardOptions = ['no parque de diversões colorido', 'na sala de aula da escola infantil', 'em uma floresta encantada cheia de flores', 'no zoológico interagindo com animais', 'num jardim ensolarado de uma casa']; if (standardOptions.includes(project.cenario)) { comicsCenarioSelect.value = project.cenario; if (comicsCenarioCustomInput) comicsCenarioCustomInput.style.display = 'none'; } else { comicsCenarioSelect.value = 'custom'; if (comicsCenarioCustomInput) { comicsCenarioCustomInput.style.display = 'block'; comicsCenarioCustomInput.value = project.cenario || ''; } } } // Carregar quantidade e balões baseados nos painéis retornados selectedComicsQty = panels.length || 5; document.querySelectorAll('.btn-comics-qty').forEach(b => { if (parseInt(b.dataset.qty) === selectedComicsQty) b.classList.add('active'); else b.classList.remove('active'); }); const hasDialogue = panels.some(p => p.dialogue); selectedComicsBubbles = hasDialogue; document.querySelectorAll('.btn-comics-bubbles').forEach(b => { if ((b.dataset.bubbles === 'true') === selectedComicsBubbles) b.classList.add('active'); else b.classList.remove('active'); }); // Carregar personagens comicsCharListContainer.innerHTML = ''; if (project.character_description_global) { try { const chars = JSON.parse(project.character_description_global); if (Array.isArray(chars)) { chars.forEach(c => { addCharacterRow(c.type || '', c.name || '', c.isAnimal || false); }); } } catch (e) { addCharacterRow("Menino", "Lucas", false); addCharacterRow("Menina", "Mariana", false); } } else { addCharacterRow("Menino", "Lucas", false); addCharacterRow("Menina", "Mariana", false); } // Carregar os painéis generatedPanelsData = panels.map(p => ({ panel_number: p.panel_number, imageUrl: p.image_url, dialogue: p.dialogue || '', image_prompt: p.image_prompt || '' })); if (generatedPanelsData.length > 0) { comicsResultTitle.textContent = project.titulo || 'História em Quadrinhos'; comicsGridContainer.innerHTML = ''; generatedPanelsData.forEach((panel, index) => { const panelCard = document.createElement('div'); panelCard.className = 'comic-panel-card'; const imgContainer = document.createElement('div'); imgContainer.className = `comic-panel-image-container ratio-${selectedComicsRatio.replace(':', '-')}`; const img = document.createElement('img'); img.className = 'comic-panel-image'; img.src = panel.imageUrl; img.alt = `Quadro ${panel.panel_number}`; imgContainer.appendChild(img); const badge = document.createElement('div'); badge.className = 'comic-panel-number-badge'; badge.textContent = panel.panel_number; imgContainer.appendChild(badge); panelCard.appendChild(imgContainer); const caption = document.createElement('div'); caption.className = 'comic-caption-text'; caption.style.cssText = 'padding: 10px 14px; background: var(--bg-primary); border-top: 1px solid var(--border-light);'; const textarea = document.createElement('textarea'); textarea.className = 'comic-caption-input'; textarea.value = panel.dialogue || ''; textarea.placeholder = 'Digite a fala ou legenda do quadrinho...'; textarea.style.cssText = 'width: 100%; border: 1px solid var(--border-light); background: var(--bg-secondary); color: var(--text-primary); border-radius: 6px; padding: 8px; font-family: inherit; font-size: 0.85rem; resize: vertical; box-sizing: border-box; min-height: 50px;'; textarea.addEventListener('input', (e) => { panel.dialogue = e.target.value; if (generatedPanelsData[index]) { generatedPanelsData[index].dialogue = e.target.value; } }); caption.appendChild(textarea); panelCard.appendChild(caption); comicsGridContainer.appendChild(panelCard); }); comicsResultArea.style.display = 'flex'; } else { comicsResultArea.style.display = 'none'; } comicsVideoResult.style.display = 'none'; comicsVideoLoader.style.display = 'none'; btnGenerateComics.disabled = false; } catch (err) { console.error(err); await showCustomAlert('Erro', 'Erro ao carregar projeto: ' + err.message); } }; const saveComicsProjectFlow = async (forceNew = false) => { const titulo = comicsProjectTitleInput ? comicsProjectTitleInput.value.trim() : ''; if (!titulo) { await showCustomAlert('Campo Obrigatório', 'Por favor, digite um título para salvar seu projeto.'); return; } const characters = []; document.querySelectorAll('.comic-char-row').forEach(row => { const typeInput = row.querySelector('.char-type-input'); const nameInput = row.querySelector('.char-name-input'); if (typeInput && nameInput) { const type = typeInput.value.trim(); const name = nameInput.value.trim(); const isAnimal = row.querySelector('span').textContent === '🐾'; if (type || name) { characters.push({ type, name, isAnimal }); } } }); let cenario = comicsCenarioSelect ? comicsCenarioSelect.value : 'no parque de diversões colorido'; if (cenario === 'custom' && comicsCenarioCustomInput) { cenario = comicsCenarioCustomInput.value.trim(); } const targetId = forceNew ? null : currentComicsProjectId; const body = { id: targetId, titulo, tema: comicsTemaInput.value.trim(), cenario, proporcao: selectedComicsRatio, character_description_global: JSON.stringify(characters), character_description_english: currentComicsCharDescEnglish || '', panels: generatedPanelsData.map(p => ({ panel_number: p.panel_number, image_url: p.imageUrl || p.image_url || '', image_prompt: p.image_prompt || '', dialogue: p.dialogue || '' })) }; try { const resp = await fetch('/api/comics/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.error || 'Erro ao salvar projeto'); } const data = await resp.json(); currentComicsProjectId = data.id; await showCustomAlert('Sucesso', 'Projeto salvo com sucesso!'); if (btnDeleteComicsProject) btnDeleteComicsProject.style.display = 'block'; if (btnSaveComicsProject) btnSaveComicsProject.style.display = 'block'; await loadComicsProjectsList(); } catch (err) { console.error(err); await showCustomAlert('Erro', 'Erro ao salvar projeto: ' + err.message); } }; const saveComicsProject = () => saveComicsProjectFlow(false); const saveNewComicsProject = () => saveComicsProjectFlow(true); const deleteComicsProject = async () => { if (!currentComicsProjectId) return; const confirmed = await showCustomConfirm( 'Confirmar Exclusão', 'Tem certeza de que deseja excluir este projeto permanentemente?', true ); if (!confirmed) return; try { const resp = await fetch(`/api/comics/projects/${currentComicsProjectId}`, { method: 'DELETE' }); if (!resp.ok) throw new Error('Falha ao excluir projeto'); await showCustomAlert('Excluído', 'Projeto excluído com sucesso!'); startNewComicsProject(); await loadComicsProjectsList(); } catch (err) { console.error(err); await showCustomAlert('Erro', 'Erro ao excluir projeto: ' + err.message); } }; // Abrir e fechar modal Fábrica de Quadrinhos const openComicsModal = () => { fabricaQuadrinhosModal.style.display = 'flex'; startNewComicsProject(); loadComicsProjectsList(); }; // Listeners do Gerenciador de Projetos if (comicsProjectSelect) { comicsProjectSelect.addEventListener('change', (e) => { loadComicsProject(e.target.value); }); } if (btnNewComicsProject) { btnNewComicsProject.addEventListener('click', async () => { const confirmed = await showCustomConfirm( 'Novo Projeto', 'Deseja iniciar um novo projeto? Alterações não salvas serão perdidas.' ); if (confirmed) { startNewComicsProject(); } }); } if (btnSaveComicsProject) { btnSaveComicsProject.addEventListener('click', saveComicsProject); } if (btnSaveNewComicsProject) { btnSaveNewComicsProject.addEventListener('click', saveNewComicsProject); } if (btnDeleteComicsProject) { btnDeleteComicsProject.addEventListener('click', deleteComicsProject); } if (barBtnComics) { barBtnComics.addEventListener('click', openComicsModal); } if (btnCloseComicsModal) { btnCloseComicsModal.addEventListener('click', () => { fabricaQuadrinhosModal.style.display = 'none'; if (comicsVideoPlayer) comicsVideoPlayer.pause(); }); } // Fechar modal clicando fora window.addEventListener('click', (e) => { if (e.target === fabricaQuadrinhosModal) { fabricaQuadrinhosModal.style.display = 'none'; if (comicsVideoPlayer) comicsVideoPlayer.pause(); } }); // Helper para converter URL de imagem local em base64 const getBase64Image = async (imgUrl) => { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'Anonymous'; img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); resolve(canvas.toDataURL('image/jpeg')); }; img.onerror = (e) => reject(new Error('Falha ao carregar imagem para o PDF: ' + imgUrl)); img.src = imgUrl; }); }; // Ação de geração da história e imagens if (btnGenerateComics) { btnGenerateComics.addEventListener('click', async () => { const tema = comicsTemaInput.value.trim(); if (!tema) { await showCustomAlert('Campo Requerido', 'Por favor, digite um tema para a história.'); return; } // Reunir personagens dinamicamente const personagens = []; document.querySelectorAll('.comic-char-row').forEach(row => { const type = row.querySelector('.char-type-input').value.trim(); const name = row.querySelector('.char-name-input').value.trim(); if (type && name) { personagens.push(`${type} chamado(a) ${name}`); } }); if (personagens.length === 0) { await showCustomAlert('Personagens Faltando', 'Adicione e preencha pelo menos um personagem principal.'); return; } // Definir cenário let cenario = comicsCenarioSelect.value; if (cenario === 'custom') { cenario = comicsCenarioCustomInput.value.trim() || 'em um local bonito'; } btnGenerateComics.disabled = true; comicsGenerateLoader.style.display = 'flex'; comicsResultArea.style.display = 'none'; comicsVideoResult.style.display = 'none'; comicsLoaderProgress.style.width = '5%'; comicsLoaderText.textContent = 'Escrevendo o roteiro e diálogos dos quadrinhos...'; try { // 1. Gerar Roteiro const genMode = currentComicsProjectId ? (document.querySelector('input[name="comicsGenMode"]:checked')?.value || 'new_story') : 'new_story'; const requestBody = { tema, quantidade: selectedComicsQty, baloes: selectedComicsBubbles, proporcao: selectedComicsRatio, personagens, cenario }; if (currentComicsProjectId) { requestBody.continuationType = genMode; requestBody.character_description = currentComicsCharDescEnglish; requestBody.existingPanels = generatedPanelsData.map(p => ({ panel_number: p.panel_number, image_url: p.imageUrl || p.image_url || '', image_prompt: p.image_prompt || '', dialogue: p.dialogue || '' })); } const scriptResp = await fetch('/api/comics/generate-script', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); if (!scriptResp.ok) { const err = await scriptResp.json(); throw new Error(err.error || 'Erro ao planejar roteiro'); } const scriptData = await scriptResp.json(); comicsLoaderProgress.style.width = '15%'; if (scriptData.character_description) { currentComicsCharDescEnglish = scriptData.character_description; } // 2. Gerar cada quadrinho sequencialmente if (genMode !== 'extend') { generatedPanelsData = []; } const totalPanels = scriptData.panels.length; for (let i = 0; i < totalPanels; i++) { const panel = scriptData.panels[i]; const progressPercent = 15 + Math.floor((i / totalPanels) * 80); comicsLoaderProgress.style.width = `${progressPercent}%`; comicsLoaderText.textContent = `Ilustrando quadrinho ${i + 1} de ${totalPanels}...`; // Injetamos a proporção e estilo de forma reforçada no prompt const fullPrompt = `${panel.image_prompt}, high resolution children book illustration, cute Pixar style, ${selectedComicsRatio === '16:9' ? '16:9 aspect ratio' : '4:3 aspect ratio'}`; const frameResp = await fetch('/api/comics/generate-frame', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: fullPrompt }) }); if (!frameResp.ok) { const err = await frameResp.json(); throw new Error(`Erro no quadrinho ${i + 1}: ${err.error}`); } const frameData = await frameResp.json(); generatedPanelsData.push({ panel_number: panel.panel_number, imageUrl: frameData.imageUrl, dialogue: panel.dialogue || '', image_prompt: panel.image_prompt || '' }); } // Renderizar quadrinhos na tela (todos, incluindo os anteriores caso tenha sido extensão) comicsResultTitle.textContent = scriptData.title || comicsResultTitle.textContent || 'Fábrica de Quadrinhos Camila'; comicsGridContainer.innerHTML = ''; generatedPanelsData.forEach((panel, index) => { const panelCard = document.createElement('div'); panelCard.className = 'comic-panel-card'; const imgContainer = document.createElement('div'); imgContainer.className = `comic-panel-image-container ratio-${selectedComicsRatio.replace(':', '-')}`; const img = document.createElement('img'); img.className = 'comic-panel-image'; img.src = panel.imageUrl || panel.image_url; img.alt = `Quadro ${panel.panel_number}`; imgContainer.appendChild(img); // Selo do número const badge = document.createElement('div'); badge.className = 'comic-panel-number-badge'; badge.textContent = panel.panel_number; imgContainer.appendChild(badge); panelCard.appendChild(imgContainer); const caption = document.createElement('div'); caption.className = 'comic-caption-text'; caption.style.cssText = 'padding: 10px 14px; background: var(--bg-primary); border-top: 1px solid var(--border-light);'; const textarea = document.createElement('textarea'); textarea.className = 'comic-caption-input'; textarea.value = panel.dialogue || ''; textarea.placeholder = 'Digite a fala ou legenda do quadrinho...'; textarea.style.cssText = 'width: 100%; border: 1px solid var(--border-light); background: var(--bg-secondary); color: var(--text-primary); border-radius: 6px; padding: 8px; font-family: inherit; font-size: 0.85rem; resize: vertical; box-sizing: border-box; min-height: 50px;'; textarea.addEventListener('input', (e) => { panel.dialogue = e.target.value; if (generatedPanelsData[index]) { generatedPanelsData[index].dialogue = e.target.value; } }); caption.appendChild(textarea); panelCard.appendChild(caption); comicsGridContainer.appendChild(panelCard); }); // Se for uma nova história derivada de um projeto antigo, limpamos o ID para virar um novo projeto independente ao salvar if (genMode === 'new_story' && currentComicsProjectId) { currentComicsProjectId = null; if (comicsProjectSelect) comicsProjectSelect.value = ''; const modeContainer = document.getElementById('comicsGenerationModeContainer'); if (modeContainer) modeContainer.style.display = 'none'; if (comicsProjectTitleInput) { comicsProjectTitleInput.value = scriptData.title || (comicsProjectTitleInput.value + ' (Derivado)'); } if (btnDeleteComicsProject) btnDeleteComicsProject.style.display = 'none'; } comicsGenerateLoader.style.display = 'none'; comicsResultArea.style.display = 'flex'; btnGenerateComics.disabled = false; } catch (err) { console.error('Erro ao fabricar quadrinhos:', err); await showCustomAlert('Erro', 'Erro ao fabricar quadrinhos: ' + err.message); comicsGenerateLoader.style.display = 'none'; btnGenerateComics.disabled = false; } }); } // Exportar PDF Retrato (1 por página) if (btnExportComicsPDFPortrait) { btnExportComicsPDFPortrait.addEventListener('click', async () => { if (generatedPanelsData.length === 0) return; btnExportComicsPDFPortrait.disabled = true; btnExportComicsPDFPortrait.textContent = '⏳ Montando PDF...'; try { const { jsPDF } = window.jspdf; const doc = new jsPDF('p', 'pt', 'a4'); const title = comicsResultTitle.textContent || 'História em Quadrinhos'; for (let i = 0; i < generatedPanelsData.length; i++) { const panel = generatedPanelsData[i]; if (i > 0) doc.addPage(); // Título centralizado doc.setFont('helvetica', 'bold'); doc.setFontSize(18); doc.setTextColor(30, 41, 59); doc.text(title, 297, 50, { align: 'center' }); // Imagem const base64Img = await getBase64Image(panel.imageUrl); const imgWidth = 515; const imgHeight = selectedComicsRatio === '16:9' ? 290 : 386; doc.addImage(base64Img, 'JPEG', 40, 80, imgWidth, imgHeight); // Altura esticada para a caixa de diálogo (160pt de altura - aprox dobro) const textHeight = 160; const textY = 80 + imgHeight; // Caixa de diálogo com fundo cinza claro e borda integrada doc.setFillColor(248, 250, 252); doc.rect(40, textY, imgWidth, textHeight, 'F'); // Moldura unificada ao redor de toda a imagem + texto doc.setDrawColor(30, 41, 59); doc.setLineWidth(2); doc.rect(40, 80, imgWidth, imgHeight + textHeight, 'D'); // Linha divisória entre a imagem e a caixa de texto doc.line(40, textY, 555, textY); // Texto maior e mais legível (dobro do tamanho relativo - 15pt) if (panel.dialogue) { doc.setFont('helvetica', 'normal'); doc.setFontSize(15); doc.setTextColor(15, 23, 42); const splitText = doc.splitTextToSize(panel.dialogue, imgWidth - 40); doc.text(splitText, 60, textY + 35); } // Círculo discreto com o número do quadrinho no canto superior esquerdo da imagem doc.setFillColor(254, 240, 138); doc.circle(65, 105, 12, 'F'); doc.setDrawColor(30, 41, 59); doc.setLineWidth(1.5); doc.circle(65, 105, 12, 'D'); doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor(30, 41, 59); doc.text(String(panel.panel_number), 65, 109, { align: 'center' }); // Rodapé doc.setFont('helvetica', 'italic'); doc.setFontSize(9); doc.setTextColor(148, 163, 184); doc.text(`Criado com Camila AI - Quadro ${panel.panel_number} de ${generatedPanelsData.length}`, 297, 815, { align: 'center' }); } doc.save(`quadrinhos_${title.replace(/\s+/g, '_').toLowerCase()}.pdf`); } catch (error) { console.error('Erro ao gerar PDF Retrato:', error); alert('Erro ao gerar PDF: ' + error.message); } finally { btnExportComicsPDFPortrait.disabled = false; btnExportComicsPDFPortrait.textContent = '📄 PDF (1 por página - Retrato)'; } }); } // Exportar PDF Paisagem (2 por página) if (btnExportComicsPDFLandscape) { btnExportComicsPDFLandscape.addEventListener('click', async () => { if (generatedPanelsData.length === 0) return; btnExportComicsPDFLandscape.disabled = true; btnExportComicsPDFLandscape.textContent = '⏳ Montando PDF...'; try { const { jsPDF } = window.jspdf; const doc = new jsPDF('l', 'pt', 'a4'); const title = comicsResultTitle.textContent || 'História em Quadrinhos'; const totalPanels = generatedPanelsData.length; const totalPages = Math.ceil(totalPanels / 2); for (let page = 0; page < totalPages; page++) { if (page > 0) doc.addPage(); // Título centralizado no topo da página paisagem doc.setFont('helvetica', 'bold'); doc.setFontSize(18); doc.setTextColor(30, 41, 59); doc.text(`${title} - Página ${page + 1} de ${totalPages}`, 421, 45, { align: 'center' }); // Renderizar até dois quadrinhos lado a lado for (let col = 0; col < 2; col++) { const index = page * 2 + col; if (index >= totalPanels) break; const panel = generatedPanelsData[index]; const startX = col === 0 ? 40 : 426; const cardWidth = 376; // Proporções exatas mantidas const imgHeight = selectedComicsRatio === '16:9' ? 211 : 282; const textHeight = 140; // Dobro de tamanho (antes era 65) // Imagem do quadrinho const base64Img = await getBase64Image(panel.imageUrl); doc.addImage(base64Img, 'JPEG', startX, 70, cardWidth, imgHeight); // Fundo da caixa de texto const textY = 70 + imgHeight; doc.setFillColor(248, 250, 252); doc.rect(startX, textY, cardWidth, textHeight, 'F'); // Moldura unificada ao redor do quadro (imagem + texto) doc.setDrawColor(30, 41, 59); doc.setLineWidth(2); doc.rect(startX, 70, cardWidth, imgHeight + textHeight, 'D'); // Linha separando a imagem da caixa de texto doc.line(startX, textY, startX + cardWidth, textY); // Legenda formatada em tamanho maior (13pt) if (panel.dialogue) { doc.setFont('helvetica', 'normal'); doc.setFontSize(13); doc.setTextColor(15, 23, 42); const splitText = doc.splitTextToSize(panel.dialogue, cardWidth - 30); doc.text(splitText, startX + 15, textY + 30); } // Círculo discreto com o numeral do quadro no canto superior esquerdo da imagem doc.setFillColor(254, 240, 138); doc.circle(startX + 20, 90, 11, 'F'); doc.setDrawColor(30, 41, 59); doc.setLineWidth(1.5); doc.circle(startX + 20, 90, 11, 'D'); doc.setFont('helvetica', 'bold'); doc.setFontSize(9); doc.setTextColor(30, 41, 59); doc.text(String(panel.panel_number), startX + 20, 93, { align: 'center' }); } } doc.save(`quadrinhos_${title.replace(/\s+/g, '_').toLowerCase()}_paisagem.pdf`); } catch (error) { console.error('Erro ao gerar PDF Paisagem:', error); alert('Erro ao gerar PDF: ' + error.message); } finally { btnExportComicsPDFLandscape.disabled = false; btnExportComicsPDFLandscape.textContent = '📄 PDF (2 por página - Paisagem)'; } }); } // Compilar Vídeo de Apresentação (FFmpeg) if (btnCompileComicsVideo) { btnCompileComicsVideo.addEventListener('click', async () => { if (generatedPanelsData.length === 0) return; btnCompileComicsVideo.disabled = true; comicsVideoLoader.style.display = 'flex'; comicsVideoResult.style.display = 'none'; try { const response = await fetch('/api/comics/generate-video', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ frames: generatedPanelsData, musicSelection: comicsVideoMusic.value, frameDuration: comicsVideoDuration.value }) }); if (!response.ok) { const err = await response.json(); throw new Error(err.error || 'Erro na compilação do vídeo'); } const data = await response.json(); comicsVideoPlayer.src = data.videoUrl; comicsVideoDownloadBtn.href = data.videoUrl; comicsVideoResult.style.display = 'flex'; comicsVideoLoader.style.display = 'none'; btnCompileComicsVideo.disabled = false; } catch (err) { console.error('Erro ao gerar vídeo dos quadrinhos:', err); alert('Erro ao compilar o vídeo: ' + err.message); comicsVideoLoader.style.display = 'none'; btnCompileComicsVideo.disabled = false; } }); } // ============================================================ // VIDEOMIND (TRANSCRIÇÃO E ANÁLISE DE VÍDEO PEDAGÓGICO) // ============================================================ const videoMindModal = document.getElementById('videoMindModal'); const btnCloseVideoMindModal = document.getElementById('btnCloseVideoMindModal'); const barBtnVideoMind = document.getElementById('barBtnVideoMind'); const btnGenerateVideoMind = document.getElementById('btnGenerateVideoMind'); const videoMindUrlInput = document.getElementById('videoMindUrlInput'); const videoMindLoader = document.getElementById('videoMindLoader'); const videoMindLoaderText = document.getElementById('videoMindLoaderText'); const videoMindResultArea = document.getElementById('videoMindResultArea'); const videoMindThumbnail = document.getElementById('videoMindThumbnail'); const videoMindTitle = document.getElementById('videoMindTitle'); const videoMindAuthor = document.getElementById('videoMindAuthor'); const videoMindReportArea = document.getElementById('videoMindReportArea'); const btnCopyVideoMindReport = document.getElementById('btnCopyVideoMindReport'); const btnDownloadVideoMindReport = document.getElementById('btnDownloadVideoMindReport'); const btnPdfVideoMindReport = document.getElementById('btnPdfVideoMindReport'); let selectedVideoMindLines = 20; let activeVideoMindReport = null; // Listener para selecionar o tamanho do resumo document.querySelectorAll('#videoMindModal .music-option-grid .btn-videomind-lines').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('#videoMindModal .music-option-grid .btn-videomind-lines').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedVideoMindLines = parseInt(btn.dataset.lines) || 20; }); }); // Abrir modal do VideoMind if (barBtnVideoMind) { barBtnVideoMind.addEventListener('click', () => { videoMindModal.style.display = 'flex'; videoMindUrlInput.value = ''; videoMindResultArea.style.display = 'none'; videoMindReportArea.innerHTML = ''; if (videoMindLoader) videoMindLoader.style.display = 'none'; btnGenerateVideoMind.disabled = false; activeVideoMindReport = null; }); } // Fechar modal if (btnCloseVideoMindModal) { btnCloseVideoMindModal.addEventListener('click', () => { videoMindModal.style.display = 'none'; }); } window.addEventListener('click', (e) => { if (e.target === videoMindModal) { videoMindModal.style.display = 'none'; } }); // Ação de analisar vídeo if (btnGenerateVideoMind) { btnGenerateVideoMind.addEventListener('click', async () => { const url = videoMindUrlInput.value.trim(); if (!url) { alert('Por favor, insira o link de um vídeo do YouTube.'); return; } btnGenerateVideoMind.disabled = true; videoMindLoader.style.display = 'flex'; videoMindResultArea.style.display = 'none'; videoMindLoaderText.textContent = 'Buscando metadados do vídeo...'; let statusMsgIdx = 0; const statusMessages = [ 'Buscando legenda automática do vídeo...', 'Carregando a transcrição completa...', 'Enviando transcrição para a IA...', 'Pedagoga IA analisando o conteúdo...', 'Alinhando temas do vídeo com a BNCC...', 'Gerando resumo de ' + selectedVideoMindLines + ' linhas...', 'Criando propostas de atividades pedagógicas...', 'Finalizando o parecer pedagógico...' ]; const statusInterval = setInterval(() => { if (statusMsgIdx < statusMessages.length) { videoMindLoaderText.textContent = statusMessages[statusMsgIdx]; statusMsgIdx++; } else { videoMindLoaderText.textContent = 'Ajustando formatação da análise...'; } }, 4000); try { const response = await fetch('/api/videomind/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, lines: selectedVideoMindLines }) }); clearInterval(statusInterval); if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || 'Erro desconhecido'); } const data = await response.json(); activeVideoMindReport = data; // Preencher informações do vídeo videoMindTitle.textContent = data.metadata.title; videoMindAuthor.textContent = 'Canal: ' + data.metadata.author; videoMindThumbnail.src = data.metadata.thumbnail; // Renderizar Markdown if (window.marked) { videoMindReportArea.innerHTML = marked.parse(data.report); } else { videoMindReportArea.textContent = data.report; } videoMindLoader.style.display = 'none'; videoMindResultArea.style.display = 'flex'; } catch (err) { clearInterval(statusInterval); console.error('Erro no VideoMind:', err); alert('Erro ao analisar vídeo: ' + err.message); videoMindLoader.style.display = 'none'; btnGenerateVideoMind.disabled = false; } }); } // Copiar Parecer if (btnCopyVideoMindReport) { btnCopyVideoMindReport.addEventListener('click', () => { if (activeVideoMindReport && activeVideoMindReport.report) { navigator.clipboard.writeText(activeVideoMindReport.report) .then(() => { btnCopyVideoMindReport.textContent = '✅ Copiado!'; setTimeout(() => { btnCopyVideoMindReport.textContent = '📋 Copiar Parecer'; }, 2000); }) .catch(err => { console.error('Falha ao copiar:', err); }); } }); } // Baixar relatório TXT if (btnDownloadVideoMindReport) { btnDownloadVideoMindReport.addEventListener('click', () => { if (activeVideoMindReport && activeVideoMindReport.report) { const title = activeVideoMindReport.metadata.title || 'analise_video'; const blob = new Blob([activeVideoMindReport.report], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `VideoMind_${title.replace(/\s+/g, '_').toLowerCase()}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } }); } // Baixar relatório PDF if (btnPdfVideoMindReport) { btnPdfVideoMindReport.addEventListener('click', () => { if (!activeVideoMindReport) return; const printWindow = window.open('', '_blank'); printWindow.document.write(` VideoMind - Parecer Pedagógico

🎬 VideoMind - Análise & Parecer Pedagógico

Vídeo: ${activeVideoMindReport.metadata.title}
Canal: ${activeVideoMindReport.metadata.author}
Resumo Escolhido: ${selectedVideoMindLines} Linhas
${window.marked ? marked.parse(activeVideoMindReport.report) : activeVideoMindReport.report}
`); printWindow.document.close(); }); } }; // 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(); }