Files
camila/public/app.js
T

4059 lines
158 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Configuração do Markdown parser personalizado com Highlight.js integrado
const renderer = new marked.Renderer();
// Função auxiliar para escapar caracteres HTML
function escapeHtml(text) {
return text
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Função auxiliar para gerar nome de arquivo de download formatado como <nome_data_hora>.<extensao>
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 `
<div class="code-container">
<div class="code-header">
<span class="code-lang">${lang}</span>
<button class="btn-copy-code" onclick="copyCode(this)">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H5.25m11.9-3.664A2.251 2.251 0 0 0 15 2.25h-3a2.251 2.251 0 0 0-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5M16.5 9h3.75c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125H16.5m-3-12h3m-3 3h3M13.5 14.25h3" />
</svg>
<span>Copiar</span>
</button>
</div>
<pre><code class="hljs language-${lang}">${escapeHtml(codeContent)}</code></pre>
</div>
`;
};
marked.use({ renderer });
// Função global de copiar código para que funcione no onclick
window.copyCode = function(button) {
const container = button.closest('.code-container');
const codeElement = container.querySelector('code');
const textToCopy = codeElement.textContent;
navigator.clipboard.writeText(textToCopy).then(() => {
const textSpan = button.querySelector('span');
const originalText = textSpan.textContent;
textSpan.textContent = 'Copiado!';
button.style.color = 'var(--success)';
setTimeout(() => {
textSpan.textContent = originalText;
button.style.color = '';
}, 2000);
}).catch(err => {
console.error('Erro ao copiar código:', err);
});
};
const initApp = () => {
// Elementos do DOM
const sidebar = document.getElementById('sidebar');
const btnMenu = document.getElementById('btnMenu');
const btnCloseSidebar = document.getElementById('btnCloseSidebar');
const sidebarOverlay = document.getElementById('sidebarOverlay');
const userInput = document.getElementById('userInput');
const btnSend = document.getElementById('btnSend');
const btnVoice = document.getElementById('btnVoice');
const chatForm = document.getElementById('chatForm');
const messagesContainer = document.getElementById('messagesContainer');
const welcomeContainer = document.getElementById('welcomeContainer');
const conversationList = document.getElementById('conversationList');
const btnNewChat = document.getElementById('btnNewChat');
const btnNewChatMobile = document.getElementById('btnNewChatMobile');
const historyItems = document.getElementById('historyItems');
const btnLogout = document.getElementById('btnLogout');
const suggestionCards = document.querySelectorAll('.suggestion-card');
const searchInput = document.getElementById('searchInput');
const searchClear = document.getElementById('searchClear');
const searchResultsHeader = document.getElementById('searchResultsHeader');
const searchResultsCount = document.getElementById('searchResultsCount');
const searchClearAll = document.getElementById('searchClearAll');
const sidebarTags = document.getElementById('sidebarTags');
// 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]) => `
<label class="temperature-option ${preset === key ? 'selected' : ''}" data-preset="${key}">
<input type="radio" name="temperature" value="${key}" ${preset === key ? 'checked' : ''}>
<div class="temperature-option-content">
<strong>${t.label}</strong>
<span>${t.description}</span>
</div>
</label>
`).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 = '<p style="color: var(--text-muted); text-align: center; padding: 20px;">Nenhum conhecimento aprendido ainda. A assistente aprende automaticamente quando você ensinar algo novo!</p>';
return;
}
knowledgeList.innerHTML = window.conhecimento.facts.map(f => `
<div class="knowledge-item" data-id="${f.id}">
<div class="knowledge-item-content" id="knowledge-content-${f.id}">
<span class="knowledge-source">${f.source}</span>
<p style="margin: 4px 0 0 0;">${f.content}</p>
<small style="display: block; margin-top: 4px;">${new Date(f.addedAt).toLocaleDateString('pt-BR')}</small>
</div>
<div class="knowledge-actions" id="knowledge-actions-${f.id}" style="display: flex; gap: 4px; align-self: flex-start; margin-left: 8px;">
<button class="knowledge-edit-btn" onclick="startEditKnowledge(${f.id})" title="Editar" style="background: none; border: none; color: var(--text-secondary); cursor: pointer; padding: 2px 4px; font-size: 0.9rem; transition: color 0.2s;">✏️</button>
<button class="knowledge-delete-btn" onclick="deleteKnowledge(${f.id})" title="Excluir">×</button>
</div>
</div>
`).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 = `
<span class="knowledge-source">${fact.source}</span>
<textarea id="edit-textarea-${id}" style="width: 100%; min-height: 70px; padding: 8px; border-radius: 6px; border: 1px solid var(--brand-green); background: var(--bg-primary); color: var(--text-primary); font-family: var(--font-sans); font-size: 0.9rem; resize: vertical; margin: 6px 0; outline: none; box-shadow: 0 0 4px var(--brand-glow);">${fact.content}</textarea>
<div style="display: flex; gap: 8px; margin-top: 4px;">
<button onclick="saveEditKnowledge(${id})" style="background: var(--brand-green); border: none; color: white; padding: 4px 10px; border-radius: 6px; font-size: 0.75rem; cursor: pointer; font-weight: 500; transition: opacity 0.2s;">Salvar</button>
<button onclick="renderKnowledgeList()" style="background: var(--bg-tertiary); border: none; color: var(--text-primary); padding: 4px 10px; border-radius: 6px; font-size: 0.75rem; cursor: pointer; font-weight: 500; transition: opacity 0.2s;">Cancelar</button>
</div>
`;
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 => `
<div class="modal-tag-item ${selectedTagsState.includes(tag.id) ? 'selected' : ''}" data-tag="${tag.id}">
<input type="checkbox" ${selectedTagsState.includes(tag.id) ? 'checked' : ''}>
<span>${tag.label}</span>
</div>
`).join('');
modalTagList.querySelectorAll('.modal-tag-item').forEach(item => {
item.addEventListener('click', () => {
const tag = item.dataset.tag;
if (selectedTagsState.includes(tag)) {
selectedTagsState = selectedTagsState.filter(t => t !== tag);
} else {
selectedTagsState.push(tag);
}
renderTagModalList();
});
});
};
const renderSidebarTags = () => {
if (!sidebarTags) return;
let tagsHtml = `<button class="tag-filter ${activeTag === 'all' ? 'active' : ''}" data-tag="all">Todas</button>`;
tagsHtml += AVAILABLE_TAGS.map(tag => `
<button class="tag-filter ${activeTag === tag.id ? 'active' : ''}" data-tag="${tag.id}">
${tag.label}
</button>
`).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 = `<img src="${url}" alt="${file.name}">`;
} else {
previewHtml = `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>`;
}
item.innerHTML = `
${previewHtml}
<span class="file-name">${file.name}</span>
<button type="button" class="remove-attachment" data-index="${index}">×</button>
`;
attachPreview.appendChild(item);
});
// Botões de remover
attachPreview.querySelectorAll('.remove-attachment').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index);
attachedFiles.splice(idx, 1);
renderAttachPreview();
});
});
};
// ==========================================================================
// ATALHOS DE TECLADO
// ==========================================================================
document.addEventListener('keydown', (e) => {
// Ctrl+N → novo chat
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
e.preventDefault();
createNewChat();
}
// Ctrl+F ou Ctrl+K → focar busca
if ((e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'k')) {
e.preventDefault();
toggleSidebar(true);
setTimeout(() => searchInput.focus(), 50);
}
// Escape → fechar sidebar
if (e.key === 'Escape') {
toggleSidebar(false);
}
});
// ==========================================================================
// HISTÓRICO DE CHATS (LOCALSTORAGE) — VERSÃO COMPLETA COM PINNED, TAGS, BUSCA
// ==========================================================================
const saveChats = () => {
localStorage.setItem('camila_chats', JSON.stringify(chats));
if (currentChatId) {
localStorage.setItem('camila_current_chat_id', currentChatId);
} else {
localStorage.removeItem('camila_current_chat_id');
}
};
// Agrupamento por data
const getDateGroup = (timestamp) => {
const now = new Date();
const date = new Date(timestamp);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (dateOnly.getTime() === today.getTime()) return 'Hoje';
if (dateOnly.getTime() === yesterday.getTime()) return 'Ontem';
if (dateOnly >= weekAgo) return 'Esta Semana';
if (dateOnly >= monthStart) return 'Este Mês';
return 'Mais Antigas';
};
// Filtrar chats por tag
const filterByTag = (chatList) => {
if (activeTag === 'all') return chatList;
return chatList.filter(chat => chat.tags && chat.tags.includes(activeTag));
};
// Filtrar chats por busca
const filterBySearch = (chatList) => {
if (!searchQuery.trim()) return chatList;
const q = searchQuery.toLowerCase();
return chatList.filter(chat => {
if (chat.title.toLowerCase().includes(q)) return true;
return chat.messages.some(msg => msg.content.toLowerCase().includes(q));
});
};
// Renderizar um item de chat
const createHistoryItem = (chat) => {
const item = document.createElement('div');
item.className = `history-item ${chat.id === currentChatId ? 'active' : ''} ${chat.pinned ? 'pinned' : ''}`;
item.dataset.id = chat.id;
const pinIcon = chat.pinned
? `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="pin-icon"><line x1="12" y1="17" x2="12" y2="22" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line><path d="M5 17h14v-1.76a2 2 0 0 0-.44-1.24l-2.78-3.47A2 2 0 0 1 15 9.29V5a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4.29a2 2 0 0 1-.78 1.24L5.44 14a2 2 0 0 0-.44 1.24z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>`
: '';
const tagBadges = (chat.tags || []).map(tag =>
`<span class="history-tag-badge">${getTagEmoji(tag)}</span>`
).join('');
item.innerHTML = `
<div class="history-item-content">
${pinIcon}
<div class="history-item-title">${escapeHtml(chat.title)}</div>
${tagBadges ? `<div class="history-item-tags">${tagBadges}</div>` : ''}
</div>
<div class="history-item-actions">
<button class="btn-history-action btn-pin-chat" title="${chat.pinned ? 'Desafixar' : 'Fixar'}">
<svg xmlns="http://www.w3.org/2000/svg" fill="${chat.pinned ? 'currentColor' : 'none'}" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<line x1="12" y1="17" x2="12" y2="22"></line>
<path d="M5 17h14v-1.76a2 2 0 0 0-.44-1.24l-2.78-3.47A2 2 0 0 1 15 9.29V5a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4.29a2 2 0 0 1-.78 1.24L5.44 14a2 2 0 0 0-.44 1.24z"></path>
</svg>
</button>
<button class="btn-history-action btn-tag-chat" title="Tags">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581a2.25 2.25 0 0 0 3.181 0l4.318-4.318a2.25 2.25 0 0 0 0-3.181l-9.58-9.581A2.25 2.25 0 0 0 9.568 3Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
</svg>
</button>
<button class="btn-history-action btn-rename-chat" title="Renomear">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
</button>
<button class="btn-history-action btn-delete-chat" title="Excluir">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</button>
</div>
`;
return item;
};
// Emoji para cada tag
const getTagEmoji = (tag) => {
const emojis = {
relatorios: '📋',
planejamento: '📝',
mindlab: '🧠',
estudos: '📚',
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 = '<div style="color: var(--text-muted); font-size: 12px; padding: 10px; text-align: center;">Nenhum chat salvo</div>';
return;
}
// Aplicar filtros
let filtered = [...chats];
filtered = filterByTag(filtered);
filtered = filterBySearch(filtered);
// Se tem busca, mostrar resultados direto (sem grupo)
if (searchQuery.trim()) {
searchResultsHeader.style.display = 'block';
searchResultsCount.textContent = `${filtered.length} resultado${filtered.length !== 1 ? 's' : ''}`;
filtered.sort((a, b) => b.updatedAt - a.updatedAt);
filtered.forEach(chat => {
const item = createHistoryItem(chat);
item.addEventListener('click', (e) => {
if (e.target.closest('.btn-delete-chat')) { e.stopPropagation(); deleteChat(chat.id); return; }
if (e.target.closest('.btn-pin-chat')) { e.stopPropagation(); togglePin(chat.id); return; }
if (e.target.closest('.btn-rename-chat')) { e.stopPropagation(); renameChatInline(chat.id, item); return; }
if (e.target.closest('.btn-tag-chat')) { e.stopPropagation(); editTags(chat.id); return; }
selectChat(chat.id);
toggleSidebar(false);
});
historyItems.appendChild(item);
});
return;
}
searchResultsHeader.style.display = 'none';
// Separar pinned e não-pinned
const pinned = filtered.filter(c => c.pinned).sort((a, b) => b.updatedAt - a.updatedAt);
const unpinned = filtered.filter(c => !c.pinned).sort((a, b) => b.updatedAt - a.updatedAt);
// Renderizar grupo se tiver items
const renderGroup = (label, items) => {
if (items.length === 0) return;
const group = document.createElement('div');
group.className = 'history-group';
group.innerHTML = `<h3>${label}</h3>`;
const container = document.createElement('div');
container.className = 'history-items';
items.forEach(chat => {
const item = createHistoryItem(chat);
item.addEventListener('click', (e) => {
if (e.target.closest('.btn-delete-chat')) { e.stopPropagation(); deleteChat(chat.id); return; }
if (e.target.closest('.btn-pin-chat')) { e.stopPropagation(); togglePin(chat.id); return; }
if (e.target.closest('.btn-rename-chat')) { e.stopPropagation(); renameChatInline(chat.id, item); return; }
if (e.target.closest('.btn-tag-chat')) { e.stopPropagation(); editTags(chat.id); return; }
selectChat(chat.id);
toggleSidebar(false);
});
container.appendChild(item);
});
group.appendChild(container);
historyItems.appendChild(group);
};
// Pinned no topo
if (pinned.length > 0) {
renderGroup('📌 Fixadas', pinned);
}
// Agrupar por data os não-pinned
const groups = {};
unpinned.forEach(chat => {
const group = getDateGroup(chat.updatedAt);
if (!groups[group]) groups[group] = [];
groups[group].push(chat);
});
['Hoje', 'Ontem', 'Esta Semana', 'Este Mês', 'Mais Antigas'].forEach(g => {
if (groups[g]) renderGroup(g, groups[g]);
});
};
// Fixar/desfixar chat
const togglePin = (id) => {
const chat = chats.find(c => c.id === id);
if (!chat) return;
chat.pinned = !chat.pinned;
saveChats();
renderHistory();
};
// Renomear chat inline
const renameChatInline = (id, itemElement) => {
const chat = chats.find(c => c.id === id);
if (!chat) return;
const titleDiv = itemElement.querySelector('.history-item-title');
if (!titleDiv) return;
const originalTitle = chat.title;
const input = document.createElement('input');
input.type = 'text';
input.className = 'rename-input';
input.value = originalTitle;
// Prevenir clique e propagação para o item
input.addEventListener('click', (e) => e.stopPropagation());
const finishRename = () => {
const newTitle = input.value.trim();
if (newTitle && newTitle !== originalTitle) {
chat.title = newTitle;
chat.updatedAt = Date.now();
saveChats();
}
renderHistory();
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
finishRename();
} else if (e.key === 'Escape') {
e.preventDefault();
renderHistory();
}
});
input.addEventListener('blur', finishRename);
titleDiv.replaceWith(input);
input.focus();
input.select();
};
// Gerenciar tags da conversa
const editTags = (id) => {
const chat = chats.find(c => c.id === id);
if (!chat) return;
const currentTags = chat.tags || [];
openTagModal(id, currentTags, (newTags) => {
chat.tags = newTags;
chat.updatedAt = Date.now();
saveChats();
renderHistory();
});
};
const selectChat = (id) => {
currentChatId = id;
const chat = chats.find(c => c.id === id);
if (!chat) return;
saveChats();
renderHistory();
// Ocultar welcome e renderizar as mensagens
welcomeContainer.style.display = 'none';
conversationList.innerHTML = '';
chat.messages.forEach(msg => {
appendMessageUI(msg.role, msg.content, 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
? `<div class="avatar user-avatar user-avatar-img-wrapper"><img src="${window.agentConfig?.camilaAvatarUrl || 'assets/camila_prof.png'}" alt="Camila" class="user-avatar-img"></div>`
: `<div class="avatar bot-avatar bot-avatar-img-wrapper"><img src="${window.agentConfig?.kemilyAvatarUrl || 'assets/kemily.png'}" alt="${window.agentConfig?.agentName || 'Kemily'}" class="bot-avatar-img"></div>`;
let contentHtml = '';
if (isUser) {
contentHtml = `<p>${escapeHtml(content).replace(/\n/g, '<br>')}</p>`;
} else {
if (media) {
if (media.type === 'image') {
contentHtml = `
<div class="media-message-card">
<div class="media-img-container">
<img src="${media.url}" alt="Ilustração gerada" class="generated-image" onload="scrollToBottom()">
</div>
<p class="media-caption">✨ "${escapeHtml(media.originalPrompt)}"</p>
<div class="media-actions-row">
<a href="${media.url}" download="${getDownloadFileName('imagem', 'png')}" target="_blank" class="media-download-btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
<span>Salvar na Galeria</span>
</a>
</div>
</div>
`;
} else if (media.type === 'audio') {
const audioId = 'audio_' + Date.now() + '_' + Math.floor(Math.random() * 10000);
contentHtml = `
<div class="media-message-card">
<div class="custom-audio-player">
<audio src="${media.url}" id="${audioId}"></audio>
<button class="audio-play-btn" onclick="toggleCustomAudio(this)" title="Ouvir música">
<svg class="play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
<svg class="pause-icon hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</button>
<div class="audio-timeline-container">
<div class="audio-timeline">
<div class="audio-progress"></div>
</div>
</div>
<span class="audio-time">0:00</span>
</div>
<p class="media-caption">🎵 "${escapeHtml(media.originalPrompt)}"</p>
<div class="media-actions-row">
<a href="${media.url}" download="${getDownloadFileName('musica', 'mp3')}" target="_blank" class="media-download-btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
<span>Baixar Música</span>
</a>
</div>
</div>
`;
} else if (media.type === 'video') {
contentHtml = `
<div class="media-message-card">
<div class="media-video-container">
<video controls src="${media.url}" class="generated-video" preload="metadata"></video>
</div>
<p class="media-caption">🎥 "${escapeHtml(media.originalPrompt)}"</p>
<div class="media-actions-row">
<a href="${media.url}" download="${getDownloadFileName('video', 'mp4')}" target="_blank" class="media-download-btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
<span>Salvar Vídeo</span>
</a>
</div>
</div>
`;
}
} else {
if (content.trim().startsWith('<div class="media-message-card">')) {
contentHtml = content;
} else {
contentHtml = marked.parse(content);
}
}
}
const actionsHtml = !isUser ? `
<div class="message-actions">
<button class="msg-action-btn btn-copy-msg" title="Copiar resposta" data-content="${media ? escapeHtml(content) : escapeHtml(content).replace(/"/g, '&quot;')}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
</svg>
<span>Copiar</span>
</button>
<button class="msg-action-btn btn-regenerate" title="Regenerar resposta">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span>Regenerar</span>
</button>
<button class="msg-action-btn btn-speak" title="Ouvir resposta" data-content="${media ? escapeHtml(content) : escapeHtml(content).replace(/"/g, '&quot;')}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>
<span>Ouvir</span>
</button>
</div>
` : `
<div class="message-actions">
<button class="msg-action-btn btn-edit" title="Editar e reenviar" data-content="${escapeHtml(content).replace(/"/g, '&quot;')}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
<span>Editar</span>
</button>
</div>
`;
row.innerHTML = `
<div class="message-icon">${avatarHtml}</div>
<div class="message-content-wrapper">
<div class="message-content">${contentHtml}</div>
${actionsHtml}
</div>
`;
conversationList.appendChild(row);
// Colorir blocos de código com Highlight.js
row.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
// Event listener para copiar mensagem
const copyBtn = row.querySelector('.btn-copy-msg');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
const text = copyBtn.dataset.content;
navigator.clipboard.writeText(text).then(() => {
const span = copyBtn.querySelector('span');
const original = span.textContent;
span.textContent = 'Copiado!';
setTimeout(() => span.textContent = original, 2000);
});
});
}
// Event listener para editar/reenviar mensagem
const editBtn = row.querySelector('.btn-edit');
if (editBtn) {
editBtn.addEventListener('click', () => {
const text = editBtn.dataset.content;
const chat = chats.find(c => c.id === currentChatId);
if (!chat) return;
// Encontrar índice desta mensagem no chat
const rows = conversationList.querySelectorAll('.message-row.user');
let msgRow = null;
for (const r of rows) {
const btn = r.querySelector('.btn-edit');
if (btn && btn.dataset.content === text) {
msgRow = r;
break;
}
}
// Remover esta mensagem e todas as subsequentes (do user e do bot)
if (msgRow) {
const allRows = Array.from(conversationList.querySelectorAll('.message-row'));
const idx = allRows.indexOf(msgRow);
for (let i = allRows.length - 1; i >= idx; i--) {
allRows[i].remove();
}
// Remover do estado também
const msgIndex = chat.messages.findIndex(m => m.role === 'user' && m.content === text);
if (msgIndex >= 0) {
chat.messages = chat.messages.slice(0, msgIndex);
}
saveChats();
}
// Colocar texto no input e focar
userInput.value = text;
userInput.focus();
userInput.dispatchEvent(new Event('input'));
btnSend.disabled = false;
});
}
// Event listener para ouvir resposta (TTS nativo)
const speakBtn = row.querySelector('.btn-speak');
if (speakBtn) {
speakBtn.addEventListener('click', () => {
const text = speakBtn.dataset.content;
speakText(text, speakBtn);
});
}
// Event listener para regenerar resposta
const regenBtn = row.querySelector('.btn-regenerate');
if (regenBtn) {
regenBtn.addEventListener('click', () => {
if (isGenerating) return;
regenerateLastResponse();
});
}
return row;
};
// Regenerar última resposta do bot
const regenerateLastResponse = () => {
const chat = chats.find(c => c.id === currentChatId);
if (!chat || chat.messages.length === 0) return;
// Encontrar última mensagem do bot
let lastBotIdx = -1;
for (let i = chat.messages.length - 1; i >= 0; i--) {
if (chat.messages[i].role === 'assistant') {
lastBotIdx = i;
break;
}
}
if (lastBotIdx === -1) return;
// Remover última mensagem do bot e todas as mensagens depois da última mensagem do usuário
let lastUserIdx = -1;
for (let i = lastBotIdx - 1; i >= 0; i--) {
if (chat.messages[i].role === 'user') {
lastUserIdx = i;
break;
}
}
// Cortar mensagens até a última do usuário
if (lastUserIdx >= 0) {
chat.messages = chat.messages.slice(0, lastUserIdx + 1);
} else {
chat.messages = [];
}
// Remover da UI as mensagens depois do último user
const rows = conversationList.querySelectorAll('.message-row');
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
const isBot = row.classList.contains('assistant');
const isUser = row.classList.contains('user');
if (isBot) {
row.remove();
} else if (isUser) {
break;
}
}
saveChats();
scrollToBottom();
// Reenviar última mensagem do usuário para gerar nova resposta
const lastUserMsg = chat.messages[chat.messages.length - 1];
if (lastUserMsg && lastUserMsg.role === 'user') {
handleSendMessage(lastUserMsg.content, true);
}
};
const scrollToBottom = () => {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
};
// Função principal de envio de mensagens
const handleSendMessage = async (text, skipUserAppend = false, files = null, 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 = `
<div class="message-icon">
<div class="avatar bot-avatar bot-avatar-img-wrapper"><img src="${window.agentConfig?.kemilyAvatarUrl || 'assets/kemily.png'}" alt="${window.agentConfig?.agentName || 'Kemily'}" class="bot-avatar-img"></div>
</div>
<div class="message-content-wrapper">
<div class="message-content">
<span class="typing-cursor"></span>
</div>
</div>
`;
conversationList.appendChild(botRow);
scrollToBottom();
const botContentDiv = botRow.querySelector('.message-content');
let assistantContent = '';
try {
// Filtrar mensagens para enviar apenas o histórico necessário ao OpenRouter, 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) + '<span class="typing-cursor"></span>';
// 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 = `
<div class="media-loading">
<div class="media-spinner"></div>
<p class="media-loading-text">${escapeHtml(parsed.message)}</p>
</div>
`;
scrollToBottom();
} else if (parsed.type === 'image') {
isMediaResponse = true;
mediaMsgData = { type: 'image', url: parsed.url, originalPrompt: parsed.originalPrompt };
assistantContent = `
<div class="media-message-card">
<div class="media-img-container">
<img src="${parsed.url}" alt="Ilustração gerada" class="generated-image" onload="scrollToBottom()">
</div>
<p class="media-caption">✨ "${escapeHtml(parsed.originalPrompt)}"</p>
<div class="media-actions-row">
<a href="${parsed.url}" download="${getDownloadFileName('imagem', 'png')}" target="_blank" class="media-download-btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
<span>Salvar na Galeria</span>
</a>
</div>
</div>
`;
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 = `
<div class="media-message-card">
<div class="custom-audio-player">
<audio src="${parsed.url}" id="${audioId}"></audio>
<button class="audio-play-btn" onclick="toggleCustomAudio(this)" title="Ouvir música">
<svg class="play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
<svg class="pause-icon hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</button>
<div class="audio-timeline-container">
<div class="audio-timeline">
<div class="audio-progress"></div>
</div>
</div>
<span class="audio-time">0:00</span>
</div>
<p class="media-caption">🎵 "${escapeHtml(parsed.originalPrompt)}"</p>
<div class="media-actions-row">
<a href="${parsed.url}" download="${getDownloadFileName('musica', 'mp3')}" target="_blank" class="media-download-btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
<span>Baixar Música</span>
</a>
</div>
</div>
`;
botContentDiv.innerHTML = assistantContent;
scrollToBottom();
} else if (parsed.type === 'video') {
isMediaResponse = true;
mediaMsgData = { type: 'video', url: parsed.url, originalPrompt: parsed.originalPrompt };
assistantContent = `
<div class="media-message-card">
<div class="media-video-container">
<video controls src="${parsed.url}" class="generated-video" preload="metadata"></video>
</div>
<p class="media-caption">🎥 "${escapeHtml(parsed.originalPrompt)}"</p>
<div class="media-actions-row">
<a href="${parsed.url}" download="${getDownloadFileName('video', 'mp4')}" target="_blank" class="media-download-btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
<span>Salvar Vídeo</span>
</a>
</div>
</div>
`;
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, '&quot;');
const actionsDiv = document.createElement('div');
actionsDiv.className = 'message-actions';
actionsDiv.innerHTML = `
<button class="msg-action-btn btn-copy-msg" title="Copiar resposta" data-content="${escapedContent}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
</svg>
<span>Copiar</span>
</button>
<button class="msg-action-btn btn-regenerate" title="Regenerar resposta">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span>Regenerar</span>
</button>
<button class="msg-action-btn btn-speak" title="Ouvir resposta" data-content="${escapedContent}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>
<span>Ouvir</span>
</button>
`;
const wrapper = botRow.querySelector('.message-content-wrapper');
wrapper.appendChild(actionsDiv);
// Vincular listeners dos botões
const copyBtn = actionsDiv.querySelector('.btn-copy-msg');
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(assistantContent).then(() => {
const span = copyBtn.querySelector('span');
const original = span.textContent;
span.textContent = 'Copiado!';
setTimeout(() => span.textContent = original, 2000);
});
});
const regenBtn = actionsDiv.querySelector('.btn-regenerate');
regenBtn.addEventListener('click', () => {
if (!isGenerating) regenerateLastResponse();
});
const speakBtn = actionsDiv.querySelector('.btn-speak');
speakBtn.addEventListener('click', () => {
speakText(assistantContent, speakBtn);
});
scrollToBottom();
// 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 += `
<p style="color: var(--error); margin-top: 8px; font-size: 13.5px; display: flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="width: 16px; height: 16px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
</svg>
Desculpe, ocorreu um erro ao obter resposta da Camila AI. Por favor, tente novamente.
</p>
`;
scrollToBottom();
} finally {
isGenerating = false;
btnSend.disabled = userInput.value.trim() === '';
userInput.disabled = false;
userInput.focus();
}
};
// Envio pelo Formulário
chatForm.addEventListener('submit', (e) => {
e.preventDefault();
const text = userInput.value.trim();
handleSendMessage(text);
});
// 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
const mode = btn.dataset.mode;
const text = userInput.value.trim();
// Se não houver texto digitado, insere um exemplo de prompt baseado na modalidade
if (!text) {
let suggestion = "";
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;
userInput.focus();
// Ajustar altura do textarea para acomodar a sugestão
userInput.style.height = 'auto';
userInput.style.height = userInput.scrollHeight + 'px';
btnSend.disabled = false;
return;
}
// Se houver texto, envia imediatamente forçando o modo correspondente (POETRY é gerado como texto)
handleSendMessage(text, false, null, mode === 'POETRY' ? 'TEXT' : mode);
});
});
// 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
? `<div class="report-meta-item"><span class="meta-label">👧 Crianças:</span> ${data.criancas.join(', ')}</div>`
: `<div class="report-meta-item"><span class="meta-label">👧 Crianças:</span> <em>não identificadas</em></div>`;
const tagsHtml = data.tags && data.tags.length
? `<div class="report-meta-item"><span class="meta-label">🏷️ Tags:</span> ${data.tags.map(t => `<span class="tag-chip">${t}</span>`).join(' ')}</div>`
: '';
const turmaHtml = data.turma && data.turma !== 'não informada'
? `<div class="report-meta-item"><span class="meta-label">🏫 Turma:</span> ${data.turma}</div>`
: '';
reportContent.innerHTML = `
<div class="report-meta">${criancasHtml}${turmaHtml}${tagsHtml}</div>
<hr style="border-color: var(--border-light); margin: 12px 0;">
${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 = '<div class="obs-empty">Carregando...</div>';
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 = '<div class="obs-empty">Erro ao carregar observações.</div>';
}
});
}
// 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 = '<option value="">Todos os meses</option>' +
months.map(m => `<option value="${m}">${m.split('-')[1]}/${m.split('-')[0]}</option>`).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 = '<div class="obs-empty">Nenhuma observação encontrada.</div>';
return;
}
obsList.innerHTML = filtered.map(o => {
const tagsHtml = (o.tags || []).map(t => `<span class="obs-tag">${t}</span>`).join('');
const criancasHtml = (o.criancas || []).map(c => `<span class="obs-crianca">${c}</span>`).join('');
const dateFormatted = o.date ? o.date.split('-').reverse().join('/') : '';
return `
<div class="obs-item" data-filename="${o.filename}" data-yearmonth="${o.date?.slice(0, 7)}">
<div class="obs-item-header">
<strong>📋 ${dateFormatted}${o.time ? ' às ' + o.time : ''}</strong>
${o.turma && o.turma !== 'não informada' ? `<span class="obs-crianca">🏫 ${o.turma}</span>` : ''}
</div>
<div class="obs-item-preview">${o.preview || 'Sem prévia disponível.'}</div>
<div class="obs-item-footer">${tagsHtml}${criancasHtml}</div>
</div>
`;
}).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 = `
<div class="settings-modal-content obs-modal-content" style="max-width: 680px;">
<div class="settings-modal-header">
<h3>📋 Observação</h3>
<button class="btn-close-modal" onclick="this.closest('.settings-modal').remove()">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<div style="padding: 20px; overflow-y: auto; max-height: 70vh;">
<div class="obs-detail-content" style="color: var(--text-primary); line-height: 1.6;">
${marked.parse(mdText.replace(/^---[\s\S]*?---\n/, ''))}
</div>
<div style="margin-top: 16px; display: flex; gap: 8px;">
<button class="btn-save-report" onclick="navigator.clipboard.writeText(this.closest('.settings-modal').querySelector('.obs-detail-content').innerText).then(()=>alert('Copiado!'))">Copiar Texto</button>
<button class="btn-discard" onclick="this.closest('.settings-modal').remove()">Fechar</button>
</div>
</div>
</div>
`;
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 = '<div style="color: var(--text-secondary); text-align: center; padding: 20px;">Carregando...</div>';
try {
const res = await fetch('/api/modelos');
allTemplates = await res.json();
renderTemplatesList();
} catch (err) {
console.error('Erro ao buscar modelos:', err);
templatesList.innerHTML = '<div style="color: #ff6b6b; text-align: center; padding: 20px;">Erro ao carregar modelos.</div>';
}
}
// Renderizar a lista de modelos na esquerda
function renderTemplatesList() {
if (allTemplates.length === 0) {
templatesList.innerHTML = '<div style="color: var(--text-secondary); text-align: center; padding: 20px;">Nenhum modelo cadastrado.</div>';
return;
}
templatesList.innerHTML = allTemplates.map(t => `
<div class="obs-item" style="padding: 10px; margin: 0 0 8px 0; cursor: pointer; border-left: 3px solid var(--brand-green); display: flex; flex-direction: column; gap: 4px; background: var(--bg-secondary); border-radius: 8px;" data-id="${t.id}">
<div style="font-weight: 600; color: var(--text-primary); font-size: 0.9rem;">${t.nome}</div>
<div style="font-size: 0.75rem; color: var(--text-secondary);">${t.periodicidade}${t.finalidade || 'Sem finalidade'}</div>
<div style="display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px;">
<button class="template-duplicate-btn" data-id="${t.id}" style="background: none; border: 1px solid var(--border-light); color: var(--text-secondary); padding: 2px 8px; border-radius: 4px; font-size: 0.72rem; cursor: pointer; transition: opacity 0.2s;">Duplicar</button>
<button class="template-edit-btn" data-id="${t.id}" style="background: none; border: 1px solid var(--border-light); color: var(--brand-green); padding: 2px 8px; border-radius: 4px; font-size: 0.72rem; cursor: pointer;">Editar</button>
<button class="template-delete-btn" data-id="${t.id}" style="background: none; border: 1px solid var(--border-light); color: #ff6b6b; padding: 2px 8px; border-radius: 4px; font-size: 0.72rem; cursor: pointer;">Excluir</button>
</div>
</div>
`).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 = `
<div style="color: var(--text-secondary); text-align: center; margin-top: 50px;">
Escolha uma criança e um modelo de relatório para começar.
</div>
`;
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 = '<option value="">Selecione a criança</option>' +
`<option value="todas" ${activeCrianca === 'todas' ? 'selected' : ''}>Todas as crianças (Relatório Global)</option>` +
(Array.isArray(criancas) ? criancas : []).map(c => `<option value="${c}" ${c === activeCrianca ? 'selected' : ''}>${c}</option>`).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 = '<option value="">Todos os anos</option>' +
(meta.anos || []).map(y => `<option value="${y}" ${y === activeAno ? 'selected' : ''}>${y}</option>`).join('');
}
if (emitirFilterTurma) {
emitirFilterTurma.innerHTML = '<option value="">Todas as turmas</option>' +
(meta.turmas || []).map(t => `<option value="${t}" ${t === activeTurma ? 'selected' : ''}>${t}</option>`).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 = '<option value="">Selecione o modelo</option>' +
(Array.isArray(modelos) ? modelos : []).map(m => `<option value="${m.id}" ${m.id == activeTemplate ? 'selected' : ''}>${m.nome}</option>`).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 = `⚠️ <span style="color: #ff6b6b; font-weight: 600;">Nenhuma observação</span> encontrada para esses filtros.`;
btnGenerateCompiledReport.disabled = true;
btnGenerateCompiledReport.style.opacity = '0.6';
btnGenerateCompiledReport.style.cursor = 'not-allowed';
} else {
emitirObsCountInfo.innerHTML = `✨ <strong>${count}</strong> 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 = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; margin-top: 50px;">
<div style="width: 32px; height: 32px; border: 3px solid var(--border-light); border-top-color: var(--brand-green); border-radius: 50%; animation: spin 1s linear infinite;"></div>
<div style="color: var(--text-secondary); font-size: 0.9rem; font-weight: 500;">Analisando observações e gerando relatório pedagógico...</div>
</div>
`;
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 = `
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px; border: 1px solid var(--border-light); font-size: 0.85rem;">
<div><strong>Estudante:</strong> ${data.crianca}</div>
<div><strong>Período:</strong> ${data.periodo}</div>
<div><strong>Modelo Aplicado:</strong> ${data.templateNome}</div>
<div><strong>Quantidade de Observações Consolidadas:</strong> ${data.totalObservacoes}</div>
</div>
<div class="report-content-body" style="color: var(--text-primary);">
${marked.parse(data.report)}
</div>
`;
// 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)';
} catch (err) {
console.error('Erro ao gerar relatório compilado:', err);
emitirReportPreviewArea.innerHTML = `
<div style="color: #ff6b6b; text-align: center; margin-top: 50px; font-weight: 500;">
Erro ao gerar relatório: ${err.message}
</div>
`;
} finally {
btnGenerateCompiledReport.disabled = false;
btnGenerateCompiledReport.style.opacity = '1';
}
});
}
// Exportar para DOCX (Formato HTML-DOCX)
if (btnExportDocx) {
btnExportDocx.addEventListener('click', () => {
if (!activeCompiledReport) return;
const htmlContent = `
<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head>
<meta charset="utf-8">
<title>Relatório Pedagógico - ${activeCompiledReport.crianca}</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.5; color: #333; }
h1, h2, h3 { color: #1f2937; }
.header-info { background-color: #f3f4f6; padding: 12px; border-radius: 6px; margin-bottom: 20px; border: 1px solid #e5e7eb; }
.header-info div { margin-bottom: 4px; font-size: 11pt; }
</style>
</head>
<body>
<div class="header-info">
<div><b>Estudante:</b> ${activeCompiledReport.crianca}</div>
<div><b>Período:</b> ${activeCompiledReport.periodo}</div>
<div><b>Modelo:</b> ${activeCompiledReport.templateNome}</div>
<div><b>Quantidade de observações consolidadas:</b> ${activeCompiledReport.totalObservacoes}</div>
</div>
<hr/>
${marked.parse(activeCompiledReport.report)}
</body>
</html>
`;
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(`
<html>
<head>
<title>Relatório Pedagógico - ${activeCompiledReport.crianca}</title>
<style>
body { font-family: 'Outfit', 'Inter', -apple-system, sans-serif; padding: 40px; color: #333; line-height: 1.6; }
h1, h2, h3 { color: #1e293b; margin-top: 24px; margin-bottom: 12px; }
h1 { border-bottom: 2px solid #e2e8f0; padding-bottom: 10px; font-size: 22px; }
h2 { font-size: 18px; border-bottom: 1px solid #f1f5f9; padding-bottom: 6px; }
.meta-box { background: #f8fafc; padding: 16px; border-radius: 8px; margin-bottom: 24px; font-size: 14px; border: 1px solid #e2e8f0; }
.meta-item { margin-bottom: 6px; display: flex; }
.meta-label { font-weight: bold; color: #475569; width: 220px; }
p { margin-bottom: 14px; text-align: justify; }
ul, ol { margin-bottom: 14px; padding-left: 20px; }
li { margin-bottom: 6px; }
@media print {
body { padding: 0; }
button { display: none; }
}
</style>
</head>
<body>
<h1>Relatório Pedagógico de Desenvolvimento</h1>
<div class="meta-box">
<div class="meta-item"><span class="meta-label">Estudante:</span> <span>${activeCompiledReport.crianca}</span></div>
<div class="meta-item"><span class="meta-label">Período:</span> <span>${activeCompiledReport.periodo}</span></div>
<div class="meta-item"><span class="meta-label">Modelo Aplicado:</span> <span>${activeCompiledReport.templateNome}</span></div>
<div class="meta-item"><span class="meta-label">Quantidade de Observações:</span> <span>${activeCompiledReport.totalObservacoes}</span></div>
</div>
<div>${marked.parse(activeCompiledReport.report)}</div>
<script>
window.onload = function() {
window.print();
setTimeout(() => { window.close(); }, 500);
};
</script>
</body>
</html>
`);
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');
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 = '&times;';
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';
}
});
}
// Abrir e fechar modal Fábrica de Quadrinhos
const openComicsModal = () => {
fabricaQuadrinhosModal.style.display = 'flex';
comicsTemaInput.value = '';
comicsResultArea.style.display = 'none';
comicsGenerateLoader.style.display = 'none';
comicsVideoResult.style.display = 'none';
comicsVideoLoader.style.display = 'none';
btnGenerateComics.disabled = false;
comicsLoaderProgress.style.width = '0%';
// Inicializar lista de personagens com padrão se estiver vazia
comicsCharListContainer.innerHTML = '';
addCharacterRow("Menino", "Lucas", false);
addCharacterRow("Menina", "Mariana", false);
};
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) {
alert('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) {
alert('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 scriptResp = await fetch('/api/comics/generate-script', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tema,
quantidade: selectedComicsQty,
baloes: selectedComicsBubbles,
proporcao: selectedComicsRatio,
personagens,
cenario
})
});
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%';
// 2. Gerar cada quadrinho sequencialmente
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 || ''
});
}
// Renderizar quadrinhos na tela
comicsResultTitle.textContent = scriptData.title || 'Fábrica de Quadrinhos Camila';
comicsGridContainer.innerHTML = '';
generatedPanelsData.forEach(panel => {
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);
// Selo do número
const badge = document.createElement('div');
badge.className = 'comic-panel-number-badge';
badge.textContent = panel.panel_number;
imgContainer.appendChild(badge);
// Balão de fala absoluto por cima se habilitado e houver diálogo
if (selectedComicsBubbles && panel.dialogue) {
const bubble = document.createElement('div');
bubble.className = 'comic-speech-bubble';
bubble.textContent = panel.dialogue;
imgContainer.appendChild(bubble);
}
panelCard.appendChild(imgContainer);
// Legenda do texto abaixo
if (panel.dialogue) {
const caption = document.createElement('div');
caption.className = 'comic-caption-text';
caption.textContent = panel.dialogue;
panelCard.appendChild(caption);
}
comicsGridContainer.appendChild(panelCard);
});
comicsGenerateLoader.style.display = 'none';
comicsResultArea.style.display = 'flex';
btnGenerateComics.disabled = false;
} catch (err) {
console.error('Erro ao fabricar quadrinhos:', err);
alert('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;
}
});
}
};
// 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();
}