3315 lines
130 KiB
JavaScript
3315 lines
130 KiB
JavaScript
// Configuração do Markdown parser personalizado com Highlight.js integrado
|
||
const renderer = new marked.Renderer();
|
||
|
||
// Função auxiliar para escapar caracteres HTML
|
||
function escapeHtml(text) {
|
||
return text
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
// 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)
|
||
// ==========================================================================
|
||
|
||
let currentSpeakingBtn = null;
|
||
|
||
// 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 antes de falar
|
||
const clean = text
|
||
.replace(/```[\s\S]*?```/g, '')
|
||
.replace(/`[^`]+`/g, '')
|
||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||
.replace(/[*_#~\[\]>]/g, '')
|
||
.replace(/\n+/g, '. ')
|
||
.replace(/\s{2,}/g, ' ')
|
||
.trim();
|
||
|
||
if (!clean) {
|
||
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, '"')}">
|
||
<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, '"')}">
|
||
<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, '"')}">
|
||
<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, '"');
|
||
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', () => {
|
||
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 === '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
|
||
handleSendMessage(text, false, null, 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();
|
||
});
|
||
}
|
||
};
|
||
|
||
// 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();
|
||
}
|